mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
Compare commits
37 Commits
feature/un
...
fix/wecomb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8979056eb | ||
|
|
498d030da9 | ||
|
|
c111bf1714 | ||
|
|
6570f276d2 | ||
|
|
42e1e038bd | ||
|
|
d0e54a45c7 | ||
|
|
23fa47b07e | ||
|
|
4902c1d3b2 | ||
|
|
a6f96e5209 | ||
|
|
37c41bcfe4 | ||
|
|
9e223949a7 | ||
|
|
267bd72c63 | ||
|
|
af0d00e5e9 | ||
|
|
244e16c491 | ||
|
|
cad259fe39 | ||
|
|
bc3199bf29 | ||
|
|
127dc455c3 | ||
|
|
e8dc6fde53 | ||
|
|
4a97895dea | ||
|
|
3c0495fc51 | ||
|
|
dfd25deb68 | ||
|
|
f4db53b759 | ||
|
|
9f90341dcb | ||
|
|
67b726afb2 | ||
|
|
01852b81d4 | ||
|
|
4d6f109788 | ||
|
|
e1e5e7aedf | ||
|
|
cd53abc440 | ||
|
|
16a15a122a | ||
|
|
6fa653f232 | ||
|
|
c13971d7d6 | ||
|
|
9c659ce8fa | ||
|
|
c9fc64360f | ||
|
|
88a04fdbe8 | ||
|
|
bbe019f0c6 | ||
|
|
def798bf1f | ||
|
|
5290834b8b |
@@ -9,16 +9,14 @@ repos:
|
||||
# Run the formatter of backend.
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v3.1.0
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [javascript, jsx, ts, tsx, css, scss]
|
||||
additional_dependencies:
|
||||
- prettier@3.1.0
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: prettier
|
||||
name: prettier
|
||||
entry: npx --prefix web prettier --write --ignore-unknown
|
||||
language: system
|
||||
types_or: [javascript, jsx, ts, tsx, css, scss]
|
||||
|
||||
- id: lint-staged
|
||||
name: lint-staged
|
||||
entry: cd web && pnpm lint-staged
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 什么是 LangBot?
|
||||
|
||||
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
||||
|
||||
### 核心能力
|
||||
@@ -43,7 +41,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.9.3"
|
||||
version = "4.9.4"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -64,7 +64,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.3.3",
|
||||
"langbot-plugin==0.3.5",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.9.3'
|
||||
__version__ = '4.9.4'
|
||||
|
||||
@@ -272,15 +272,30 @@ class DingTalkClient:
|
||||
|
||||
message_data['Type'] = 'audio'
|
||||
elif incoming_message.message_type == 'file':
|
||||
down_list = incoming_message.get_down_list()
|
||||
if len(down_list) >= 2:
|
||||
message_data['File'] = await self.get_file_url(down_list[0])
|
||||
message_data['Name'] = down_list[1]
|
||||
# 获取原始数据字典并提取嵌套的文件信息
|
||||
raw_data = incoming_message.to_dict()
|
||||
file_info = raw_data.get('content', {})
|
||||
|
||||
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||
if isinstance(file_info, str):
|
||||
try:
|
||||
file_info = json.loads(file_info)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
file_info = {}
|
||||
|
||||
download_code = file_info.get('downloadCode')
|
||||
file_name = file_info.get('fileName')
|
||||
|
||||
if download_code and file_name:
|
||||
# 转换 downloadCode 为可下载的真实 URL
|
||||
message_data['File'] = await self.get_file_url(download_code)
|
||||
message_data['Name'] = file_name
|
||||
else:
|
||||
if self.logger:
|
||||
await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')
|
||||
await self.logger.error(f'Failed to extract file info from message content: {file_info}')
|
||||
message_data['File'] = None
|
||||
message_data['Name'] = None
|
||||
|
||||
message_data['Type'] = 'file'
|
||||
|
||||
copy_message_data = message_data.copy()
|
||||
|
||||
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .client import OpenClawWeixinClient as OpenClawWeixinClient
|
||||
from .types import ApiError as ApiError
|
||||
from .types import LoginResult as LoginResult
|
||||
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
@@ -0,0 +1,807 @@
|
||||
"""Async HTTP client for the OpenClaw WeChat API.
|
||||
|
||||
Implements the iLink Bot API protocol.
|
||||
Reference: https://github.com/epiral/weixin-bot
|
||||
|
||||
Endpoints: getUpdates (long-poll), sendMessage, getUploadUrl, getConfig, sendTyping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import typing
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .types import (
|
||||
ApiError,
|
||||
CDNMedia,
|
||||
FileItem,
|
||||
GetConfigResponse,
|
||||
GetUpdatesResponse,
|
||||
GetUploadUrlResponse,
|
||||
ImageItem,
|
||||
LoginResult,
|
||||
MessageItem,
|
||||
QRCodeResponse,
|
||||
QRStatusResponse,
|
||||
RefMessage,
|
||||
TextItem,
|
||||
VideoItem,
|
||||
VoiceItem,
|
||||
WeixinMessage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('openclaw-weixin-sdk')
|
||||
|
||||
DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
||||
CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
|
||||
|
||||
CHANNEL_VERSION = '1.0.0'
|
||||
|
||||
DEFAULT_API_TIMEOUT = 15
|
||||
DEFAULT_LONG_POLL_TIMEOUT = 40
|
||||
DEFAULT_CONFIG_TIMEOUT = 10
|
||||
DEFAULT_QR_POLL_TIMEOUT = 35
|
||||
|
||||
SESSION_EXPIRED_ERRCODE = -14
|
||||
|
||||
DEFAULT_BOT_TYPE = '3'
|
||||
|
||||
# Maximum text length per message chunk (WeChat limit)
|
||||
MAX_TEXT_CHUNK_SIZE = 2000
|
||||
|
||||
|
||||
def _random_wechat_uin() -> str:
|
||||
"""Generate the X-WECHAT-UIN header: random uint32 -> decimal string -> base64."""
|
||||
rand_bytes = os.urandom(4)
|
||||
uint32_val = struct.unpack('>I', rand_bytes)[0]
|
||||
return base64.b64encode(str(uint32_val).encode('utf-8')).decode('utf-8')
|
||||
|
||||
|
||||
def _build_base_info() -> dict:
|
||||
"""Build the base_info payload included in every API request."""
|
||||
return {'channel_version': CHANNEL_VERSION}
|
||||
|
||||
|
||||
def _chunk_text(text: str, max_size: int = MAX_TEXT_CHUNK_SIZE) -> list[str]:
|
||||
"""Split long text into chunks that fit within WeChat's message size limit."""
|
||||
if len(text) <= max_size:
|
||||
return [text]
|
||||
chunks = []
|
||||
while text:
|
||||
chunks.append(text[:max_size])
|
||||
text = text[max_size:]
|
||||
return chunks
|
||||
|
||||
|
||||
class OpenClawWeixinClient:
|
||||
"""Async client for the OpenClaw WeChat HTTP JSON API."""
|
||||
|
||||
def __init__(self, base_url: str, token: str):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.token = token
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
return self._session
|
||||
|
||||
async def close(self):
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def _build_headers(self) -> dict[str, str]:
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'AuthorizationType': 'ilink_bot_token',
|
||||
'X-WECHAT-UIN': _random_wechat_uin(),
|
||||
}
|
||||
if self.token:
|
||||
headers['Authorization'] = f'Bearer {self.token}'
|
||||
return headers
|
||||
|
||||
async def _post(self, endpoint: str, payload: dict, timeout: float = DEFAULT_API_TIMEOUT) -> dict:
|
||||
"""Make a POST request and return the JSON response.
|
||||
|
||||
Raises ApiError on HTTP errors or when the response contains a non-zero errcode.
|
||||
"""
|
||||
payload['base_info'] = _build_base_info()
|
||||
|
||||
session = await self._get_session()
|
||||
url = f'{self.base_url}/{endpoint}'
|
||||
headers = self._build_headers()
|
||||
|
||||
async with session.post(
|
||||
url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(
|
||||
f'OpenClaw API error {resp.status}: {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
|
||||
# Check for application-level errors in the response body
|
||||
errcode = data.get('errcode') or data.get('ret')
|
||||
if errcode and errcode != 0:
|
||||
raise ApiError(
|
||||
data.get('errmsg') or f'API errcode {errcode}',
|
||||
status=200,
|
||||
code=errcode,
|
||||
payload=data,
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
async def get_updates(
|
||||
self, get_updates_buf: str = '', timeout: float = DEFAULT_LONG_POLL_TIMEOUT
|
||||
) -> GetUpdatesResponse:
|
||||
"""Long-poll for new messages.
|
||||
|
||||
Note: This method does NOT raise ApiError for errcode responses —
|
||||
it returns them in the GetUpdatesResponse so the caller can handle
|
||||
session expiry and other errors with full context.
|
||||
"""
|
||||
try:
|
||||
# Bypass the errcode check in _post since get_updates needs
|
||||
# to return error info (e.g. session expired) to the caller.
|
||||
payload: dict = {'get_updates_buf': get_updates_buf}
|
||||
payload['base_info'] = _build_base_info()
|
||||
|
||||
session = await self._get_session()
|
||||
url = f'{self.base_url}/ilink/bot/getupdates'
|
||||
headers = self._build_headers()
|
||||
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(
|
||||
f'OpenClaw API error {resp.status}: {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
||||
except ApiError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if 'timeout' in str(e).lower():
|
||||
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
||||
raise
|
||||
|
||||
return _parse_get_updates_response(data)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
to_user_id: str,
|
||||
item_list: list[MessageItem],
|
||||
context_token: str = '',
|
||||
) -> None:
|
||||
"""Send a message to a user."""
|
||||
items_payload = [_message_item_to_dict(item) for item in item_list]
|
||||
|
||||
payload = {
|
||||
'msg': {
|
||||
'from_user_id': '',
|
||||
'to_user_id': to_user_id,
|
||||
'client_id': f'langbot-{uuid.uuid4().hex[:16]}',
|
||||
'message_type': WeixinMessage.TYPE_BOT,
|
||||
'message_state': WeixinMessage.STATE_FINISH,
|
||||
'item_list': items_payload,
|
||||
'context_token': context_token or None,
|
||||
}
|
||||
}
|
||||
await self._post('ilink/bot/sendmessage', payload)
|
||||
|
||||
async def send_text(self, to_user_id: str, text: str, context_token: str = '') -> None:
|
||||
"""Send a plain text message, automatically chunking if too long."""
|
||||
chunks = _chunk_text(text)
|
||||
for chunk in chunks:
|
||||
item = MessageItem(type=MessageItem.TEXT, text_item=TextItem(text=chunk))
|
||||
await self.send_message(to_user_id, [item], context_token)
|
||||
|
||||
async def get_config(self, ilink_user_id: str, context_token: str = '') -> GetConfigResponse:
|
||||
"""Get bot config including typing_ticket."""
|
||||
data = await self._post(
|
||||
'ilink/bot/getconfig',
|
||||
{'ilink_user_id': ilink_user_id, 'context_token': context_token or None},
|
||||
timeout=DEFAULT_CONFIG_TIMEOUT,
|
||||
)
|
||||
return GetConfigResponse(
|
||||
ret=data.get('ret'),
|
||||
errmsg=data.get('errmsg'),
|
||||
typing_ticket=data.get('typing_ticket'),
|
||||
)
|
||||
|
||||
async def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> None:
|
||||
"""Send typing indicator. status: 1=typing, 2=cancel."""
|
||||
await self._post(
|
||||
'ilink/bot/sendtyping',
|
||||
{
|
||||
'ilink_user_id': ilink_user_id,
|
||||
'typing_ticket': typing_ticket,
|
||||
'status': status,
|
||||
},
|
||||
timeout=DEFAULT_CONFIG_TIMEOUT,
|
||||
)
|
||||
|
||||
async def stop_typing(self, ilink_user_id: str, typing_ticket: str) -> None:
|
||||
"""Cancel the typing indicator for a user."""
|
||||
await self.send_typing(ilink_user_id, typing_ticket, status=2)
|
||||
|
||||
async def download_media(
|
||||
self,
|
||||
media: CDNMedia,
|
||||
) -> bytes:
|
||||
"""Download and decrypt a file from the WeChat CDN.
|
||||
|
||||
Args:
|
||||
media: CDNMedia object with encrypt_query_param and aes_key.
|
||||
|
||||
Returns:
|
||||
Decrypted file bytes.
|
||||
"""
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
|
||||
if not media.encrypt_query_param:
|
||||
raise ApiError('CDN media has no encrypt_query_param', status=0)
|
||||
if not media.aes_key:
|
||||
raise ApiError('CDN media has no aes_key', status=0)
|
||||
|
||||
# Derive 16-byte AES key
|
||||
# aes_key is base64-encoded; the decoded content may be:
|
||||
# - raw 16 bytes (direct AES key)
|
||||
# - 32-char hex string (decode hex to get 16 bytes)
|
||||
raw = base64.b64decode(media.aes_key)
|
||||
if len(raw) == 16:
|
||||
aes_key = raw
|
||||
elif len(raw) == 32:
|
||||
# Hex-encoded 16-byte key
|
||||
aes_key = bytes.fromhex(raw.decode('utf-8'))
|
||||
else:
|
||||
raise ApiError(f'Invalid AES key length: {len(raw)} (expected 16 or 32)', status=0)
|
||||
|
||||
# Download encrypted bytes from CDN
|
||||
session = await self._get_session()
|
||||
cdn_url = f'{CDN_BASE_URL}/download?encrypted_query_param={quote(media.encrypt_query_param, safe="")}'
|
||||
|
||||
async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status)
|
||||
encrypted = await resp.read()
|
||||
|
||||
# Decrypt AES-128-ECB with PKCS7 padding
|
||||
cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
|
||||
decryptor = cipher.decryptor()
|
||||
padded = decryptor.update(encrypted) + decryptor.finalize()
|
||||
|
||||
unpadder = PKCS7(128).unpadder()
|
||||
return unpadder.update(padded) + unpadder.finalize()
|
||||
|
||||
async def upload_media(
|
||||
self,
|
||||
file_bytes: bytes,
|
||||
to_user_id: str,
|
||||
media_type: int,
|
||||
) -> CDNMedia:
|
||||
"""Encrypt and upload media to WeChat CDN.
|
||||
|
||||
Args:
|
||||
file_bytes: Raw file bytes to upload.
|
||||
to_user_id: Recipient user ID.
|
||||
media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE.
|
||||
|
||||
Returns:
|
||||
CDNMedia with encrypt_query_param and aes_key for use in sendMessage.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
|
||||
# 1. Generate random 16-byte AES key
|
||||
raw_key = os.urandom(16)
|
||||
aes_key_hex = raw_key.hex() # 32-char hex string
|
||||
|
||||
# 2. Encode key for CDNMedia: base64(hex_string) — same for all media types
|
||||
# Matches official SDK: Buffer.from(aeskey_hex).toString("base64")
|
||||
encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
|
||||
|
||||
# 3. Encrypt file with AES-128-ECB + PKCS7
|
||||
padder = PKCS7(128).padder()
|
||||
padded = padder.update(file_bytes) + padder.finalize()
|
||||
cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||
|
||||
# 4. Get upload URL
|
||||
raw_md5 = hashlib.md5(file_bytes).hexdigest()
|
||||
filekey = os.urandom(16).hex() # 32-char hex, matches official SDK
|
||||
|
||||
upload_resp = await self.get_upload_url(
|
||||
filekey=filekey,
|
||||
media_type=media_type,
|
||||
to_user_id=to_user_id,
|
||||
rawsize=len(file_bytes),
|
||||
rawfilemd5=raw_md5,
|
||||
filesize=len(encrypted),
|
||||
aeskey=aes_key_hex, # hex string, as expected by the API
|
||||
)
|
||||
|
||||
if not upload_resp.upload_param:
|
||||
raise ApiError('Failed to get upload URL', status=0)
|
||||
|
||||
# 5. Upload to CDN
|
||||
# upload_param is an opaque token from the server — pass it as-is
|
||||
session = await self._get_session()
|
||||
cdn_url = f'{CDN_BASE_URL}/upload?encrypted_query_param={quote(upload_resp.upload_param, safe="")}&filekey={quote(filekey, safe="")}'
|
||||
logger.debug(
|
||||
'CDN upload: url=%s raw_size=%d encrypted_size=%d md5=%s aeskey=%s',
|
||||
cdn_url,
|
||||
len(file_bytes),
|
||||
len(encrypted),
|
||||
raw_md5,
|
||||
encoded_key,
|
||||
)
|
||||
|
||||
async with session.post(
|
||||
cdn_url,
|
||||
data=encrypted,
|
||||
headers={'Content-Type': 'application/octet-stream'},
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500])
|
||||
raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status)
|
||||
download_param = resp.headers.get('x-encrypted-param', '')
|
||||
|
||||
if not download_param:
|
||||
raise ApiError('CDN upload succeeded but no x-encrypted-param returned', status=0)
|
||||
|
||||
return CDNMedia(
|
||||
encrypt_query_param=download_param,
|
||||
aes_key=encoded_key,
|
||||
encrypt_type=1,
|
||||
)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
to_user_id: str,
|
||||
image_bytes: bytes,
|
||||
context_token: str = '',
|
||||
) -> None:
|
||||
"""Upload an image to CDN and send it."""
|
||||
media = await self.upload_media(image_bytes, to_user_id, media_type=1)
|
||||
item = MessageItem(
|
||||
type=MessageItem.IMAGE,
|
||||
image_item=ImageItem(
|
||||
media=media,
|
||||
aeskey=media.aes_key,
|
||||
),
|
||||
)
|
||||
await self.send_message(to_user_id, [item], context_token)
|
||||
|
||||
async def send_file(
|
||||
self,
|
||||
to_user_id: str,
|
||||
file_bytes: bytes,
|
||||
file_name: str,
|
||||
context_token: str = '',
|
||||
) -> None:
|
||||
"""Upload a file to CDN and send it."""
|
||||
import hashlib
|
||||
|
||||
media = await self.upload_media(file_bytes, to_user_id, media_type=3)
|
||||
item = MessageItem(
|
||||
type=MessageItem.FILE,
|
||||
file_item=FileItem(
|
||||
media=media,
|
||||
file_name=file_name,
|
||||
md5=hashlib.md5(file_bytes).hexdigest(),
|
||||
len=str(len(file_bytes)),
|
||||
),
|
||||
)
|
||||
await self.send_message(to_user_id, [item], context_token)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
to_user_id: str,
|
||||
voice_bytes: bytes,
|
||||
playtime: int = 0,
|
||||
context_token: str = '',
|
||||
) -> None:
|
||||
"""Upload a voice message to CDN and send it."""
|
||||
media = await self.upload_media(voice_bytes, to_user_id, media_type=4)
|
||||
item = MessageItem(
|
||||
type=MessageItem.VOICE,
|
||||
voice_item=VoiceItem(
|
||||
media=media,
|
||||
playtime=playtime,
|
||||
),
|
||||
)
|
||||
await self.send_message(to_user_id, [item], context_token)
|
||||
|
||||
async def get_upload_url(
|
||||
self,
|
||||
filekey: str,
|
||||
media_type: int,
|
||||
to_user_id: str,
|
||||
rawsize: int,
|
||||
rawfilemd5: str,
|
||||
filesize: int,
|
||||
thumb_rawsize: Optional[int] = None,
|
||||
thumb_rawfilemd5: Optional[str] = None,
|
||||
thumb_filesize: Optional[int] = None,
|
||||
aeskey: Optional[str] = None,
|
||||
) -> GetUploadUrlResponse:
|
||||
"""Get a pre-signed CDN upload URL."""
|
||||
payload: dict = {
|
||||
'filekey': filekey,
|
||||
'media_type': media_type,
|
||||
'to_user_id': to_user_id,
|
||||
'rawsize': rawsize,
|
||||
'rawfilemd5': rawfilemd5,
|
||||
'filesize': filesize,
|
||||
'no_need_thumb': True,
|
||||
}
|
||||
if thumb_rawsize is not None:
|
||||
payload['thumb_rawsize'] = thumb_rawsize
|
||||
if thumb_rawfilemd5 is not None:
|
||||
payload['thumb_rawfilemd5'] = thumb_rawfilemd5
|
||||
if thumb_filesize is not None:
|
||||
payload['thumb_filesize'] = thumb_filesize
|
||||
if aeskey is not None:
|
||||
payload['aeskey'] = aeskey
|
||||
|
||||
data = await self._post('ilink/bot/getuploadurl', payload)
|
||||
logger.debug('get_upload_url response: %s', data)
|
||||
return GetUploadUrlResponse(
|
||||
upload_param=data.get('upload_param'),
|
||||
thumb_upload_param=data.get('thumb_upload_param'),
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# QR Code Login
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def fetch_qrcode(self, bot_type: str = DEFAULT_BOT_TYPE) -> QRCodeResponse:
|
||||
"""Fetch a QR code for WeChat login authorization (GET, no auth needed)."""
|
||||
session = await self._get_session()
|
||||
url = f'{self.base_url}/ilink/bot/get_bot_qrcode?bot_type={bot_type}'
|
||||
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(
|
||||
f'Failed to fetch QR code: {resp.status} {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
|
||||
logger.debug(
|
||||
'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content'))
|
||||
)
|
||||
|
||||
return QRCodeResponse(
|
||||
qrcode=data.get('qrcode'),
|
||||
qrcode_img_content=data.get('qrcode_img_content'),
|
||||
)
|
||||
|
||||
async def _fetch_qr_image_base64(self, url: str) -> str:
|
||||
"""Generate a QR code image from the URL and return a data URI string.
|
||||
|
||||
The qrcode_img_content URL points to an HTML page (not a raw image),
|
||||
so we generate the QR code locally using the qrcode library.
|
||||
"""
|
||||
import qrcode
|
||||
|
||||
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color='black', back_color='white')
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
return f'data:image/png;base64,{b64}'
|
||||
|
||||
async def poll_qrcode_status(self, qrcode: str) -> QRStatusResponse:
|
||||
"""Long-poll the QR code scan status (GET with iLink-App-ClientVersion header)."""
|
||||
session = await self._get_session()
|
||||
url = f'{self.base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe="")}'
|
||||
headers = {'iLink-App-ClientVersion': '1'}
|
||||
|
||||
try:
|
||||
async with session.get(
|
||||
url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ApiError(
|
||||
f'Failed to poll QR status: {resp.status} {text}',
|
||||
status=resp.status,
|
||||
)
|
||||
data = await resp.json(content_type=None)
|
||||
logger.debug('QR status poll response: %s', data)
|
||||
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||
return QRStatusResponse(status='wait')
|
||||
|
||||
return QRStatusResponse(
|
||||
status=data.get('status'),
|
||||
bot_token=data.get('bot_token'),
|
||||
ilink_bot_id=data.get('ilink_bot_id'),
|
||||
baseurl=data.get('baseurl'),
|
||||
ilink_user_id=data.get('ilink_user_id'),
|
||||
)
|
||||
|
||||
async def login(
|
||||
self,
|
||||
max_retries: int = 5,
|
||||
poll_timeout_ms: int = 480_000,
|
||||
on_qrcode: Optional[typing.Callable[[str, str], typing.Any]] = None,
|
||||
on_status: Optional[typing.Callable[[str], typing.Any]] = None,
|
||||
) -> LoginResult:
|
||||
"""Complete QR code login flow with auto-retry on expiry.
|
||||
|
||||
Args:
|
||||
max_retries: Max number of QR code refreshes on expiry.
|
||||
poll_timeout_ms: Timeout per QR code in milliseconds.
|
||||
on_qrcode: Callback(qr_image_base64, qr_url) called each time a
|
||||
new QR code is fetched. Use this to display the QR code.
|
||||
on_status: Callback(status_str) called on each status poll change.
|
||||
|
||||
Returns:
|
||||
LoginResult with token, base_url, and account_id.
|
||||
|
||||
Raises:
|
||||
ApiError: On unrecoverable API errors.
|
||||
Exception: If all retries are exhausted.
|
||||
"""
|
||||
last_qr_base64: Optional[str] = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
qr_resp = await self.fetch_qrcode()
|
||||
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
||||
raise ApiError('Failed to get QR code from server', status=0)
|
||||
|
||||
# Convert QR image to base64 and notify caller
|
||||
last_qr_base64 = await self._fetch_qr_image_base64(qr_resp.qrcode_img_content)
|
||||
if on_qrcode:
|
||||
try:
|
||||
result = on_qrcode(last_qr_base64, qr_resp.qrcode_img_content)
|
||||
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.warning('on_qrcode callback error: %s', e)
|
||||
|
||||
# Poll until confirmed / expired / timeout
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + poll_timeout_ms / 1000.0
|
||||
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
status_resp = await self.poll_qrcode_status(qr_resp.qrcode)
|
||||
except Exception as e:
|
||||
logger.error('Error polling QR status: %s', e)
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
if on_status:
|
||||
try:
|
||||
cb_result = on_status(status_resp.status or 'unknown')
|
||||
if asyncio.iscoroutine(cb_result) or asyncio.isfuture(cb_result):
|
||||
await cb_result
|
||||
except Exception as e:
|
||||
logger.warning('on_status callback error: %s', e)
|
||||
|
||||
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
||||
new_base_url = status_resp.baseurl or self.base_url
|
||||
# Update this client instance as well
|
||||
self.token = status_resp.bot_token
|
||||
self.base_url = new_base_url.rstrip('/')
|
||||
return LoginResult(
|
||||
token=status_resp.bot_token,
|
||||
base_url=new_base_url,
|
||||
account_id=status_resp.ilink_bot_id or '',
|
||||
qr_image_base64=last_qr_base64,
|
||||
)
|
||||
|
||||
if status_resp.status == 'expired':
|
||||
break # retry with a new QR code
|
||||
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
# While-loop ended without break → poll timeout, treat as expired
|
||||
pass
|
||||
|
||||
remaining = max_retries - attempt - 1
|
||||
if remaining > 0:
|
||||
logger.info('QR code expired, refreshing... (%d retries left)', remaining)
|
||||
else:
|
||||
raise ApiError('QR code login failed: max retries exceeded', status=0)
|
||||
|
||||
# Should not reach here, but just in case
|
||||
raise ApiError('QR code login failed', status=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_cdn_media(data: Optional[dict]) -> Optional[CDNMedia]:
|
||||
if not data:
|
||||
return None
|
||||
return CDNMedia(
|
||||
encrypt_query_param=data.get('encrypt_query_param'),
|
||||
aes_key=data.get('aes_key'),
|
||||
encrypt_type=data.get('encrypt_type'),
|
||||
)
|
||||
|
||||
|
||||
def _parse_message_item(data: dict) -> MessageItem:
|
||||
item = MessageItem(
|
||||
type=data.get('type'),
|
||||
create_time_ms=data.get('create_time_ms'),
|
||||
update_time_ms=data.get('update_time_ms'),
|
||||
is_completed=data.get('is_completed'),
|
||||
msg_id=data.get('msg_id'),
|
||||
)
|
||||
|
||||
if data.get('text_item'):
|
||||
item.text_item = TextItem(text=data['text_item'].get('text'))
|
||||
|
||||
if data.get('image_item'):
|
||||
img = data['image_item']
|
||||
item.image_item = ImageItem(
|
||||
media=_parse_cdn_media(img.get('media')),
|
||||
thumb_media=_parse_cdn_media(img.get('thumb_media')),
|
||||
aeskey=img.get('aeskey'),
|
||||
url=img.get('url'),
|
||||
mid_size=img.get('mid_size'),
|
||||
)
|
||||
|
||||
if data.get('voice_item'):
|
||||
v = data['voice_item']
|
||||
item.voice_item = VoiceItem(
|
||||
media=_parse_cdn_media(v.get('media')),
|
||||
encode_type=v.get('encode_type'),
|
||||
playtime=v.get('playtime'),
|
||||
text=v.get('text'),
|
||||
)
|
||||
|
||||
if data.get('file_item'):
|
||||
f = data['file_item']
|
||||
item.file_item = FileItem(
|
||||
media=_parse_cdn_media(f.get('media')),
|
||||
file_name=f.get('file_name'),
|
||||
md5=f.get('md5'),
|
||||
len=f.get('len'),
|
||||
)
|
||||
|
||||
if data.get('video_item'):
|
||||
vid = data['video_item']
|
||||
item.video_item = VideoItem(
|
||||
media=_parse_cdn_media(vid.get('media')),
|
||||
video_size=vid.get('video_size'),
|
||||
play_length=vid.get('play_length'),
|
||||
video_md5=vid.get('video_md5'),
|
||||
thumb_media=_parse_cdn_media(vid.get('thumb_media')),
|
||||
)
|
||||
|
||||
if data.get('ref_msg'):
|
||||
ref = data['ref_msg']
|
||||
item.ref_msg = RefMessage(
|
||||
title=ref.get('title'),
|
||||
message_item=_parse_message_item(ref['message_item']) if ref.get('message_item') else None,
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def _parse_weixin_message(data: dict) -> WeixinMessage:
|
||||
msg = WeixinMessage(
|
||||
seq=data.get('seq'),
|
||||
message_id=data.get('message_id'),
|
||||
from_user_id=data.get('from_user_id'),
|
||||
to_user_id=data.get('to_user_id'),
|
||||
client_id=data.get('client_id'),
|
||||
create_time_ms=data.get('create_time_ms'),
|
||||
session_id=data.get('session_id'),
|
||||
group_id=data.get('group_id'),
|
||||
message_type=data.get('message_type'),
|
||||
message_state=data.get('message_state'),
|
||||
context_token=data.get('context_token'),
|
||||
)
|
||||
if data.get('item_list'):
|
||||
msg.item_list = [_parse_message_item(item) for item in data['item_list']]
|
||||
return msg
|
||||
|
||||
|
||||
def _parse_get_updates_response(data: dict) -> GetUpdatesResponse:
|
||||
resp = GetUpdatesResponse(
|
||||
ret=data.get('ret'),
|
||||
errcode=data.get('errcode'),
|
||||
errmsg=data.get('errmsg'),
|
||||
get_updates_buf=data.get('get_updates_buf'),
|
||||
longpolling_timeout_ms=data.get('longpolling_timeout_ms'),
|
||||
)
|
||||
if data.get('msgs'):
|
||||
resp.msgs = [_parse_weixin_message(m) for m in data['msgs']]
|
||||
return resp
|
||||
|
||||
|
||||
def _cdn_media_to_dict(media: Optional[CDNMedia]) -> Optional[dict]:
|
||||
if not media:
|
||||
return None
|
||||
d: dict = {}
|
||||
if media.encrypt_query_param is not None:
|
||||
d['encrypt_query_param'] = media.encrypt_query_param
|
||||
if media.aes_key is not None:
|
||||
d['aes_key'] = media.aes_key
|
||||
if media.encrypt_type is not None:
|
||||
d['encrypt_type'] = media.encrypt_type
|
||||
return d or None
|
||||
|
||||
|
||||
def _message_item_to_dict(item: MessageItem) -> dict:
|
||||
d: dict = {'type': item.type}
|
||||
|
||||
if item.text_item:
|
||||
d['text_item'] = {'text': item.text_item.text}
|
||||
|
||||
if item.image_item:
|
||||
img_d: dict = {}
|
||||
if item.image_item.media:
|
||||
img_d['media'] = _cdn_media_to_dict(item.image_item.media)
|
||||
if item.image_item.mid_size is not None:
|
||||
img_d['mid_size'] = item.image_item.mid_size
|
||||
d['image_item'] = img_d
|
||||
|
||||
if item.voice_item:
|
||||
voice_d: dict = {}
|
||||
if item.voice_item.media:
|
||||
voice_d['media'] = _cdn_media_to_dict(item.voice_item.media)
|
||||
if item.voice_item.playtime is not None:
|
||||
voice_d['playtime'] = item.voice_item.playtime
|
||||
d['voice_item'] = voice_d
|
||||
|
||||
if item.file_item:
|
||||
file_d: dict = {}
|
||||
if item.file_item.media:
|
||||
file_d['media'] = _cdn_media_to_dict(item.file_item.media)
|
||||
if item.file_item.file_name:
|
||||
file_d['file_name'] = item.file_item.file_name
|
||||
if item.file_item.len:
|
||||
file_d['len'] = item.file_item.len
|
||||
d['file_item'] = file_d
|
||||
|
||||
if item.video_item:
|
||||
vid_d: dict = {}
|
||||
if item.video_item.media:
|
||||
vid_d['media'] = _cdn_media_to_dict(item.video_item.media)
|
||||
if item.video_item.video_size is not None:
|
||||
vid_d['video_size'] = item.video_item.video_size
|
||||
d['video_item'] = vid_d
|
||||
|
||||
return d
|
||||
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Type definitions for the OpenClaw WeChat API, mirroring the upstream protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
SESSION_EXPIRED_ERRCODE = -14
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""Structured error raised by the OpenClaw WeChat API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status: int = 0,
|
||||
code: int | None = None,
|
||||
payload: Any = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.payload = payload
|
||||
|
||||
@property
|
||||
def is_session_expired(self) -> bool:
|
||||
return self.code == SESSION_EXPIRED_ERRCODE
|
||||
|
||||
|
||||
@dataclass
|
||||
class CDNMedia:
|
||||
encrypt_query_param: Optional[str] = None
|
||||
aes_key: Optional[str] = None
|
||||
encrypt_type: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextItem:
|
||||
text: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageItem:
|
||||
media: Optional[CDNMedia] = None
|
||||
thumb_media: Optional[CDNMedia] = None
|
||||
aeskey: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
mid_size: Optional[int] = None
|
||||
thumb_size: Optional[int] = None
|
||||
thumb_height: Optional[int] = None
|
||||
thumb_width: Optional[int] = None
|
||||
hd_size: Optional[int] = None
|
||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceItem:
|
||||
media: Optional[CDNMedia] = None
|
||||
encode_type: Optional[int] = None
|
||||
bits_per_sample: Optional[int] = None
|
||||
sample_rate: Optional[int] = None
|
||||
playtime: Optional[int] = None
|
||||
text: Optional[str] = None
|
||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileItem:
|
||||
media: Optional[CDNMedia] = None
|
||||
file_name: Optional[str] = None
|
||||
md5: Optional[str] = None
|
||||
len: Optional[str] = None
|
||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoItem:
|
||||
media: Optional[CDNMedia] = None
|
||||
video_size: Optional[int] = None
|
||||
play_length: Optional[int] = None
|
||||
video_md5: Optional[str] = None
|
||||
thumb_media: Optional[CDNMedia] = None
|
||||
thumb_size: Optional[int] = None
|
||||
thumb_height: Optional[int] = None
|
||||
thumb_width: Optional[int] = None
|
||||
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefMessage:
|
||||
message_item: Optional[MessageItem] = None
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageItem:
|
||||
"""A single content item inside a WeixinMessage."""
|
||||
|
||||
# Item types
|
||||
NONE = 0
|
||||
TEXT = 1
|
||||
IMAGE = 2
|
||||
VOICE = 3
|
||||
FILE = 4
|
||||
VIDEO = 5
|
||||
|
||||
type: Optional[int] = None
|
||||
create_time_ms: Optional[int] = None
|
||||
update_time_ms: Optional[int] = None
|
||||
is_completed: Optional[bool] = None
|
||||
msg_id: Optional[str] = None
|
||||
ref_msg: Optional[RefMessage] = None
|
||||
text_item: Optional[TextItem] = None
|
||||
image_item: Optional[ImageItem] = None
|
||||
voice_item: Optional[VoiceItem] = None
|
||||
file_item: Optional[FileItem] = None
|
||||
video_item: Optional[VideoItem] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeixinMessage:
|
||||
"""Unified message from getUpdates or for sendMessage."""
|
||||
|
||||
# Message types
|
||||
TYPE_USER = 1
|
||||
TYPE_BOT = 2
|
||||
|
||||
# Message states
|
||||
STATE_NEW = 0
|
||||
STATE_GENERATING = 1
|
||||
STATE_FINISH = 2
|
||||
|
||||
seq: Optional[int] = None
|
||||
message_id: Optional[int] = None
|
||||
from_user_id: Optional[str] = None
|
||||
to_user_id: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
create_time_ms: Optional[int] = None
|
||||
update_time_ms: Optional[int] = None
|
||||
delete_time_ms: Optional[int] = None
|
||||
session_id: Optional[str] = None
|
||||
group_id: Optional[str] = None
|
||||
message_type: Optional[int] = None
|
||||
message_state: Optional[int] = None
|
||||
item_list: Optional[list[MessageItem]] = None
|
||||
context_token: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetUpdatesResponse:
|
||||
ret: Optional[int] = None
|
||||
errcode: Optional[int] = None
|
||||
errmsg: Optional[str] = None
|
||||
msgs: list[WeixinMessage] = field(default_factory=list)
|
||||
get_updates_buf: Optional[str] = None
|
||||
longpolling_timeout_ms: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetConfigResponse:
|
||||
ret: Optional[int] = None
|
||||
errmsg: Optional[str] = None
|
||||
typing_ticket: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetUploadUrlResponse:
|
||||
upload_param: Optional[str] = None
|
||||
thumb_upload_param: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class QRCodeResponse:
|
||||
"""Response from get_bot_qrcode endpoint."""
|
||||
|
||||
qrcode: Optional[str] = None
|
||||
qrcode_img_content: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class QRStatusResponse:
|
||||
"""Response from get_qrcode_status endpoint."""
|
||||
|
||||
status: Optional[str] = None # "wait" | "scaned" | "confirmed" | "expired"
|
||||
bot_token: Optional[str] = None
|
||||
ilink_bot_id: Optional[str] = None
|
||||
baseurl: Optional[str] = None
|
||||
ilink_user_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginResult:
|
||||
"""Result returned by the login flow."""
|
||||
|
||||
token: str
|
||||
base_url: str
|
||||
account_id: str
|
||||
qr_image_base64: Optional[str] = None # data URI of the last QR code shown
|
||||
@@ -6,7 +6,8 @@ import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
import re
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
@@ -199,52 +200,139 @@ class StreamSessionManager:
|
||||
self._msg_index.pop(msg_id, None)
|
||||
|
||||
|
||||
async def download_encrypted_file(download_url: str, encoding_aes_key: str, logger: EventLogger) -> Optional[str]:
|
||||
"""Download an AES-encrypted file from WeChat Work and return as data URI.
|
||||
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||
"""Decrypt AES-256-CBC encrypted file data.
|
||||
|
||||
Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py).
|
||||
|
||||
Args:
|
||||
download_url: The encrypted file download URL.
|
||||
encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '=').
|
||||
logger: Logger instance.
|
||||
encrypted_data: The raw encrypted bytes.
|
||||
aes_key_str: Base64-encoded AES key (may lack padding).
|
||||
|
||||
Returns:
|
||||
A data URI string (e.g. 'data:image/jpeg;base64,...') or None on failure.
|
||||
Decrypted bytes with PKCS#7 padding removed.
|
||||
"""
|
||||
if not download_url:
|
||||
return None
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(download_url)
|
||||
if response.status_code != 200:
|
||||
await logger.error(f'failed to get file: {response.text}')
|
||||
return None
|
||||
encrypted_bytes = response.content
|
||||
if not encrypted_data:
|
||||
raise ValueError('encrypted_data is empty')
|
||||
if not aes_key_str:
|
||||
raise ValueError('aes_key is empty')
|
||||
|
||||
aes_key = base64.b64decode(encoding_aes_key + '=')
|
||||
iv = aes_key[:16]
|
||||
# Python's base64.b64decode requires proper padding (length % 4 == 0).
|
||||
# Node.js Buffer.from tolerates missing '=', so we must pad manually.
|
||||
remainder = len(aes_key_str) % 4
|
||||
if remainder != 0:
|
||||
aes_key_str = aes_key_str + '=' * (4 - remainder)
|
||||
key = base64.b64decode(aes_key_str)
|
||||
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted_bytes)
|
||||
iv = key[:16]
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
|
||||
# Ensure encrypted data is aligned to AES block size (16 bytes).
|
||||
# Node.js setAutoPadding(false) silently handles unaligned data,
|
||||
# but PyCryptodome will raise an error.
|
||||
block_size = 16
|
||||
data_remainder = len(encrypted_data) % block_size
|
||||
if data_remainder != 0:
|
||||
encrypted_data = encrypted_data + b'\x00' * (block_size - data_remainder)
|
||||
|
||||
decrypted = cipher.decrypt(encrypted_data)
|
||||
|
||||
# Remove PKCS#7 padding with validation
|
||||
if len(decrypted) == 0:
|
||||
raise ValueError('Decrypted data is empty')
|
||||
|
||||
pad_len = decrypted[-1]
|
||||
decrypted = decrypted[:-pad_len]
|
||||
if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
|
||||
raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}')
|
||||
|
||||
if decrypted.startswith(b'\xff\xd8'):
|
||||
# Verify all padding bytes are consistent
|
||||
for i in range(len(decrypted) - pad_len, len(decrypted)):
|
||||
if decrypted[i] != pad_len:
|
||||
raise ValueError('Invalid PKCS#7 padding: padding bytes mismatch')
|
||||
|
||||
return decrypted[: len(decrypted) - pad_len]
|
||||
|
||||
|
||||
def _extract_filename(content_disposition: str) -> Optional[str]:
|
||||
"""Extract filename from a Content-Disposition header value."""
|
||||
if not content_disposition:
|
||||
return None
|
||||
# RFC 5987: filename*=UTF-8''xxx
|
||||
utf8_match = re.search(r"filename\*=UTF-8''([^;\s]+)", content_disposition, re.IGNORECASE)
|
||||
if utf8_match:
|
||||
return unquote(utf8_match.group(1))
|
||||
# Standard: filename="xxx" or filename=xxx
|
||||
match = re.search(r'filename="?([^";\s]+)"?', content_disposition, re.IGNORECASE)
|
||||
if match:
|
||||
return unquote(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _bytes_to_data_uri(data: bytes) -> str:
|
||||
"""Convert raw bytes to a data URI with auto-detected MIME type."""
|
||||
if data.startswith(b'\xff\xd8'):
|
||||
mime_type = 'image/jpeg'
|
||||
elif decrypted.startswith(b'\x89PNG'):
|
||||
elif data.startswith(b'\x89PNG'):
|
||||
mime_type = 'image/png'
|
||||
elif decrypted.startswith((b'GIF87a', b'GIF89a')):
|
||||
elif data.startswith((b'GIF87a', b'GIF89a')):
|
||||
mime_type = 'image/gif'
|
||||
elif decrypted.startswith(b'BM'):
|
||||
elif data.startswith(b'BM'):
|
||||
mime_type = 'image/bmp'
|
||||
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'):
|
||||
elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'):
|
||||
mime_type = 'image/tiff'
|
||||
elif data[:4] == b'%PDF':
|
||||
mime_type = 'application/pdf'
|
||||
elif data[:4] == b'PK\x03\x04':
|
||||
mime_type = 'application/zip'
|
||||
else:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
base64_str = base64.b64encode(decrypted).decode('utf-8')
|
||||
base64_str = base64.b64encode(data).decode('utf-8')
|
||||
return f'data:{mime_type};base64,{base64_str}'
|
||||
|
||||
|
||||
async def download_encrypted_file(
|
||||
download_url: str, aes_key: str, logger: EventLogger
|
||||
) -> Tuple[Optional[bytes], Optional[str]]:
|
||||
"""Download an AES-encrypted file from WeChat Work and decrypt it.
|
||||
|
||||
Args:
|
||||
download_url: The encrypted file download URL.
|
||||
aes_key: The AES key for decryption (base64-encoded, per-message aeskey
|
||||
or platform EncodingAESKey).
|
||||
logger: Logger instance.
|
||||
|
||||
Returns:
|
||||
A tuple of (decrypted_bytes, filename) or (None, None) on failure.
|
||||
"""
|
||||
if not download_url:
|
||||
return None, None
|
||||
if not aes_key:
|
||||
await logger.error('download_encrypted_file: aes_key is empty, cannot decrypt')
|
||||
return None, None
|
||||
|
||||
filename: Optional[str] = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(download_url)
|
||||
if response.status_code != 200:
|
||||
await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
|
||||
return None, None
|
||||
encrypted_bytes = response.content
|
||||
filename = _extract_filename(response.headers.get('content-disposition', ''))
|
||||
except Exception:
|
||||
await logger.error(f'Failed to download file: {traceback.format_exc()}')
|
||||
return None, None
|
||||
|
||||
try:
|
||||
decrypted = _decrypt_file(encrypted_bytes, aes_key)
|
||||
return decrypted, filename
|
||||
except Exception:
|
||||
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
|
||||
return None, None
|
||||
|
||||
|
||||
async def parse_wecom_bot_message(
|
||||
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
|
||||
) -> dict[str, Any]:
|
||||
@@ -273,10 +361,22 @@ async def parse_wecom_bot_message(
|
||||
|
||||
max_inline_file_size = 5 * 1024 * 1024
|
||||
|
||||
async def _safe_download(url: str):
|
||||
async def _safe_download(url: str, per_msg_aeskey: str = '') -> Tuple[Optional[bytes], Optional[str]]:
|
||||
"""Download and decrypt a file, preferring per-message aeskey over platform key."""
|
||||
if not url:
|
||||
return None
|
||||
return await download_encrypted_file(url, encoding_aes_key, logger)
|
||||
return None, None
|
||||
key = per_msg_aeskey or encoding_aes_key
|
||||
if not key:
|
||||
await logger.warning('No AES key available for file decryption, skipping download')
|
||||
return None, None
|
||||
return await download_encrypted_file(url, key, logger)
|
||||
|
||||
async def _safe_download_as_data_uri(url: str, per_msg_aeskey: str = '') -> Optional[str]:
|
||||
"""Download, decrypt, and convert to data URI for backward compatibility."""
|
||||
data, _filename = await _safe_download(url, per_msg_aeskey)
|
||||
if data:
|
||||
return _bytes_to_data_uri(data)
|
||||
return None
|
||||
|
||||
if msg_type == 'text':
|
||||
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||
@@ -285,14 +385,17 @@ async def parse_wecom_bot_message(
|
||||
'content', ''
|
||||
)
|
||||
elif msg_type == 'image':
|
||||
picurl = msg_json.get('image', {}).get('url', '')
|
||||
base64_data = await _safe_download(picurl)
|
||||
image_info = msg_json.get('image', {})
|
||||
picurl = image_info.get('url', '')
|
||||
per_msg_aeskey = image_info.get('aeskey', '')
|
||||
base64_data = await _safe_download_as_data_uri(picurl, per_msg_aeskey)
|
||||
if base64_data:
|
||||
message_data['picurl'] = base64_data
|
||||
message_data['images'] = [base64_data]
|
||||
elif msg_type == 'voice':
|
||||
voice_info = msg_json.get('voice', {}) or {}
|
||||
download_url = voice_info.get('url')
|
||||
per_msg_aeskey = voice_info.get('aeskey', '')
|
||||
message_data['voice'] = {
|
||||
'url': download_url,
|
||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||
@@ -302,12 +405,13 @@ async def parse_wecom_bot_message(
|
||||
if voice_info.get('content'):
|
||||
message_data['content'] = voice_info.get('content')
|
||||
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||
voice_base64 = await _safe_download(download_url)
|
||||
voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||
if voice_base64:
|
||||
message_data['voice']['base64'] = voice_base64
|
||||
elif msg_type == 'video':
|
||||
video_info = msg_json.get('video', {}) or {}
|
||||
download_url = video_info.get('url')
|
||||
per_msg_aeskey = video_info.get('aeskey', '')
|
||||
video_data = {
|
||||
'url': download_url,
|
||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||
@@ -316,13 +420,14 @@ async def parse_wecom_bot_message(
|
||||
'filename': video_info.get('filename') or video_info.get('name'),
|
||||
}
|
||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
video_base64 = await _safe_download(download_url)
|
||||
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||
if video_base64:
|
||||
video_data['base64'] = video_base64
|
||||
message_data['video'] = video_data
|
||||
elif msg_type == 'file':
|
||||
file_info = msg_json.get('file', {}) or {}
|
||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||
per_msg_aeskey = file_info.get('aeskey', '')
|
||||
file_data = {
|
||||
'filename': file_info.get('filename') or file_info.get('name'),
|
||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||
@@ -332,9 +437,11 @@ async def parse_wecom_bot_message(
|
||||
'extra': file_info,
|
||||
}
|
||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
file_base64 = await _safe_download(download_url)
|
||||
if file_base64:
|
||||
file_data['base64'] = file_base64
|
||||
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
||||
if file_bytes:
|
||||
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||
if dl_filename and not file_data.get('filename'):
|
||||
file_data['filename'] = dl_filename
|
||||
message_data['file'] = file_data
|
||||
elif msg_type == 'link':
|
||||
message_data['link'] = msg_json.get('link', {})
|
||||
@@ -355,13 +462,16 @@ async def parse_wecom_bot_message(
|
||||
if item_type == 'text':
|
||||
texts.append(item.get('text', {}).get('content', ''))
|
||||
elif item_type == 'image':
|
||||
img_url = item.get('image', {}).get('url')
|
||||
base64_data = await _safe_download(img_url)
|
||||
img_info = item.get('image', {})
|
||||
img_url = img_info.get('url')
|
||||
img_aeskey = img_info.get('aeskey', '')
|
||||
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||
if base64_data:
|
||||
images.append(base64_data)
|
||||
elif item_type == 'file':
|
||||
file_info = item.get('file', {}) or {}
|
||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||
item_aeskey = file_info.get('aeskey', '')
|
||||
file_data = {
|
||||
'filename': file_info.get('filename') or file_info.get('name'),
|
||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||
@@ -371,13 +481,16 @@ async def parse_wecom_bot_message(
|
||||
'extra': file_info,
|
||||
}
|
||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
file_base64 = await _safe_download(download_url)
|
||||
if file_base64:
|
||||
file_data['base64'] = file_base64
|
||||
file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
|
||||
if file_bytes:
|
||||
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||
if dl_filename and not file_data.get('filename'):
|
||||
file_data['filename'] = dl_filename
|
||||
files.append(file_data)
|
||||
elif item_type == 'voice':
|
||||
voice_info = item.get('voice', {}) or {}
|
||||
download_url = voice_info.get('url')
|
||||
item_aeskey = voice_info.get('aeskey', '')
|
||||
voice_data = {
|
||||
'url': download_url,
|
||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||
@@ -387,13 +500,14 @@ async def parse_wecom_bot_message(
|
||||
if voice_info.get('content'):
|
||||
texts.append(voice_info.get('content'))
|
||||
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
voice_base64 = await _safe_download(download_url)
|
||||
voice_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
|
||||
if voice_base64:
|
||||
voice_data['base64'] = voice_base64
|
||||
voices.append(voice_data)
|
||||
elif item_type == 'video':
|
||||
video_info = item.get('video', {}) or {}
|
||||
download_url = video_info.get('url')
|
||||
item_aeskey = video_info.get('aeskey', '')
|
||||
video_data = {
|
||||
'url': download_url,
|
||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||
@@ -402,7 +516,7 @@ async def parse_wecom_bot_message(
|
||||
'filename': video_info.get('filename') or video_info.get('name'),
|
||||
}
|
||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
video_base64 = await _safe_download(download_url)
|
||||
video_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
|
||||
if video_base64:
|
||||
video_data['base64'] = video_base64
|
||||
videos.append(video_data)
|
||||
@@ -770,7 +884,10 @@ class WecomBotClient:
|
||||
return decorator
|
||||
|
||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||
return await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||
if data:
|
||||
return _bytes_to_data_uri(data)
|
||||
return None
|
||||
|
||||
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
import binascii
|
||||
import httpx
|
||||
import traceback
|
||||
from urllib.parse import quote
|
||||
from quart import Quart
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Callable, Dict, Any
|
||||
@@ -67,6 +68,31 @@ class WecomClient:
|
||||
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
||||
raise Exception(f'未获取access token: {data}')
|
||||
|
||||
async def get_user_info(self, userid: str) -> dict:
|
||||
"""
|
||||
Get user information by user ID using the application secret.
|
||||
|
||||
Args:
|
||||
userid: The user ID to look up.
|
||||
|
||||
Returns:
|
||||
dict: User information including 'name' field.
|
||||
"""
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
|
||||
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
data = response.json()
|
||||
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.get_user_info(userid)
|
||||
if data.get('errcode', 0) != 0:
|
||||
await self.logger.error(f'获取用户信息失败:{data}')
|
||||
return {}
|
||||
return data
|
||||
|
||||
async def get_users(self):
|
||||
if not self.check_access_token_for_contacts():
|
||||
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
||||
|
||||
@@ -13,9 +13,9 @@ from .. import group
|
||||
@group.group_class('files', '/api/v1/files')
|
||||
class FilesRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _(image_key: str) -> quart.Response:
|
||||
if '/' in image_key or '\\' in image_key:
|
||||
if '..' in image_key or '\\' in image_key:
|
||||
return quart.Response(status=404)
|
||||
|
||||
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
||||
|
||||
@@ -105,6 +105,28 @@ class HTTPController:
|
||||
):
|
||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||
path += '.html'
|
||||
elif path.startswith('home/'):
|
||||
# SPA fallback for /home/* sub-routes.
|
||||
# Entity detail views use query params (e.g. /home/bots?id=uuid),
|
||||
# so the pre-rendered list page is served directly via path + '.html'.
|
||||
# This fallback handles any remaining unmatched sub-paths.
|
||||
segments = path.rstrip('/').split('/')
|
||||
|
||||
# Walk up parent segments looking for matching .html files
|
||||
for i in range(len(segments) - 1, 0, -1):
|
||||
parent_path = '/'.join(segments[:i]) + '.html'
|
||||
if os.path.exists(os.path.join(frontend_path, parent_path)):
|
||||
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
# Final fallback to index.html for /home/* routes
|
||||
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
else:
|
||||
return await quart.send_from_directory(frontend_path, '404.html')
|
||||
|
||||
|
||||
@@ -16,6 +16,57 @@ class MonitoringService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
# ========== Cleanup Methods ==========
|
||||
|
||||
async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]:
|
||||
"""Delete monitoring records older than the specified retention period.
|
||||
|
||||
Args:
|
||||
retention_days: Number of days to retain records.
|
||||
|
||||
Returns:
|
||||
A dict mapping table name to the number of deleted rows.
|
||||
"""
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
||||
days=retention_days
|
||||
)
|
||||
|
||||
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
|
||||
(
|
||||
'monitoring_messages',
|
||||
persistence_monitoring.MonitoringMessage,
|
||||
persistence_monitoring.MonitoringMessage.timestamp,
|
||||
),
|
||||
(
|
||||
'monitoring_llm_calls',
|
||||
persistence_monitoring.MonitoringLLMCall,
|
||||
persistence_monitoring.MonitoringLLMCall.timestamp,
|
||||
),
|
||||
(
|
||||
'monitoring_embedding_calls',
|
||||
persistence_monitoring.MonitoringEmbeddingCall,
|
||||
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
||||
),
|
||||
(
|
||||
'monitoring_errors',
|
||||
persistence_monitoring.MonitoringError,
|
||||
persistence_monitoring.MonitoringError.timestamp,
|
||||
),
|
||||
(
|
||||
'monitoring_sessions',
|
||||
persistence_monitoring.MonitoringSession,
|
||||
persistence_monitoring.MonitoringSession.last_activity,
|
||||
),
|
||||
]
|
||||
|
||||
deleted_counts: dict[str, int] = {}
|
||||
|
||||
for table_name, model_cls, ts_column in tables_and_columns:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
|
||||
deleted_counts[table_name] = result.rowcount
|
||||
|
||||
return deleted_counts
|
||||
|
||||
# ========== Recording Methods ==========
|
||||
|
||||
async def record_message(
|
||||
|
||||
@@ -188,6 +188,34 @@ class Application:
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
# Start monitoring data cleanup task if enabled
|
||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||
if auto_cleanup_cfg.get('enabled', True):
|
||||
retention_days = auto_cleanup_cfg.get('retention_days', 30)
|
||||
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
|
||||
|
||||
async def monitoring_cleanup_loop():
|
||||
check_interval_seconds = check_interval_hours * 3600
|
||||
while True:
|
||||
try:
|
||||
deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
|
||||
total_deleted = sum(deleted.values())
|
||||
if total_deleted > 0:
|
||||
self.logger.info(
|
||||
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
|
||||
f'(retention={retention_days}d): {deleted}'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Monitoring auto-cleanup error: {e}')
|
||||
await asyncio.sleep(check_interval_seconds)
|
||||
|
||||
self.task_mgr.create_task(
|
||||
monitoring_cleanup_loop(),
|
||||
name='monitoring-cleanup',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
self.task_mgr.create_task(
|
||||
never_ending(),
|
||||
name='never-ending-task',
|
||||
|
||||
@@ -2,18 +2,16 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
import json
|
||||
import uuid
|
||||
|
||||
|
||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||
import sqlalchemy
|
||||
|
||||
from . import database, migration
|
||||
from ..entity.persistence import base, pipeline, metadata, model as persistence_model
|
||||
from ..entity.persistence import base, metadata, model as persistence_model
|
||||
from ..entity import persistence
|
||||
from ..core import app
|
||||
from ..utils import constants, importutil
|
||||
from ..api.http.service import pipeline as pipeline_service
|
||||
from . import databases, migrations
|
||||
|
||||
importutil.import_modules_in_pkg(databases)
|
||||
@@ -78,7 +76,6 @@ class PersistenceManager:
|
||||
|
||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||
|
||||
await self.write_default_pipeline()
|
||||
await self.write_space_model_providers()
|
||||
|
||||
async def create_tables(self):
|
||||
@@ -101,29 +98,6 @@ class PersistenceManager:
|
||||
if row is None:
|
||||
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
|
||||
|
||||
async def write_default_pipeline(self):
|
||||
# write default pipeline
|
||||
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
|
||||
default_pipeline_uuid = None
|
||||
if result.first() is None:
|
||||
self.ap.logger.info('Creating default pipeline...')
|
||||
|
||||
pipeline_config = json.loads(importutil.read_resource_file('templates/default-pipeline-config.json'))
|
||||
|
||||
default_pipeline_uuid = str(uuid.uuid4())
|
||||
pipeline_data = {
|
||||
'uuid': default_pipeline_uuid,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
'stages': pipeline_service.default_stage_order,
|
||||
'is_default': True,
|
||||
'name': 'ChatPipeline',
|
||||
'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线',
|
||||
'config': pipeline_config,
|
||||
'extensions_preferences': {},
|
||||
}
|
||||
|
||||
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
|
||||
|
||||
async def write_space_model_providers(self):
|
||||
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
|
||||
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
|
||||
|
||||
577
src/langbot/pkg/platform/sources/openclaw_weixin.py
Normal file
577
src/langbot/pkg/platform/sources/openclaw_weixin.py
Normal file
@@ -0,0 +1,577 @@
|
||||
"""OpenClaw WeChat adapter for LangBot.
|
||||
|
||||
Uses the OpenClaw WeChat HTTP JSON API (long-poll getUpdates + sendMessage)
|
||||
to integrate personal WeChat accounts with LangBot.
|
||||
|
||||
Reference: https://github.com/epiral/weixin-bot
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
import sqlalchemy
|
||||
|
||||
from langbot.libs.openclaw_weixin_api.client import (
|
||||
DEFAULT_BASE_URL,
|
||||
SESSION_EXPIRED_ERRCODE,
|
||||
OpenClawWeixinClient,
|
||||
)
|
||||
from langbot.libs.openclaw_weixin_api.types import (
|
||||
MessageItem,
|
||||
WeixinMessage,
|
||||
)
|
||||
from langbot.pkg.entity.persistence import bot as persistence_bot
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Converts between LangBot MessageChain and OpenClaw WeChat message items."""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
|
||||
"""Convert LangBot MessageChain to a list of OpenClaw message item dicts."""
|
||||
items = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
items.append({'type': MessageItem.TEXT, 'text_item': {'text': component.text}})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
# OpenClaw WeChat only supports text messages without CDN upload.
|
||||
# For images, we send a placeholder text with the URL if available.
|
||||
if component.url:
|
||||
items.append(
|
||||
{
|
||||
'type': MessageItem.TEXT,
|
||||
'text_item': {'text': f'[Image: {component.url}]'},
|
||||
}
|
||||
)
|
||||
elif component.base64:
|
||||
items.append(
|
||||
{
|
||||
'type': MessageItem.TEXT,
|
||||
'text_item': {'text': '[Image]'},
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.File):
|
||||
if component.name:
|
||||
items.append(
|
||||
{
|
||||
'type': MessageItem.TEXT,
|
||||
'text_item': {'text': f'[File: {component.name}]'},
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
items.extend(await OpenClawWeixinMessageConverter.yiri2target(node.message_chain))
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
msg: WeixinMessage,
|
||||
) -> platform_message.MessageChain:
|
||||
"""Convert an OpenClaw WeixinMessage to LangBot MessageChain."""
|
||||
components: list[platform_message.MessageComponent] = []
|
||||
|
||||
if not msg.item_list:
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
for item in msg.item_list:
|
||||
if item.type == MessageItem.TEXT and item.text_item and item.text_item.text:
|
||||
text = item.text_item.text
|
||||
|
||||
# Handle quoted messages
|
||||
if item.ref_msg:
|
||||
ref_parts = []
|
||||
if item.ref_msg.title:
|
||||
ref_parts.append(item.ref_msg.title)
|
||||
if item.ref_msg.message_item:
|
||||
ref_item = item.ref_msg.message_item
|
||||
if ref_item.text_item and ref_item.text_item.text:
|
||||
ref_parts.append(ref_item.text_item.text)
|
||||
if ref_parts:
|
||||
components.append(
|
||||
platform_message.Quote(
|
||||
sender_id='',
|
||||
origin=platform_message.MessageChain(
|
||||
[platform_message.Plain(text=' | '.join(ref_parts))]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
components.append(platform_message.Plain(text=text))
|
||||
|
||||
elif item.type == MessageItem.IMAGE and item.image_item:
|
||||
if hasattr(item.image_item, '_downloaded_bytes') and item.image_item._downloaded_bytes:
|
||||
b64 = base64.b64encode(item.image_item._downloaded_bytes).decode('utf-8')
|
||||
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
|
||||
else:
|
||||
components.append(platform_message.Unknown(text='[Image]'))
|
||||
|
||||
elif item.type == MessageItem.VOICE and item.voice_item:
|
||||
# Voice with speech-to-text: use the transcribed text
|
||||
if item.voice_item.text:
|
||||
components.append(platform_message.Plain(text=item.voice_item.text))
|
||||
else:
|
||||
components.append(platform_message.Unknown(text='[Voice]'))
|
||||
|
||||
# TODO: enable after full testing
|
||||
# elif item.type == MessageItem.VOICE and item.voice_item:
|
||||
# if item.voice_item.text:
|
||||
# components.append(platform_message.Plain(text=item.voice_item.text))
|
||||
# elif hasattr(item.voice_item, '_downloaded_bytes') and item.voice_item._downloaded_bytes:
|
||||
# b64 = base64.b64encode(item.voice_item._downloaded_bytes).decode('utf-8')
|
||||
# components.append(
|
||||
# platform_message.Voice(
|
||||
# base64=b64,
|
||||
# length=item.voice_item.playtime or 0,
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# components.append(
|
||||
# platform_message.Voice(
|
||||
# length=item.voice_item.playtime or 0,
|
||||
# )
|
||||
# )
|
||||
|
||||
elif item.type == MessageItem.FILE and item.file_item:
|
||||
components.append(platform_message.Unknown(text=f'[File: {item.file_item.file_name or ""}]'))
|
||||
|
||||
# TODO: enable after full testing
|
||||
# elif item.type == MessageItem.FILE and item.file_item:
|
||||
# file_name = item.file_item.file_name or ''
|
||||
# file_size = int(item.file_item.len) if item.file_item.len else 0
|
||||
# if hasattr(item.file_item, '_downloaded_bytes') and item.file_item._downloaded_bytes:
|
||||
# b64 = base64.b64encode(item.file_item._downloaded_bytes).decode('utf-8')
|
||||
# components.append(
|
||||
# platform_message.File(
|
||||
# name=file_name,
|
||||
# size=file_size,
|
||||
# base64=b64,
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# components.append(
|
||||
# platform_message.File(
|
||||
# name=file_name,
|
||||
# size=file_size,
|
||||
# )
|
||||
# )
|
||||
|
||||
elif item.type == MessageItem.VIDEO and item.video_item:
|
||||
components.append(platform_message.Unknown(text='[Video]'))
|
||||
|
||||
# TODO: enable after full testing
|
||||
# elif item.type == MessageItem.VIDEO and item.video_item:
|
||||
# if hasattr(item.video_item, '_downloaded_bytes') and item.video_item._downloaded_bytes:
|
||||
# b64 = base64.b64encode(item.video_item._downloaded_bytes).decode('utf-8')
|
||||
# components.append(
|
||||
# platform_message.File(
|
||||
# name='video.mp4',
|
||||
# size=item.video_item.video_size or 0,
|
||||
# base64=b64,
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# components.append(
|
||||
# platform_message.File(
|
||||
# name='video.mp4',
|
||||
# size=item.video_item.video_size or 0,
|
||||
# )
|
||||
# )
|
||||
|
||||
else:
|
||||
components.append(platform_message.Unknown(text='[Unknown message type]'))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class OpenClawWeixinEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""Converts OpenClaw WeChat messages to LangBot events."""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(msg: WeixinMessage) -> typing.Optional[platform_events.MessageEvent]:
|
||||
"""Convert an inbound WeixinMessage to a LangBot event."""
|
||||
if msg.message_type != WeixinMessage.TYPE_USER:
|
||||
return None
|
||||
|
||||
from_user_id = msg.from_user_id or ''
|
||||
if not from_user_id:
|
||||
return None
|
||||
|
||||
message_chain = await OpenClawWeixinMessageConverter.target2yiri(msg)
|
||||
if not message_chain:
|
||||
return None
|
||||
|
||||
timestamp = (msg.create_time_ms or 0) / 1000.0
|
||||
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=from_user_id,
|
||||
nickname=from_user_id,
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object=msg,
|
||||
)
|
||||
|
||||
|
||||
class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""LangBot adapter for OpenClaw WeChat (long-poll based)."""
|
||||
|
||||
name: str = 'openclaw-weixin'
|
||||
|
||||
client: OpenClawWeixinClient = pydantic.Field(exclude=True)
|
||||
|
||||
config: dict
|
||||
|
||||
message_converter: OpenClawWeixinMessageConverter = OpenClawWeixinMessageConverter()
|
||||
event_converter: OpenClawWeixinEventConverter = OpenClawWeixinEventConverter()
|
||||
|
||||
# context_token cache: from_user_id -> context_token
|
||||
_context_tokens: dict[str, str] = pydantic.PrivateAttr(default_factory=dict)
|
||||
|
||||
_polling: bool = pydantic.PrivateAttr(default=False)
|
||||
_poll_task: typing.Optional[asyncio.Task] = pydantic.PrivateAttr(default=None)
|
||||
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
client = OpenClawWeixinClient(
|
||||
base_url=config.get('base_url', DEFAULT_BASE_URL),
|
||||
token=config.get('token', ''),
|
||||
)
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
client=client,
|
||||
bot_account_id='',
|
||||
listeners={},
|
||||
name='openclaw-weixin',
|
||||
)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""Called by BotManager to provide the bot's UUID for config persistence."""
|
||||
self._bot_uuid = bot_uuid
|
||||
|
||||
async def _persist_config(self) -> None:
|
||||
"""Persist current self.config to the database so token survives restart."""
|
||||
if not self._bot_uuid:
|
||||
return
|
||||
try:
|
||||
ap = self.logger.ap
|
||||
await ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_bot.Bot)
|
||||
.where(persistence_bot.Bot.uuid == self._bot_uuid)
|
||||
.values(adapter_config=self.config)
|
||||
)
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'Failed to persist adapter config: {e}')
|
||||
|
||||
async def _do_login(self) -> None:
|
||||
"""Run the QR code login flow via client.login() and update config."""
|
||||
adapter_logger = self.logger
|
||||
|
||||
async def _on_qrcode(qr_base64: str, _qr_url: str):
|
||||
await adapter_logger.info(
|
||||
f'Please scan the QR code to login WeChat: {_qr_url}',
|
||||
images=[platform_message.Image(base64=qr_base64)],
|
||||
)
|
||||
|
||||
login_result = await self.client.login(
|
||||
on_qrcode=_on_qrcode,
|
||||
)
|
||||
|
||||
# client.login() already updates client.token and client.base_url
|
||||
self.config['token'] = login_result.token
|
||||
self.config['base_url'] = login_result.base_url
|
||||
if login_result.account_id:
|
||||
self.config['account_id'] = login_result.account_id
|
||||
|
||||
await self.logger.info(f'WeChat login successful! account_id={login_result.account_id}')
|
||||
|
||||
# Persist token to database so it survives restart
|
||||
await self._persist_config()
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
):
|
||||
"""Send a message to a user."""
|
||||
context_token = self._context_tokens.get(target_id, '')
|
||||
|
||||
for component in message:
|
||||
try:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
if component.text:
|
||||
await self.client.send_text(target_id, component.text, context_token)
|
||||
|
||||
elif isinstance(component, platform_message.Image):
|
||||
img_bytes, _ = await component.get_bytes()
|
||||
await self.client.send_image(target_id, img_bytes, context_token)
|
||||
|
||||
elif isinstance(component, platform_message.File):
|
||||
file_bytes = await self._get_component_bytes(component)
|
||||
if file_bytes:
|
||||
await self.client.send_file(target_id, file_bytes, component.name or 'file', context_token)
|
||||
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
voice_bytes = await self._get_component_bytes(component)
|
||||
if voice_bytes:
|
||||
await self.client.send_voice(target_id, voice_bytes, component.length or 0, context_token)
|
||||
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
await self.send_message(target_type, target_id, node.message_chain)
|
||||
|
||||
except Exception:
|
||||
await self.logger.error(
|
||||
f'Failed to send component {type(component).__name__}: {traceback.format_exc()}'
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""Reply to a received message."""
|
||||
source_msg = message_source.source_platform_object
|
||||
if isinstance(source_msg, WeixinMessage):
|
||||
target_id = source_msg.from_user_id or ''
|
||||
if target_id:
|
||||
await self.send_message('friend', target_id, message)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def _get_component_bytes(component: platform_message.MessageComponent) -> typing.Optional[bytes]:
|
||||
"""Extract raw bytes from a File or Voice component."""
|
||||
b64_val = getattr(component, 'base64', None)
|
||||
url_val = getattr(component, 'url', None)
|
||||
path_val = getattr(component, 'path', None)
|
||||
|
||||
if b64_val:
|
||||
return base64.b64decode(b64_val)
|
||||
elif url_val and url_val.startswith(('http://', 'https://')):
|
||||
import aiohttp
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url_val) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.read()
|
||||
elif path_val:
|
||||
import asyncio
|
||||
|
||||
with open(path_val, 'rb') as f:
|
||||
return await asyncio.to_thread(f.read)
|
||||
return None
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||
None,
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||
None,
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
"""Start the adapter. If no token is configured, trigger QR code login first."""
|
||||
base_url = self.config.get('base_url', DEFAULT_BASE_URL)
|
||||
token = self.config.get('token', '')
|
||||
|
||||
await self.logger.info('OpenClaw WeChat adapter starting...')
|
||||
|
||||
# QR code login flow when no token is provided
|
||||
if not token:
|
||||
await self.logger.info('No token configured, starting QR code login...')
|
||||
try:
|
||||
await self._do_login()
|
||||
except Exception as e:
|
||||
await self.logger.error(f'QR code login failed: {e}')
|
||||
raise
|
||||
|
||||
# Rebuild client with the (possibly updated) config
|
||||
self.client = OpenClawWeixinClient(
|
||||
base_url=self.config.get('base_url', base_url),
|
||||
token=self.config.get('token', token),
|
||||
)
|
||||
self.bot_account_id = self.config.get('account_id', 'openclaw-weixin')
|
||||
self._polling = True
|
||||
|
||||
# Start the long-poll loop
|
||||
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||
await self.logger.info('OpenClaw WeChat adapter running')
|
||||
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _poll_loop(self):
|
||||
"""Long-poll loop: call getUpdates continuously.
|
||||
|
||||
Error handling follows the weixin-bot SDK pattern:
|
||||
- Exponential backoff (1s -> 10s max) on failures
|
||||
- Session expired (errcode -14) triggers automatic re-login
|
||||
"""
|
||||
get_updates_buf = ''
|
||||
poll_timeout = float(self.config.get('poll_timeout', 35))
|
||||
|
||||
backoff_delay = 1.0
|
||||
max_backoff = 10.0
|
||||
|
||||
while self._polling:
|
||||
try:
|
||||
resp = await self.client.get_updates(
|
||||
get_updates_buf=get_updates_buf,
|
||||
timeout=poll_timeout + 5,
|
||||
)
|
||||
|
||||
if resp.longpolling_timeout_ms and resp.longpolling_timeout_ms > 0:
|
||||
poll_timeout = resp.longpolling_timeout_ms / 1000.0
|
||||
|
||||
is_api_error = (resp.ret is not None and resp.ret != 0) or (
|
||||
resp.errcode is not None and resp.errcode != 0
|
||||
)
|
||||
if is_api_error:
|
||||
is_session_expired = resp.errcode == SESSION_EXPIRED_ERRCODE or resp.ret == SESSION_EXPIRED_ERRCODE
|
||||
|
||||
if is_session_expired:
|
||||
await self.logger.error('OpenClaw WeChat session expired, attempting re-login...')
|
||||
try:
|
||||
await self._do_login()
|
||||
# Rebuild client with new credentials
|
||||
self.client = OpenClawWeixinClient(
|
||||
base_url=self.config.get('base_url', DEFAULT_BASE_URL),
|
||||
token=self.config.get('token', ''),
|
||||
)
|
||||
self._context_tokens.clear()
|
||||
get_updates_buf = ''
|
||||
backoff_delay = 1.0
|
||||
continue
|
||||
except Exception:
|
||||
await self.logger.error(f'Re-login failed: {traceback.format_exc()}')
|
||||
break
|
||||
|
||||
await self.logger.error(
|
||||
f'OpenClaw getUpdates failed: ret={resp.ret} errcode={resp.errcode} errmsg={resp.errmsg}'
|
||||
)
|
||||
await asyncio.sleep(backoff_delay)
|
||||
backoff_delay = min(backoff_delay * 2, max_backoff)
|
||||
continue
|
||||
|
||||
backoff_delay = 1.0
|
||||
|
||||
if resp.get_updates_buf:
|
||||
get_updates_buf = resp.get_updates_buf
|
||||
|
||||
for msg in resp.msgs:
|
||||
try:
|
||||
await self._handle_inbound_message(msg)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error handling message: {traceback.format_exc()}')
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await self.logger.error(f'OpenClaw poll error: {traceback.format_exc()}')
|
||||
await asyncio.sleep(backoff_delay)
|
||||
backoff_delay = min(backoff_delay * 2, max_backoff)
|
||||
|
||||
async def _handle_inbound_message(self, msg: WeixinMessage):
|
||||
"""Process a single inbound message from getUpdates."""
|
||||
if msg.context_token and msg.from_user_id:
|
||||
self._context_tokens[msg.from_user_id] = msg.context_token
|
||||
|
||||
# Download CDN media (files, images) before converting to LangBot events
|
||||
await self._download_media_items(msg)
|
||||
|
||||
event = await OpenClawWeixinEventConverter.target2yiri(msg)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
if type(event) in self.listeners:
|
||||
await self.listeners[type(event)](event, self)
|
||||
|
||||
async def _download_media_items(self, msg: WeixinMessage):
|
||||
"""Download CDN media for image items in the message."""
|
||||
if not msg.item_list:
|
||||
return
|
||||
|
||||
for item in msg.item_list:
|
||||
try:
|
||||
if item.type == MessageItem.IMAGE and item.image_item:
|
||||
if (
|
||||
item.image_item.media
|
||||
and item.image_item.media.encrypt_query_param
|
||||
and item.image_item.media.aes_key
|
||||
):
|
||||
img_bytes = await self.client.download_media(item.image_item.media)
|
||||
item.image_item._downloaded_bytes = img_bytes
|
||||
|
||||
# TODO: enable after full testing
|
||||
# elif item.type == MessageItem.FILE and item.file_item and item.file_item.media:
|
||||
# if item.file_item.media.encrypt_query_param and item.file_item.media.aes_key:
|
||||
# file_bytes = await self.client.download_media(item.file_item.media)
|
||||
# item.file_item._downloaded_bytes = file_bytes
|
||||
#
|
||||
# elif item.type == MessageItem.VOICE and item.voice_item and item.voice_item.media:
|
||||
# if item.voice_item.media.encrypt_query_param and item.voice_item.media.aes_key:
|
||||
# voice_bytes = await self.client.download_media(item.voice_item.media)
|
||||
# item.voice_item._downloaded_bytes = voice_bytes
|
||||
#
|
||||
# elif item.type == MessageItem.VIDEO and item.video_item and item.video_item.media:
|
||||
# if item.video_item.media.encrypt_query_param and item.video_item.media.aes_key:
|
||||
# video_bytes = await self.client.download_media(item.video_item.media)
|
||||
# item.video_item._downloaded_bytes = video_bytes
|
||||
|
||||
except Exception:
|
||||
await self.logger.warning(f'Failed to download CDN media: {traceback.format_exc()}')
|
||||
|
||||
async def kill(self) -> bool:
|
||||
"""Stop the adapter."""
|
||||
self._polling = False
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self.client.close()
|
||||
await self.logger.info('OpenClaw WeChat adapter stopped')
|
||||
return True
|
||||
57
src/langbot/pkg/platform/sources/openclaw_weixin.yaml
Normal file
57
src/langbot/pkg/platform/sources/openclaw_weixin.yaml
Normal file
@@ -0,0 +1,57 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: openclaw-weixin
|
||||
label:
|
||||
en_US: OpenClaw WeChat
|
||||
zh_Hans: OpenClaw 微信
|
||||
description:
|
||||
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
|
||||
zh_Hans: OpenClaw 微信适配器,通过扫码登录支持个人微信
|
||||
icon: wechat.png
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础地址
|
||||
description:
|
||||
en_US: The base URL of the OpenClaw WeChat backend API
|
||||
zh_Hans: OpenClaw 微信后端 API 的基础地址
|
||||
type: string
|
||||
required: true
|
||||
default: "https://ilinkai.weixin.qq.com"
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
description:
|
||||
en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup.
|
||||
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。留空则启动时自动触发扫码登录。
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
- name: account_id
|
||||
label:
|
||||
en_US: Account ID
|
||||
zh_Hans: 账号标识
|
||||
description:
|
||||
en_US: A label for this WeChat account (used for display purposes)
|
||||
zh_Hans: 此微信账号的标识(用于显示)
|
||||
type: string
|
||||
required: false
|
||||
default: "openclaw-weixin"
|
||||
- name: poll_timeout
|
||||
label:
|
||||
en_US: Poll Timeout (seconds)
|
||||
zh_Hans: 轮询超时(秒)
|
||||
description:
|
||||
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
|
||||
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
|
||||
type: integer
|
||||
required: false
|
||||
default: 35
|
||||
execution:
|
||||
python:
|
||||
path: ./openclaw_weixin.py
|
||||
attr: OpenClawWeixinAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/wechat.png
Normal file
BIN
src/langbot/pkg/platform/sources/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
@@ -148,51 +148,54 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
pass
|
||||
|
||||
if type(event) is platform_events.FriendMessage:
|
||||
payload = {
|
||||
'MsgType': 'text',
|
||||
'Content': '',
|
||||
'FromUserName': event.sender.id,
|
||||
'ToUserName': bot_account_id,
|
||||
'CreateTime': int(datetime.datetime.now().timestamp()),
|
||||
'AgentID': event.sender.nickname,
|
||||
}
|
||||
wecom_event = WecomEvent.from_payload(payload=payload)
|
||||
if not wecom_event:
|
||||
raise ValueError('无法从 message_data 构造 WecomEvent 对象')
|
||||
|
||||
return wecom_event
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomEvent):
|
||||
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
|
||||
"""
|
||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||
|
||||
Args:
|
||||
event (WecomEvent): 企业微信事件。
|
||||
bot (WecomClient): 企业微信客户端,用于获取用户信息。
|
||||
|
||||
Returns:
|
||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||
"""
|
||||
# Try to get the user's real name from the WeCom API
|
||||
nickname = str(event.user_id)
|
||||
if bot and event.user_id:
|
||||
try:
|
||||
user_info = await bot.get_user_info(event.user_id)
|
||||
if user_info and user_info.get('name'):
|
||||
nickname = user_info.get('name')
|
||||
except Exception:
|
||||
pass # Fall back to user_id as nickname
|
||||
|
||||
# 转换消息链
|
||||
if event.type == 'text':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.agent_id),
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
|
||||
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||
return platform_events.FriendMessage(
|
||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
||||
)
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.agent_id),
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
|
||||
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
||||
|
||||
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||
return platform_events.FriendMessage(
|
||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
||||
)
|
||||
|
||||
|
||||
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
@@ -210,7 +213,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'secret',
|
||||
'token',
|
||||
'EncodingAESKey',
|
||||
'contacts_secret',
|
||||
]
|
||||
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
@@ -223,7 +225,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
EncodingAESKey=config['EncodingAESKey'],
|
||||
contacts_secret=config['contacts_secret'],
|
||||
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
||||
@@ -248,18 +250,17 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
):
|
||||
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
fixed_user_id = Wecom_event.user_id
|
||||
# 删掉开头的u
|
||||
fixed_user_id = fixed_user_id[1:]
|
||||
# user_id is the original FromUserName from WecomEvent
|
||||
user_id = Wecom_event.user_id
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
|
||||
await self.bot.send_private_msg(user_id, Wecom_event.agent_id, content['content'])
|
||||
elif content['type'] == 'image':
|
||||
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
await self.bot.send_image(user_id, Wecom_event.agent_id, content['media_id'])
|
||||
elif content['type'] == 'voice':
|
||||
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
await self.bot.send_voice(user_id, Wecom_event.agent_id, content['media_id'])
|
||||
elif content['type'] == 'file':
|
||||
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
await self.bot.send_file(user_id, Wecom_event.agent_id, content['media_id'])
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
@@ -287,7 +288,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
async def on_message(event: WecomEvent):
|
||||
self.bot_account_id = event.receiver_id
|
||||
try:
|
||||
return await callback(await self.event_converter.target2yiri(event), self)
|
||||
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
||||
|
||||
|
||||
@@ -39,13 +39,6 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: contacts_secret
|
||||
label:
|
||||
en_US: Contacts Secret
|
||||
zh_Hans: 通讯录密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: api_base_url
|
||||
label:
|
||||
en_US: API Base URL
|
||||
|
||||
@@ -277,14 +277,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
return {'stream': success}
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
"""智能机器人侧默认开启流式能力。
|
||||
|
||||
Returns:
|
||||
bool: 恒定返回 True。
|
||||
|
||||
Example:
|
||||
流水线执行阶段会调用此方法以确认是否启用流式。"""
|
||||
return True
|
||||
"""Whether streaming output is enabled for this bot instance."""
|
||||
return self.config.get('enable-stream-reply', True)
|
||||
|
||||
async def send_message(self, target_type, target_id, message):
|
||||
_ws_mode = not self.config.get('enable-webhook', False)
|
||||
|
||||
@@ -55,6 +55,10 @@ spec:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
show_if:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
value: true
|
||||
- name: Token
|
||||
label:
|
||||
en_US: Token
|
||||
@@ -65,6 +69,10 @@ spec:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
show_if:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
value: true
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
@@ -75,6 +83,20 @@ spec:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
show_if:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
value: true
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable Stream Reply
|
||||
zh_Hans: 启用流式回复
|
||||
description:
|
||||
en_US: If enabled, the bot will use streaming mode to reply messages
|
||||
zh_Hans: 如果启用,机器人将使用流式模式回复消息
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
execution:
|
||||
python:
|
||||
path: ./wecombot.py
|
||||
|
||||
@@ -314,11 +314,11 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
||||
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Get llm models"""
|
||||
"""Get llm models, returns list of UUID strings"""
|
||||
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
|
||||
return handler.ActionResponse.success(
|
||||
data={
|
||||
'llm_models': llm_models,
|
||||
'llm_models': [m['uuid'] for m in llm_models],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -531,6 +531,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
filters = data.get('filters')
|
||||
search_type = data.get('search_type', 'vector')
|
||||
query_text = data.get('query_text', '')
|
||||
vector_weight = data.get('vector_weight')
|
||||
try:
|
||||
results = await self.ap.rag_runtime_service.vector_search(
|
||||
collection_id,
|
||||
@@ -539,6 +540,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
filters,
|
||||
search_type,
|
||||
query_text,
|
||||
vector_weight=vector_weight,
|
||||
)
|
||||
return handler.ActionResponse.success(data={'results': results})
|
||||
except Exception as e:
|
||||
@@ -613,6 +615,47 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
# ================= Knowledge Base Query APIs =================
|
||||
|
||||
@self.action(PluginToRuntimeAction.LIST_KNOWLEDGE_BASES)
|
||||
async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""List all knowledge bases available in the LangBot instance (unrestricted)."""
|
||||
knowledge_bases = []
|
||||
for kb_uuid, kb in self.ap.rag_mgr.knowledge_bases.items():
|
||||
knowledge_bases.append(
|
||||
{
|
||||
'uuid': kb.get_uuid(),
|
||||
'name': kb.get_name(),
|
||||
'description': kb.knowledge_base_entity.description or '',
|
||||
}
|
||||
)
|
||||
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
|
||||
|
||||
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE)
|
||||
async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Retrieve documents from any knowledge base (unrestricted)."""
|
||||
kb_id = data['kb_id']
|
||||
query_text = data['query_text']
|
||||
top_k = data.get('top_k', 5)
|
||||
filters = data.get('filters', {})
|
||||
|
||||
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
|
||||
if not kb:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Knowledge base {kb_id} not found',
|
||||
)
|
||||
|
||||
try:
|
||||
entries = await kb.retrieve(
|
||||
query_text,
|
||||
settings={
|
||||
'top_k': top_k,
|
||||
'filters': filters,
|
||||
},
|
||||
)
|
||||
results = [entry.model_dump(mode='json') for entry in entries]
|
||||
return handler.ActionResponse.success(data={'results': results})
|
||||
except Exception as e:
|
||||
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
|
||||
|
||||
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
|
||||
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""List knowledge bases configured for the current query's pipeline."""
|
||||
|
||||
@@ -41,6 +41,7 @@ class RAGRuntimeService:
|
||||
filters: dict[str, Any] | None = None,
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
vector_weight: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Handle VECTOR_SEARCH action."""
|
||||
return await self.ap.vector_db_mgr.search(
|
||||
@@ -50,6 +51,7 @@ class RAGRuntimeService:
|
||||
filter=filters,
|
||||
search_type=search_type,
|
||||
query_text=query_text,
|
||||
vector_weight=vector_weight,
|
||||
)
|
||||
|
||||
async def vector_delete(
|
||||
|
||||
@@ -97,6 +97,7 @@ class VectorDBManager:
|
||||
filter: dict | None = None,
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
vector_weight: float | None = None,
|
||||
) -> list[dict]:
|
||||
"""Proxy: Search vectors.
|
||||
|
||||
@@ -111,6 +112,7 @@ class VectorDBManager:
|
||||
search_type=search_type,
|
||||
query_text=query_text,
|
||||
filter=filter,
|
||||
vector_weight=vector_weight,
|
||||
)
|
||||
|
||||
if not results or 'ids' not in results or not results['ids']:
|
||||
|
||||
@@ -53,6 +53,7 @@ class VectorDatabase(abc.ABC):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for the most similar vectors in the specified collection.
|
||||
|
||||
@@ -70,6 +71,8 @@ class VectorDatabase(abc.ABC):
|
||||
{"file_id": "abc"}
|
||||
{"created_at": {"$gte": 1700000000}}
|
||||
{"file_type": {"$in": ["pdf", "docx"]}}
|
||||
vector_weight: Weight for vector search in hybrid mode (0.0–1.0).
|
||||
``None`` means use equal weights (backward compatible).
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -52,13 +52,16 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
col = await self.get_or_create_collection(collection)
|
||||
|
||||
if search_type == SearchType.FULL_TEXT:
|
||||
return await self._full_text_search(col, collection, k, query_text, filter)
|
||||
elif search_type == SearchType.HYBRID:
|
||||
return await self._hybrid_search(col, collection, query_embedding, k, query_text, filter)
|
||||
return await self._hybrid_search(
|
||||
col, collection, query_embedding, k, query_text, filter, vector_weight=vector_weight
|
||||
)
|
||||
|
||||
# Default: vector search
|
||||
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||
@@ -127,6 +130,7 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
k: int,
|
||||
query_text: str,
|
||||
filter: dict[str, Any] | None,
|
||||
vector_weight: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# Fall back to pure vector search when no text is provided
|
||||
if not query_text:
|
||||
@@ -144,7 +148,15 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||
|
||||
# RRF fusion
|
||||
fused = self._rrf_fuse([vector_ids, text_ids], k)
|
||||
weights = None
|
||||
if vector_weight is not None:
|
||||
weights = [vector_weight, 1.0 - vector_weight]
|
||||
self.ap.logger.info(
|
||||
f"Chroma hybrid fusion config in '{collection}': "
|
||||
f'vector_weight={vector_weight}, weights={weights or [1.0, 1.0]}, '
|
||||
f'vector_hits={len(vector_ids)}, text_hits={len(text_ids)}'
|
||||
)
|
||||
fused = self._rrf_fuse([vector_ids, text_ids], k, weights=weights)
|
||||
if not fused:
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||
|
||||
@@ -197,16 +209,24 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _rrf_fuse(result_lists: list[list[str]], k: int) -> list[tuple[str, float]]:
|
||||
def _rrf_fuse(result_lists: list[list[str]], k: int, weights: list[float] | None = None) -> list[tuple[str, float]]:
|
||||
"""Reciprocal Rank Fusion over multiple ranked ID lists.
|
||||
|
||||
Returns a list of (doc_id, rrf_score) sorted by descending score,
|
||||
truncated to *k* entries.
|
||||
|
||||
Args:
|
||||
result_lists: Ranked ID lists from different search methods.
|
||||
k: Number of results to return.
|
||||
weights: Per-list weights. ``None`` means equal weight (1.0 each).
|
||||
"""
|
||||
if weights is None:
|
||||
weights = [1.0] * len(result_lists)
|
||||
scores: dict[str, float] = {}
|
||||
for ranked_ids in result_lists:
|
||||
for list_idx, ranked_ids in enumerate(result_lists):
|
||||
w = weights[list_idx]
|
||||
for rank, doc_id in enumerate(ranked_ids):
|
||||
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_K + rank + 1)
|
||||
scores[doc_id] = scores.get(doc_id, 0.0) + w / (_RRF_K + rank + 1)
|
||||
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
return sorted_results[:k]
|
||||
|
||||
|
||||
@@ -255,6 +255,7 @@ class MilvusVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for similar vectors in Milvus collection
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ class PgVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for similar vectors using cosine distance
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ class QdrantVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
exists = await self.client.collection_exists(collection)
|
||||
if not exists:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from decimal import Decimal
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
@@ -101,8 +103,28 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
}
|
||||
)
|
||||
|
||||
def _normalize_collection_name(self, collection: str) -> str:
|
||||
"""SeekDB only accepts [a-zA-Z0-9_], while LangBot uses UUID-like KB IDs."""
|
||||
normalized = re.sub(r'[^A-Za-z0-9_]', '_', collection)
|
||||
if normalized != collection:
|
||||
self.ap.logger.info(f"Normalized SeekDB collection name: '{collection}' -> '{normalized}'")
|
||||
return normalized
|
||||
|
||||
def _json_safe(self, value: Any) -> Any:
|
||||
"""Convert SeekDB result values into JSON-serializable Python primitives."""
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, dict):
|
||||
return {k: self._json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [self._json_safe(v) for v in value]
|
||||
if isinstance(value, tuple):
|
||||
return [self._json_safe(v) for v in value]
|
||||
return value
|
||||
|
||||
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
|
||||
"""Internal method to get or create a collection with proper configuration."""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
if collection in self._collections:
|
||||
return self._collections[collection]
|
||||
|
||||
@@ -173,6 +195,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
if not embeddings_list:
|
||||
return
|
||||
|
||||
collection = self._normalize_collection_name(collection)
|
||||
# Ensure collection exists with correct dimension
|
||||
vector_size = len(embeddings_list[0])
|
||||
coll = await self._get_or_create_collection_internal(collection, vector_size)
|
||||
@@ -194,6 +217,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
search_type: str = 'vector',
|
||||
query_text: str = '',
|
||||
filter: Dict[str, Any] | None = None,
|
||||
vector_weight: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for the most similar vectors in the specified collection.
|
||||
|
||||
@@ -210,6 +234,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
Returns:
|
||||
Dictionary with 'ids', 'metadatas', 'distances' keys
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
# Check if collection exists
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
@@ -271,6 +296,17 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
query_cfg['where'] = filter
|
||||
knn_cfg['where'] = filter
|
||||
|
||||
# Apply vector_weight via pyseekdb's native boost parameter
|
||||
if vector_weight is not None:
|
||||
knn_cfg['boost'] = vector_weight
|
||||
query_cfg['boost'] = 1.0 - vector_weight
|
||||
self.ap.logger.info(
|
||||
f"SeekDB hybrid fusion config in '{collection}': "
|
||||
f'vector_weight={vector_weight}, '
|
||||
f'knn_boost={knn_cfg.get("boost", 1.0)}, '
|
||||
f'query_boost={query_cfg.get("boost", 1.0)}'
|
||||
)
|
||||
|
||||
results = await asyncio.to_thread(
|
||||
coll.hybrid_search,
|
||||
query=query_cfg,
|
||||
@@ -279,6 +315,9 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
n_results=k,
|
||||
include=['documents', 'metadatas'],
|
||||
)
|
||||
self.ap.logger.info(
|
||||
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||
)
|
||||
else:
|
||||
# Default: vector search via query()
|
||||
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
|
||||
@@ -286,6 +325,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
query_kwargs['where'] = filter
|
||||
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
||||
|
||||
results = self._json_safe(results)
|
||||
self.ap.logger.info(
|
||||
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
||||
)
|
||||
@@ -299,6 +339,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
collection: Collection name
|
||||
file_id: File ID to delete
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
# Check if collection exists
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
@@ -325,6 +366,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
collection: Collection name
|
||||
filter: Chroma-style ``where`` filter dict
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
||||
@@ -347,6 +389,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Dict[str, Any]], int]:
|
||||
collection = self._normalize_collection_name(collection)
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
return [], 0
|
||||
@@ -367,6 +410,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
|
||||
results = await asyncio.to_thread(coll.get, **get_kwargs)
|
||||
|
||||
results = self._json_safe(results)
|
||||
ids = results.get('ids', [])
|
||||
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
||||
documents = results.get('documents', []) or [None] * len(ids)
|
||||
@@ -390,6 +434,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
Args:
|
||||
collection: Collection name
|
||||
"""
|
||||
collection = self._normalize_collection_name(collection)
|
||||
# Remove from cache
|
||||
if collection in self._collections:
|
||||
del self._collections[collection]
|
||||
|
||||
@@ -78,6 +78,14 @@ plugin:
|
||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||
enable_marketplace: true
|
||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||
monitoring:
|
||||
auto_cleanup:
|
||||
# Enable automatic cleanup of expired monitoring records
|
||||
enabled: true
|
||||
# Retention period in days, records older than this will be deleted
|
||||
retention_days: 30
|
||||
# Cleanup check interval in hours
|
||||
check_interval_hours: 1
|
||||
space:
|
||||
# Space service URL for OAuth and API
|
||||
url: 'https://space.langbot.app'
|
||||
|
||||
@@ -74,6 +74,10 @@ stages:
|
||||
type: integer
|
||||
required: true
|
||||
default: 10
|
||||
show_if:
|
||||
field: __system.is_wizard
|
||||
operator: neq
|
||||
value: true
|
||||
- name: prompt
|
||||
label:
|
||||
en_US: Prompt
|
||||
@@ -83,6 +87,9 @@ stages:
|
||||
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
|
||||
type: prompt-editor
|
||||
required: true
|
||||
default:
|
||||
- role: system
|
||||
content: "You are a helpful assistant."
|
||||
- name: knowledge-bases
|
||||
label:
|
||||
en_US: Knowledge Bases
|
||||
@@ -93,6 +100,10 @@ stages:
|
||||
type: knowledge-base-multi-selector
|
||||
required: false
|
||||
default: []
|
||||
show_if:
|
||||
field: __system.is_wizard
|
||||
operator: neq
|
||||
value: true
|
||||
- name: tbox-app-api
|
||||
label:
|
||||
en_US: Tbox App API
|
||||
@@ -107,12 +118,14 @@ stages:
|
||||
zh_Hans: API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: app-id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用 ID
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: dify-service-api
|
||||
label:
|
||||
en_US: Dify Service API
|
||||
@@ -127,6 +140,7 @@ stages:
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: 'https://api.dify.ai/v1'
|
||||
- name: base-prompt
|
||||
label:
|
||||
en_US: Base PROMPT
|
||||
@@ -163,6 +177,7 @@ stages:
|
||||
zh_Hans: API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-api-key'
|
||||
- name: dashscope-app-api
|
||||
label:
|
||||
en_US: Aliyun Dashscope App API
|
||||
@@ -193,12 +208,14 @@ stages:
|
||||
zh_Hans: API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-api-key'
|
||||
- name: app-id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用 ID
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-app-id'
|
||||
- name: references_quote
|
||||
label:
|
||||
en_US: References Quote
|
||||
@@ -226,6 +243,7 @@ stages:
|
||||
zh_Hans: n8n 工作流的 webhook URL
|
||||
type: string
|
||||
required: true
|
||||
default: 'http://your-n8n-webhook-url'
|
||||
- name: auth-type
|
||||
label:
|
||||
en_US: Authentication Type
|
||||
@@ -263,6 +281,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'basic'
|
||||
- name: basic-password
|
||||
label:
|
||||
en_US: Password
|
||||
@@ -273,6 +295,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'basic'
|
||||
- name: jwt-secret
|
||||
label:
|
||||
en_US: Secret
|
||||
@@ -283,6 +309,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'jwt'
|
||||
- name: jwt-algorithm
|
||||
label:
|
||||
en_US: Algorithm
|
||||
@@ -293,6 +323,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: 'HS256'
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'jwt'
|
||||
- name: header-name
|
||||
label:
|
||||
en_US: Header Name
|
||||
@@ -303,6 +337,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'header'
|
||||
- name: header-value
|
||||
label:
|
||||
en_US: Header Value
|
||||
@@ -313,6 +351,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'header'
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
@@ -350,6 +392,7 @@ stages:
|
||||
zh_Hans: Langflow 服务器的基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: 'http://localhost:7860'
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
@@ -359,6 +402,7 @@ stages:
|
||||
zh_Hans: Langflow 服务器的 API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-api-key'
|
||||
- name: flow-id
|
||||
label:
|
||||
en_US: Flow ID
|
||||
@@ -368,6 +412,7 @@ stages:
|
||||
zh_Hans: 要运行的流程 ID
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-flow-id'
|
||||
- name: input-type
|
||||
label:
|
||||
en_US: Input Type
|
||||
@@ -415,6 +460,7 @@ stages:
|
||||
zh_Hans: Coze服务器的 API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: bot-id
|
||||
label:
|
||||
en_US: Bot ID
|
||||
@@ -424,6 +470,7 @@ stages:
|
||||
zh_Hans: 要运行的机器人 ID
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: api-base
|
||||
label:
|
||||
en_US: API Base URL
|
||||
|
||||
10
uv.lock
generated
10
uv.lock
generated
@@ -1832,7 +1832,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.9.3"
|
||||
version = "4.9.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -1937,7 +1937,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.3" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.5" },
|
||||
{ name = "langchain", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||
@@ -1993,7 +1993,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.3.3"
|
||||
version = "0.3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2011,9 +2011,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/be/78a0375aec6aad34bc2f00e2b4d2511ec597e8143cf8596d0736e8767904/langbot_plugin-0.3.3.tar.gz", hash = "sha256:5b07609b9b08f8b24fefcf29b97b9882838c140143de6d4bac2f320511ef4374", size = 170709, upload-time = "2026-03-19T12:40:23.877Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/8f/0a22e4461b0893ac2afb1b6aaebafe04c921df6dbbf4b8bd6c83cf6a97b2/langbot_plugin-0.3.5.tar.gz", hash = "sha256:79c7feb08f788f480435de8cdefc3cfed4de2dfb03978a460251b8c9d1c271d3", size = 171927, upload-time = "2026-03-25T13:53:18.334Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/53/c708f3ef9459420974f1b131cffd43cfb2f97220350c26a6b5fbadbd6211/langbot_plugin-0.3.3-py3-none-any.whl", hash = "sha256:cca94a2ed07c1d3ec4be33f97268746908ed304b528d0eb7f23308677fe619ca", size = 145188, upload-time = "2026-03-19T12:40:25.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/93/fdd4eb54434a358a3917aec74190e2e1b64351a5bb955677f634d29fc4fd/langbot_plugin-0.3.5-py3-none-any.whl", hash = "sha256:4d31f92338e1e2dc343ae00982e4facbe7abae84f4d1c4e1375cdcac9d7155d7", size = 146575, upload-time = "2026-03-25T13:53:16.987Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
|
||||
4424
web/pnpm-lock.yaml
generated
4424
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -46,8 +46,12 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
setStatus('success');
|
||||
toast.success(t('common.spaceLoginSuccess'));
|
||||
|
||||
// If wizard state exists, redirect back to wizard instead of home
|
||||
const wizardState = localStorage.getItem('langbot_wizard_state');
|
||||
const redirectTo = wizardState ? '/wizard' : '/home';
|
||||
setTimeout(() => {
|
||||
router.push('/home');
|
||||
router.push(redirectTo);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
|
||||
@@ -114,22 +114,23 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--animate-twinkle: twinkle 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.08 0.002 285.823);
|
||||
--background: oklch(0.17 0.003 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.12 0.004 285.885);
|
||||
--card: oklch(0.16 0.004 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.12 0.004 285.885);
|
||||
--popover: oklch(0.16 0.004 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.62 0.2 255);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.18 0.004 286.033);
|
||||
--secondary: oklch(0.27 0.005 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.18 0.004 286.033);
|
||||
--muted: oklch(0.27 0.005 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.18 0.004 286.033);
|
||||
--accent: oklch(0.27 0.005 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
@@ -140,7 +141,7 @@
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.1 0.003 285.885);
|
||||
--sidebar: oklch(0.05 0.002 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.62 0.2 255);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
@@ -158,3 +159,23 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.85) rotate(-8deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.15) rotate(4deg);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.7;
|
||||
transform: scale(0.95) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
319
web/src/app/home/bots/BotDetailContent.tsx
Normal file
319
web/src/app/home/bots/BotDetailContent.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
|
||||
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
|
||||
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function BotDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('bots.createBot'));
|
||||
} else {
|
||||
const bot = bots.find((b) => b.id === id);
|
||||
setDetailEntityName(bot?.name ?? id);
|
||||
}
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, bots, setDetailEntityName, t]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
|
||||
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
|
||||
|
||||
// Track whether the form has unsaved changes
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
|
||||
// Enable state managed here so the header switch works
|
||||
const [botEnabled, setBotEnabled] = useState(true);
|
||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||
|
||||
// Fetch bot enable state
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
}
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
const handleEnableToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
const prev = botEnabled;
|
||||
setBotEnabled(checked);
|
||||
try {
|
||||
// Fetch current bot data to send a complete update
|
||||
const res = await httpClient.getBot(id);
|
||||
const bot = res.bot;
|
||||
await httpClient.updateBot(id, {
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
adapter: bot.adapter,
|
||||
adapter_config: bot.adapter_config,
|
||||
enable: checked,
|
||||
});
|
||||
refreshBots();
|
||||
} catch {
|
||||
setBotEnabled(prev);
|
||||
toast.error(t('bots.setBotEnableError'));
|
||||
}
|
||||
},
|
||||
[id, botEnabled, refreshBots, t],
|
||||
);
|
||||
|
||||
function handleFormSubmit() {
|
||||
// Re-sync enable state after form save (form may update enable too)
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
});
|
||||
refreshBots();
|
||||
}
|
||||
|
||||
function handleBotDeleted() {
|
||||
refreshBots();
|
||||
router.push('/home/bots');
|
||||
}
|
||||
|
||||
function handleNewBotCreated(newBotId: string) {
|
||||
refreshBots();
|
||||
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteBot(id)
|
||||
.then(() => {
|
||||
setShowDeleteConfirm(false);
|
||||
toast.success(t('bots.deleteSuccess'));
|
||||
handleBotDeleted();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('bots.deleteError') + err.msg);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Create Mode ====================
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<BotForm
|
||||
initBotId={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sticky Header: title + enable switch + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1>
|
||||
{enableLoaded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="bot-enable-switch"
|
||||
checked={botEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="bot-enable-switch"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t('common.enable')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
<Tabs
|
||||
key={id}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger value="config" className="gap-1.5">
|
||||
<Settings className="size-3.5" />
|
||||
{t('bots.configuration')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('bots.logs')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{t('bots.sessionMonitor.title')}
|
||||
{activeTab === 'sessions' && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center ml-0.5"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (isRefreshingSessions) return;
|
||||
setIsRefreshingSessions(true);
|
||||
const minDelay = new Promise((r) => setTimeout(r, 500));
|
||||
Promise.all([
|
||||
sessionMonitorRef.current?.refreshSessions(),
|
||||
minDelay,
|
||||
]).finally(() => setIsRefreshingSessions(false));
|
||||
}}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground hover:text-foreground transition-colors',
|
||||
isRefreshingSessions && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab: Configuration */}
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<BotForm
|
||||
initBotId={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
|
||||
{/* Card: Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('bots.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('bots.deleteBotAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('bots.deleteBotHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Logs */}
|
||||
<TabsContent
|
||||
value="logs"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<BotLogListComponent botId={id} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Sessions */}
|
||||
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
|
||||
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('bots.deleteConfirmation')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('bots.deleteConfirmation')}</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
|
||||
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
|
||||
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
interface BotDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
botId?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onFormSubmit: (value: z.infer<any>) => void;
|
||||
onFormCancel: () => void;
|
||||
onBotDeleted: () => void;
|
||||
onNewBotCreated: (botId: string) => void;
|
||||
}
|
||||
|
||||
export default function BotDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
botId: propBotId,
|
||||
onFormSubmit,
|
||||
onFormCancel,
|
||||
onBotDeleted,
|
||||
onNewBotCreated,
|
||||
}: BotDetailDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [botId, setBotId] = useState<string | undefined>(propBotId);
|
||||
const [activeMenu, setActiveMenu] = useState('config');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBotId(propBotId);
|
||||
setActiveMenu('config');
|
||||
}, [propBotId, open]);
|
||||
|
||||
const menu = [
|
||||
{
|
||||
key: 'config',
|
||||
label: t('bots.configuration'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
label: t('bots.logs'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sessions',
|
||||
label: t('bots.sessionMonitor.title'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleFormSubmit = (value: any) => {
|
||||
onFormSubmit(value);
|
||||
};
|
||||
|
||||
const handleFormCancel = () => {
|
||||
onFormCancel();
|
||||
};
|
||||
|
||||
const handleBotDeleted = () => {
|
||||
httpClient.deleteBot(botId ?? '').then(() => {
|
||||
onBotDeleted();
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewBotCreated = (newBotId: string) => {
|
||||
setBotId(newBotId);
|
||||
setActiveMenu('config');
|
||||
onNewBotCreated(newBotId);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
handleBotDeleted();
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
if (!botId) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
|
||||
<main className="flex flex-1 flex-col h-[70vh]">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>{t('bots.createBot')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('bots.createBot')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<BotForm
|
||||
initBotId={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onBotDeleted={handleBotDeleted}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</main>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[70rem] max-h-[75vh] flex">
|
||||
<SidebarProvider className="items-start w-full flex">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{menu.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={activeMenu === item.key}
|
||||
onClick={() => setActiveMenu(item.key)}
|
||||
>
|
||||
<a href="#">
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
<main className="flex flex-1 flex-col h-[75vh]">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>
|
||||
{activeMenu === 'config'
|
||||
? t('bots.editBot')
|
||||
: activeMenu === 'logs'
|
||||
? t('bots.botLogTitle')
|
||||
: t('bots.sessionMonitor.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{activeMenu === 'config'
|
||||
? t('bots.editBot')
|
||||
: activeMenu === 'logs'
|
||||
? t('bots.botLogTitle')
|
||||
: t('bots.sessionMonitor.title')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className={
|
||||
activeMenu === 'sessions'
|
||||
? 'flex-1 min-h-0'
|
||||
: 'flex-1 overflow-y-auto px-6 pb-6'
|
||||
}
|
||||
>
|
||||
{activeMenu === 'config' && (
|
||||
<BotForm
|
||||
initBotId={botId}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onBotDeleted={handleBotDeleted}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'logs' && botId && (
|
||||
<BotLogListComponent botId={botId} />
|
||||
)}
|
||||
{activeMenu === 'sessions' && botId && (
|
||||
<BotSessionMonitor botId={botId} />
|
||||
)}
|
||||
</div>
|
||||
{activeMenu === 'config' && (
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('bots.deleteConfirmation')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('bots.deleteConfirmation')}</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
height: 10rem;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #e4e4e7;
|
||||
padding: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
@@ -11,15 +11,15 @@
|
||||
|
||||
:global(.dark) .cardContainer {
|
||||
background-color: #1f1f22;
|
||||
box-shadow: 0;
|
||||
border-color: #27272a;
|
||||
}
|
||||
|
||||
.cardContainer:hover {
|
||||
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
|
||||
border-color: #a1a1aa;
|
||||
}
|
||||
|
||||
:global(.dark) .cardContainer:hover {
|
||||
box-shadow: 0;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.iconBasicInfoContainer {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
IChooseAdapterEntity,
|
||||
IPipelineEntity,
|
||||
@@ -21,18 +21,11 @@ import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -47,16 +40,20 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, { message: t('bots.botNameRequired') }),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, { message: t('bots.botDescriptionRequired') }),
|
||||
description: z.string().optional(),
|
||||
adapter: z.string().min(1, { message: t('bots.adapterRequired') }),
|
||||
adapter_config: z.record(z.string(), z.any()),
|
||||
enable: z.boolean().optional(),
|
||||
@@ -66,13 +63,13 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
export default function BotForm({
|
||||
initBotId,
|
||||
onFormSubmit,
|
||||
onBotDeleted,
|
||||
onNewBotCreated,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initBotId?: string;
|
||||
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
|
||||
onBotDeleted: () => void;
|
||||
onNewBotCreated: (botId: string) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
@@ -81,7 +78,7 @@ export default function BotForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: t('bots.defaultDescription'),
|
||||
description: '',
|
||||
adapter: '',
|
||||
adapter_config: {},
|
||||
enable: true,
|
||||
@@ -89,19 +86,16 @@ export default function BotForm({
|
||||
},
|
||||
});
|
||||
|
||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
|
||||
// Track whether initial data loading is complete.
|
||||
// setValue calls during init should NOT mark the form as dirty.
|
||||
const isInitializing = useRef(true);
|
||||
|
||||
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
|
||||
useState(new Map<string, IDynamicFormItemSchema[]>());
|
||||
// const [form] = Form.useForm<IBotFormEntity>();
|
||||
const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false);
|
||||
// const [dynamicForm] = Form.useForm();
|
||||
const [adapterNameList, setAdapterNameList] = useState<
|
||||
IChooseAdapterEntity[]
|
||||
>([]);
|
||||
const [adapterIconList, setAdapterIconList] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [adapterDescriptionList, setAdapterDescriptionList] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
@@ -140,11 +134,16 @@ export default function BotForm({
|
||||
return dynamicFormConfigList;
|
||||
}, [currentAdapter, enableWebhook, dynamicFormConfigList]);
|
||||
|
||||
// Notify parent when dirty state changes
|
||||
const { isDirty } = form.formState;
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
|
||||
// 复制到剪贴板的辅助函数
|
||||
const copyToClipboard = (
|
||||
text: string,
|
||||
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
@@ -157,7 +156,6 @@ export default function BotForm({
|
||||
setTimeout(() => setStatus(false), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
// 降级:创建临时textarea复制
|
||||
fallbackCopy(text, setStatus);
|
||||
});
|
||||
} else {
|
||||
@@ -184,21 +182,23 @@ export default function BotForm({
|
||||
};
|
||||
|
||||
function setBotFormValues() {
|
||||
isInitializing.current = true;
|
||||
initBotFormComponent().then(() => {
|
||||
// 拉取初始化表单信息
|
||||
if (initBotId) {
|
||||
getBotConfig(initBotId)
|
||||
.then((val) => {
|
||||
form.setValue('name', val.name);
|
||||
form.setValue('description', val.description);
|
||||
form.setValue('adapter', val.adapter);
|
||||
form.setValue('adapter_config', val.adapter_config);
|
||||
form.setValue('enable', val.enable);
|
||||
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');
|
||||
// Use form.reset() to set values AND update the dirty baseline,
|
||||
// so isDirty stays false after initial load.
|
||||
form.reset({
|
||||
name: val.name,
|
||||
description: val.description,
|
||||
adapter: val.adapter,
|
||||
adapter_config: val.adapter_config,
|
||||
enable: val.enable,
|
||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||
});
|
||||
handleAdapterSelect(val.adapter);
|
||||
// dynamicForm.setFieldsValue(val.adapter_config);
|
||||
|
||||
// 设置 webhook 地址(如果有)
|
||||
if (val.webhook_full_url) {
|
||||
setWebhookUrl(val.webhook_full_url);
|
||||
} else {
|
||||
@@ -210,28 +210,31 @@ export default function BotForm({
|
||||
toast.error(
|
||||
t('bots.getBotConfigError') + (err as CustomApiError).msg,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
isInitializing.current = false;
|
||||
});
|
||||
} else {
|
||||
form.reset();
|
||||
setWebhookUrl('');
|
||||
setExtraWebhookUrl('');
|
||||
isInitializing.current = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initBotFormComponent() {
|
||||
// 初始化流水线列表
|
||||
const pipelinesRes = await httpClient.getPipelines();
|
||||
setPipelineNameList(
|
||||
pipelinesRes.pipelines.map((item) => {
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.uuid ?? '',
|
||||
emoji: item.emoji,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// 拉取adapter
|
||||
const adaptersRes = await httpClient.getAdapters();
|
||||
setAdapterNameList(
|
||||
adaptersRes.adapters.map((item) => {
|
||||
@@ -242,18 +245,6 @@ export default function BotForm({
|
||||
}),
|
||||
);
|
||||
|
||||
// 初始化适配器图标列表
|
||||
setAdapterIconList(
|
||||
adaptersRes.adapters.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = httpClient.getAdapterIconURL(item.name);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
);
|
||||
|
||||
// 初始化适配器描述列表
|
||||
setAdapterDescriptionList(
|
||||
adaptersRes.adapters.reduce(
|
||||
(acc, item) => {
|
||||
@@ -264,7 +255,6 @@ export default function BotForm({
|
||||
),
|
||||
);
|
||||
|
||||
// 初始化适配器表单map
|
||||
adaptersRes.adapters.forEach((rawAdapter) => {
|
||||
adapterNameToDynamicConfigMap.set(
|
||||
rawAdapter.name,
|
||||
@@ -341,15 +331,13 @@ export default function BotForm({
|
||||
}
|
||||
}
|
||||
|
||||
// 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里
|
||||
function onDynamicFormSubmit() {
|
||||
setIsLoading(true);
|
||||
if (initBotId) {
|
||||
// 编辑提交
|
||||
const updateBot: Bot = {
|
||||
uuid: initBotId,
|
||||
name: form.getValues().name,
|
||||
description: form.getValues().description,
|
||||
description: form.getValues().description ?? '',
|
||||
adapter: form.getValues().adapter,
|
||||
adapter_config: form.getValues().adapter_config,
|
||||
enable: form.getValues().enable,
|
||||
@@ -358,6 +346,8 @@ export default function BotForm({
|
||||
httpClient
|
||||
.updateBot(initBotId, updateBot)
|
||||
.then(() => {
|
||||
// Reset dirty baseline to current values so isDirty becomes false
|
||||
form.reset(form.getValues());
|
||||
onFormSubmit(form.getValues());
|
||||
toast.success(t('bots.saveSuccess'));
|
||||
})
|
||||
@@ -366,14 +356,11 @@ export default function BotForm({
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
// form.reset();
|
||||
// dynamicForm.resetFields();
|
||||
});
|
||||
} else {
|
||||
// 创建提交
|
||||
const newBot: Bot = {
|
||||
name: form.getValues().name,
|
||||
description: form.getValues().description,
|
||||
description: form.getValues().description ?? '',
|
||||
adapter: form.getValues().adapter,
|
||||
adapter_config: form.getValues().adapter_config,
|
||||
};
|
||||
@@ -393,181 +380,30 @@ export default function BotForm({
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
form.reset();
|
||||
// dynamicForm.resetFields();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function deleteBot() {
|
||||
if (initBotId) {
|
||||
httpClient
|
||||
.deleteBot(initBotId)
|
||||
.then(() => {
|
||||
onBotDeleted();
|
||||
toast.success(t('bots.deleteSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('bots.deleteError') + err.msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- Webhook URL display helper ---
|
||||
const showWebhook =
|
||||
initBotId &&
|
||||
webhookUrl &&
|
||||
(currentAdapter !== 'lark' || enableWebhook !== false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={showDeleteConfirmModal}
|
||||
onOpenChange={setShowDeleteConfirmModal}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="bot-form"
|
||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>{t('bots.deleteConfirmation')}</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirmModal(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
deleteBot();
|
||||
setShowDeleteConfirmModal(false);
|
||||
}}
|
||||
>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="bot-form"
|
||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 是否启用 & 绑定流水线 仅在编辑模式 */}
|
||||
{initBotId && (
|
||||
<>
|
||||
<div className="flex items-center gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
|
||||
<FormLabel>{t('common.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="use_pipeline_uuid"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
|
||||
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} {...field}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectPipeline')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
<SelectGroup>
|
||||
{pipelineNameList.map((item) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Webhook 地址显示(统一 Webhook 模式) */}
|
||||
{webhookUrl &&
|
||||
(currentAdapter !== 'lark' || enableWebhook !== false) && (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||
onClick={(e) => {
|
||||
// 点击输入框时自动全选
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(webhookUrl, setCopied)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600 mr-2" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
{extraWebhookUrl && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={extraWebhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||
onClick={(e) => {
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(extraWebhookUrl, setExtraCopied)
|
||||
}
|
||||
>
|
||||
{extraCopied ? (
|
||||
<Check className="h-4 w-4 text-green-600 mr-2" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{extraWebhookUrl
|
||||
? t('bots.webhookUrlHintEither')
|
||||
: t('bots.webhookUrlHint')}
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Card 1: Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||
<CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -575,7 +411,7 @@ export default function BotForm({
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.botName')}
|
||||
<span className="text-red-500">*</span>
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@@ -589,10 +425,7 @@ export default function BotForm({
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.botDescription')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -600,7 +433,84 @@ export default function BotForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Pipeline Binding (edit mode only) */}
|
||||
{initBotId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.routingConnection')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.routingConnectionDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="use_pipeline_uuid"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} {...field}>
|
||||
<SelectTrigger>
|
||||
{field.value ? (
|
||||
(() => {
|
||||
const pipeline = pipelineNameList.find(
|
||||
(p) => p.value === field.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{pipeline?.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{pipeline.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span>{pipeline?.label ?? field.value}</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectPipeline')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{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>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 3: Adapter Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.adapterConfigDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="adapter"
|
||||
@@ -608,76 +518,136 @@ export default function BotForm({
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.platformAdapter')}
|
||||
<span className="text-red-500">*</span>
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleAdapterSelect(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleAdapterSelect(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
{field.value ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(field.value)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>
|
||||
{adapterNameList.find(
|
||||
(a) => a.value === field.value,
|
||||
)?.label ?? field.value}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t('bots.selectAdapter')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
<SelectGroup>
|
||||
{adapterNameList.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{adapterNameList.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(item.value)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{currentAdapter && adapterDescriptionList[currentAdapter] && (
|
||||
<FormDescription>
|
||||
{adapterDescriptionList[currentAdapter]}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch('adapter') && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg border">
|
||||
<img
|
||||
src={adapterIconList[form.watch('adapter')]}
|
||||
alt="adapter icon"
|
||||
className="w-12 h-12 rounded-[8%]"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-medium">
|
||||
{
|
||||
adapterNameList.find(
|
||||
(item) => item.value === form.watch('adapter'),
|
||||
)?.label
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{adapterDescriptionList[form.watch('adapter')]}
|
||||
</div>
|
||||
{/* Webhook URL: shown after adapter is selected (edit mode only) */}
|
||||
{showWebhook && (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-muted"
|
||||
onClick={(e) => {
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(webhookUrl, setCopied)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{extraWebhookUrl && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={extraWebhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-muted"
|
||||
onClick={(e) => {
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(extraWebhookUrl, setExtraCopied)
|
||||
}
|
||||
>
|
||||
{extraCopied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<FormDescription>
|
||||
{extraWebhookUrl
|
||||
? t('bots.webhookUrlHintEither')
|
||||
: t('bots.webhookUrlHint')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
{showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-medium">
|
||||
{t('bots.adapterConfig')}
|
||||
</div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={filteredDynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={filteredDynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ export interface IChooseAdapterEntity {
|
||||
export interface IPipelineEntity {
|
||||
label: string;
|
||||
value: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
@@ -2,220 +2,187 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
import styles from './botLog.module.css';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { PhotoProvider } from 'react-photo-view';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
const LEVEL_STYLES: Record<string, string> = {
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
warning:
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
debug: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
const SHORT_TEXT_LIMIT = 120;
|
||||
|
||||
export function BotLogCard({
|
||||
botLog,
|
||||
defaultExpanded = false,
|
||||
}: {
|
||||
botLog: BotLog;
|
||||
defaultExpanded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const baseURL = httpClient.getBaseUrl();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
function copySessionId() {
|
||||
const text = botLog.message_session_id;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
})
|
||||
.catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 复制方法,用于不支持 clipboard API 的环境
|
||||
function fallbackCopy(text: string) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
const now = new Date();
|
||||
const date = new Date(timestamp * 1000);
|
||||
|
||||
// 获取各个时间部分
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1; // 月份从0开始,需要+1
|
||||
const day = date.getDate();
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// 判断时间范围
|
||||
const isToday = now.toDateString() === date.toDateString();
|
||||
const isYesterday =
|
||||
new Date(now.setDate(now.getDate() - 1)).toDateString() ===
|
||||
date.toDateString();
|
||||
const isThisYear = now.getFullYear() === year;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const isYesterday = yesterday.toDateString() === date.toDateString();
|
||||
const isThisYear = now.getFullYear() === date.getFullYear();
|
||||
|
||||
if (isToday) {
|
||||
return `${hours}:${minutes}`; // 今天的消息:小时:分钟
|
||||
} else if (isYesterday) {
|
||||
return `${t('bots.yesterday')} ${hours}:${minutes}`; // 昨天的消息:昨天 小时:分钟
|
||||
} else if (isThisYear) {
|
||||
return t('bots.dateFormat', { month, day }); // 本年消息:x月x日
|
||||
} else {
|
||||
return t('bots.earlier'); // 更早的消息:更久之前
|
||||
}
|
||||
if (isToday) return `${hours}:${minutes}`;
|
||||
if (isYesterday) return `${t('bots.yesterday')} ${hours}:${minutes}`;
|
||||
if (isThisYear)
|
||||
return t('bots.dateFormat', {
|
||||
month: date.getMonth() + 1,
|
||||
day: date.getDate(),
|
||||
});
|
||||
return t('bots.earlier');
|
||||
}
|
||||
|
||||
function getSubChatId(str: string) {
|
||||
const strArr = str.split('');
|
||||
return strArr;
|
||||
}
|
||||
|
||||
// 根据日志级别返回对应的样式类
|
||||
function getLevelStyles(level: string) {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'warning':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
|
||||
case 'info':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'debug':
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
// 截取文本的简短版本
|
||||
function getShortText(text: string, maxLength: number = 100) {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
// 判断是否需要展开按钮
|
||||
const needsExpand = botLog.text.length > 100 || botLog.images.length > 0;
|
||||
const needsExpand =
|
||||
botLog.text.length > SHORT_TEXT_LIMIT || botLog.images.length > 0;
|
||||
const levelStyle =
|
||||
LEVEL_STYLES[botLog.level.toLowerCase()] ?? LEVEL_STYLES.debug;
|
||||
|
||||
return (
|
||||
<div className={`${styles.botLogCardContainer}`}>
|
||||
{/* 头部标签,时间 */}
|
||||
<div className={`${styles.cardTitleContainer}`}>
|
||||
<div className={`flex flex-row gap-2 items-center`}>
|
||||
<div
|
||||
className={`px-2 py-1 rounded text-xs font-medium uppercase ${getLevelStyles(
|
||||
botLog.level,
|
||||
)}`}
|
||||
<div className="rounded-lg border bg-card px-3.5 py-3 transition-colors hover:border-border/80">
|
||||
{/* Header: level badge, session id, expand toggle, timestamp */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Level badge */}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase leading-none',
|
||||
levelStyle,
|
||||
)}
|
||||
>
|
||||
{botLog.level}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
{/* Session ID */}
|
||||
{botLog.message_session_id && (
|
||||
<div
|
||||
className={`${styles.tag} ${styles.chatTag} relative`}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 兼容性更好的复制方法
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(botLog.message_session_id)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback
|
||||
fallbackCopy(botLog.message_session_id);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(botLog.message_session_id);
|
||||
}
|
||||
copySessionId();
|
||||
}}
|
||||
title={t('common.clickToCopy')}
|
||||
className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[11px] font-mono text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors truncate max-w-48 cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
<Check className="size-3 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<svg
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="1664"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z"
|
||||
p-id="1665"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path
|
||||
d="M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z"
|
||||
p-id="1666"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path
|
||||
d="M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z"
|
||||
p-id="1667"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<Copy className="size-3 shrink-0" />
|
||||
)}
|
||||
|
||||
<span className={`${styles.chatId}`}>
|
||||
{getSubChatId(botLog.message_session_id)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate">{botLog.message_session_id}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{needsExpand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
|
||||
className="flex items-center gap-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<ChevronDown className="size-3" />
|
||||
{t('bots.collapse')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<ChevronRight className="size-3" />
|
||||
{t('bots.viewDetails')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className={`${styles.timestamp}`}>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatTime(botLog.timestamp)}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志内容 - 简化显示 */}
|
||||
<div className={`${styles.cardText}`}>
|
||||
{expanded ? botLog.text : getShortText(botLog.text)}
|
||||
{/* Log text */}
|
||||
<div className="mt-2 text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||
{expanded
|
||||
? botLog.text
|
||||
: botLog.text.length > SHORT_TEXT_LIMIT
|
||||
? botLog.text.slice(0, SHORT_TEXT_LIMIT) + '...'
|
||||
: botLog.text}
|
||||
</div>
|
||||
|
||||
{/* 图片 - 只在展开时显示 */}
|
||||
{/* Images (expanded) */}
|
||||
{expanded && botLog.images.length > 0 && (
|
||||
<PhotoProvider>
|
||||
<div className={`flex flex-wrap gap-2 mt-3`}>
|
||||
<div className="flex flex-wrap gap-2 mt-2.5">
|
||||
{botLog.images.map((item) => (
|
||||
<img
|
||||
key={item}
|
||||
src={`${baseURL}/api/v1/files/image/${item}`}
|
||||
alt=""
|
||||
className="max-w-xs rounded cursor-pointer hover:opacity-90 transition-opacity"
|
||||
className="max-w-xs rounded-md cursor-pointer hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PhotoProvider>
|
||||
)}
|
||||
|
||||
{/* 图片数量提示 - 未展开时显示 */}
|
||||
{/* Image count hint (collapsed) */}
|
||||
{!expanded && botLog.images.length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
📷 {botLog.images.length} {t('bots.imagesAttached')}
|
||||
<div className="mt-1.5 text-[11px] text-muted-foreground">
|
||||
{botLog.images.length} {t('bots.imagesAttached')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager'
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';
|
||||
import styles from './botLog.module.css';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Popover,
|
||||
@@ -18,7 +17,20 @@ import { debounce } from 'lodash';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
export function BotLogListComponent({
|
||||
botId,
|
||||
autoExpandImages = false,
|
||||
hideDetailedLogsLink = false,
|
||||
hideToolbar = false,
|
||||
}: {
|
||||
botId: string;
|
||||
/** When true, log entries with images are rendered expanded by default */
|
||||
autoExpandImages?: boolean;
|
||||
/** When true, hides the "View Detailed Logs" navigation button */
|
||||
hideDetailedLogsLink?: boolean;
|
||||
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
||||
hideToolbar?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const manager = useRef(new BotLogManager(botId)).current;
|
||||
@@ -50,7 +62,6 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
botLogListRef.current = botLogList;
|
||||
}, [botLogList]);
|
||||
|
||||
// 根据级别过滤日志
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (selectedLevels.length === 0) {
|
||||
return botLogList;
|
||||
@@ -75,18 +86,15 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
if (selectedLevels.length === logLevels.length) {
|
||||
return t('bots.allLevels');
|
||||
}
|
||||
// 如果选中3个或以上,显示数量
|
||||
if (selectedLevels.length >= 3) {
|
||||
return `${selectedLevels.length} ${t('bots.levelsSelected')}`;
|
||||
}
|
||||
// 显示选中级别的标签(大写形式)
|
||||
return logLevels
|
||||
.filter((level) => selectedLevels.includes(level.value))
|
||||
.map((level) => level.label)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
// 观测自动刷新状态
|
||||
useEffect(() => {
|
||||
if (autoFlush) {
|
||||
manager.startListenServerPush();
|
||||
@@ -99,13 +107,10 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
}, [autoFlush]);
|
||||
|
||||
function initComponent() {
|
||||
// 订阅日志推送
|
||||
manager.subscribeLogPush(handleBotLogPush);
|
||||
// 加载第一页日志
|
||||
manager.loadFirstPage().then((response) => {
|
||||
setBotLogList(response.reverse());
|
||||
});
|
||||
// 监听滚动
|
||||
listenScroll();
|
||||
}
|
||||
|
||||
@@ -115,28 +120,19 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
}
|
||||
|
||||
function listenScroll() {
|
||||
if (!listContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
const list = listContainerRef.current;
|
||||
list.addEventListener('scroll', handleScroll);
|
||||
if (!listContainerRef.current) return;
|
||||
listContainerRef.current.addEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
function removeScrollListener() {
|
||||
if (!listContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
const list = listContainerRef.current;
|
||||
list.removeEventListener('scroll', handleScroll);
|
||||
if (!listContainerRef.current) return;
|
||||
listContainerRef.current.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
// 加载更多日志
|
||||
const list = botLogListRef.current;
|
||||
const lastSeq = list[list.length - 1].seq_id;
|
||||
if (lastSeq === 0) {
|
||||
return;
|
||||
}
|
||||
if (lastSeq === 0) return;
|
||||
manager.loadMore(lastSeq - 1, 10).then((response) => {
|
||||
setBotLogList([...list, ...response.reverse()]);
|
||||
});
|
||||
@@ -165,63 +161,101 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
if (!isTop && !isBottom) {
|
||||
setAutoFlush(false);
|
||||
}
|
||||
}, 300), // 防抖延迟 300ms
|
||||
[botLogList], // 依赖项为空
|
||||
}, 300),
|
||||
[botLogList],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${styles.botLogListContainer}`} ref={listContainerRef}>
|
||||
<div className={`${styles.listHeader}`}>
|
||||
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>
|
||||
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />
|
||||
<div className={'ml-4 mr-2'}>{t('bots.logLevel')}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="flex flex-col h-full min-h-0 overflow-y-auto"
|
||||
ref={listContainerRef}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
{!hideToolbar && (
|
||||
<div className="flex items-center gap-3 pb-3 shrink-0 flex-wrap">
|
||||
{/* Auto-refresh toggle */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('bots.enableAutoRefresh')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={autoFlush}
|
||||
onCheckedChange={(v) => setAutoFlush(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Level filter */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('bots.logLevel')}
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-[160px] justify-between"
|
||||
>
|
||||
<span className="text-sm truncate">{getDisplayText()}</span>
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[160px] p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{logLevels.map((level) => (
|
||||
<div
|
||||
key={level.value}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={level.value}
|
||||
checked={selectedLevels.includes(level.value)}
|
||||
onCheckedChange={() => handleLevelToggle(level.value)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={level.value}
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
{level.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Link to detailed logs */}
|
||||
{!hideDetailedLogsLink && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-[180px] flex items-center justify-between"
|
||||
className="gap-1"
|
||||
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
|
||||
>
|
||||
<span className="text-sm truncate flex-1 text-left">
|
||||
{getDisplayText()}
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 flex-shrink-0" />
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{logLevels.map((level) => (
|
||||
<div key={level.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={level.value}
|
||||
checked={selectedLevels.includes(level.value)}
|
||||
onCheckedChange={() => handleLevelToggle(level.value)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={level.value}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{level.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-4 flex items-center gap-1"
|
||||
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredLogs.map((botLog) => {
|
||||
return <BotLogCard botLog={botLog} key={botLog.seq_id} />;
|
||||
})}
|
||||
{/* Log cards */}
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">{t('bots.noLogs')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{filteredLogs.map((botLog) => (
|
||||
<BotLogCard
|
||||
botLog={botLog}
|
||||
key={botLog.seq_id}
|
||||
defaultExpanded={autoExpandImages && botLog.images.length > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
.botLogListContainer {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botLogCardContainer {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botLogCardContainer:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .botLogCardContainer {
|
||||
background-color: #1f1f22;
|
||||
border: 1px solid #2a2a2e;
|
||||
}
|
||||
|
||||
:global(.dark) .botLogCardContainer:hover {
|
||||
border-color: #3a3a3e;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
height: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
max-width: 16rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
:global(.dark) .tag {
|
||||
background-color: #1e3a8a;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.chatTag {
|
||||
color: #4b5563;
|
||||
background-color: #f3f4f6;
|
||||
text-transform: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.chatTag:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
:global(.dark) .chatTag {
|
||||
color: #9ca3af;
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .chatTag:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.chatId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
|
||||
'Courier New', monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.cardTitleContainer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
color: #1e293b;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
max-width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', sans-serif;
|
||||
}
|
||||
|
||||
:global(.dark) .cardText {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.dark) .timestamp {
|
||||
color: #64748b;
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
@@ -49,11 +55,18 @@ interface SessionMessage {
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface BotSessionMonitorHandle {
|
||||
refreshSessions: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface BotSessionMonitorProps {
|
||||
botId: string;
|
||||
}
|
||||
|
||||
export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
const BotSessionMonitor = forwardRef<
|
||||
BotSessionMonitorHandle,
|
||||
BotSessionMonitorProps
|
||||
>(function BotSessionMonitor({ botId }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
||||
@@ -97,6 +110,14 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
}
|
||||
}, [botId]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
refreshSessions: loadSessions,
|
||||
}),
|
||||
[loadSessions],
|
||||
);
|
||||
|
||||
const loadMessages = useCallback(async (sessionId: string) => {
|
||||
setLoadingMessages(true);
|
||||
try {
|
||||
@@ -235,7 +256,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="mb-2 pl-2.5 border-l-2 border-gray-300 dark:border-gray-600 opacity-80"
|
||||
className="mb-2 pl-2.5 border-l-2 border-muted-foreground/50 opacity-80"
|
||||
>
|
||||
<div className="text-sm">
|
||||
{quote.origin?.map((comp, idx) =>
|
||||
@@ -278,9 +299,17 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
);
|
||||
};
|
||||
|
||||
// Backend timestamps may lack timezone indicator; treat as UTC
|
||||
const parseTimestamp = (timestamp: string): Date => {
|
||||
if (!timestamp) return new Date(0);
|
||||
const hasTimezone =
|
||||
timestamp.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(timestamp);
|
||||
return new Date(hasTimezone ? timestamp : timestamp + 'Z');
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string): string => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const date = parseTimestamp(timestamp);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
@@ -288,7 +317,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
|
||||
const formatRelativeTime = (timestamp: string): string => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const date = parseTimestamp(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
@@ -306,36 +335,9 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
||||
{/* Left Panel: Session List */}
|
||||
<div className="w-64 flex-shrink-0 border-r flex flex-col min-h-0">
|
||||
{/* Refresh Button */}
|
||||
<div className="px-2 py-2 border-b shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 text-sm text-muted-foreground"
|
||||
onClick={loadSessions}
|
||||
disabled={loadingSessions}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 mr-1.5',
|
||||
loadingSessions && 'animate-spin',
|
||||
)}
|
||||
>
|
||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
|
||||
</svg>
|
||||
{t('bots.sessionMonitor.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
|
||||
{/* Session List */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{loadingSessions && sessions.length === 0 ? (
|
||||
@@ -347,14 +349,15 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
{t('bots.sessionMonitor.noSessions')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
<div className="p-1.5">
|
||||
{sessions.map((session) => {
|
||||
const isSelected = selectedSessionId === session.session_id;
|
||||
return (
|
||||
<button
|
||||
key={session.session_id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
||||
'w-full text-left px-2.5 py-2 rounded-md transition-colors',
|
||||
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
||||
)}
|
||||
onClick={() => setSelectedSessionId(session.session_id)}
|
||||
@@ -403,20 +406,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
{/* Right Panel: Messages */}
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
{!selectedSessionId ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground text-sm flex-1 flex items-center justify-center">
|
||||
{t('bots.sessionMonitor.selectSession')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat Header */}
|
||||
<div className="px-6 py-3 border-b shrink-0 flex items-center justify-between">
|
||||
<div className="px-4 py-2.5 border-b shrink-0">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{selectedSession?.user_name ||
|
||||
selectedSession?.user_id ||
|
||||
selectedSessionId.slice(0, 20)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
{parseSessionType(selectedSessionId) && (
|
||||
<span>{parseSessionType(selectedSessionId)}</span>
|
||||
)}
|
||||
@@ -433,6 +436,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
{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')}
|
||||
@@ -462,40 +466,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-8 h-8"
|
||||
onClick={() => loadMessages(selectedSessionId)}
|
||||
disabled={loadingMessages}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn('w-4 h-4', loadingMessages && 'animate-spin')}
|
||||
>
|
||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Messages Area — matches DebugDialog style */}
|
||||
{/* Messages Area */}
|
||||
<ScrollArea
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black"
|
||||
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{loadingMessages ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -511,21 +495,18 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-3xl px-5 py-3 rounded-2xl',
|
||||
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
||||
isUser
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
|
||||
? 'bg-primary/10 rounded-br-sm'
|
||||
: 'bg-muted rounded-bl-sm',
|
||||
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
||||
)}
|
||||
>
|
||||
{renderMessageContent(msg)}
|
||||
{/* Role label + timestamp inside bubble, matching DebugDialog */}
|
||||
{/* Role label + timestamp */}
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-2 flex items-center gap-2',
|
||||
isUser
|
||||
? 'text-gray-600 dark:text-gray-300'
|
||||
: 'text-gray-500 dark:text-gray-400',
|
||||
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
@@ -561,4 +542,6 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default BotSessionMonitor;
|
||||
|
||||
@@ -1,144 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './botConfig.module.css';
|
||||
import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';
|
||||
import BotCard from '@/app/home/bots/components/bot-card/BotCard';
|
||||
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Bot, Adapter } from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import BotDetailDialog from '@/app/home/bots/BotDetailDialog';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import BotDetailContent from './BotDetailContent';
|
||||
|
||||
export default function BotConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
// 机器人详情dialog
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState<boolean>(false);
|
||||
const [botList, setBotList] = useState<BotCardVO[]>([]);
|
||||
const [selectedBotId, setSelectedBotId] = useState<string>('');
|
||||
const searchParams = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
useEffect(() => {
|
||||
getBotList();
|
||||
}, []);
|
||||
|
||||
async function getBotList() {
|
||||
const adapterListResp = await httpClient.getAdapters();
|
||||
const adapterList = adapterListResp.adapters.map((adapter: Adapter) => {
|
||||
return {
|
||||
label: extractI18nObject(adapter.label),
|
||||
value: adapter.name,
|
||||
};
|
||||
});
|
||||
|
||||
httpClient
|
||||
.getBots()
|
||||
.then((resp) => {
|
||||
const botList: BotCardVO[] = resp.bots.map((bot: Bot) => {
|
||||
return new BotCardVO({
|
||||
id: bot.uuid || '',
|
||||
iconURL: httpClient.getAdapterIconURL(bot.adapter),
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
adapter: bot.adapter,
|
||||
adapterConfig: bot.adapter_config,
|
||||
adapterLabel:
|
||||
adapterList.find((item) => item.value === bot.adapter)?.label ||
|
||||
bot.adapter.substring(0, 10),
|
||||
usePipelineName: bot.use_pipeline_name || '',
|
||||
enable: bot.enable || false,
|
||||
});
|
||||
});
|
||||
setBotList(botList);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('get bot list error', err);
|
||||
toast.error(t('bots.getBotListError') + (err as CustomApiError).msg);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateBotClick() {
|
||||
const maxBots = systemInfo.limitation?.max_bots ?? -1;
|
||||
if (maxBots >= 0 && botList.length >= maxBots) {
|
||||
toast.error(t('limitation.maxBotsReached', { max: maxBots }));
|
||||
return;
|
||||
}
|
||||
setSelectedBotId('');
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
|
||||
function selectBot(botUUID: string) {
|
||||
setSelectedBotId(botUUID);
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
|
||||
function handleFormSubmit() {
|
||||
getBotList();
|
||||
// setDetailDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleFormCancel() {
|
||||
setDetailDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleBotDeleted() {
|
||||
getBotList();
|
||||
setDetailDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleNewBotCreated(botId: string) {
|
||||
getBotList();
|
||||
setSelectedBotId(botId);
|
||||
if (detailId) {
|
||||
return <BotDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BotDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
botId={selectedBotId || undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onFormCancel={handleFormCancel}
|
||||
onBotDeleted={handleBotDeleted}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
|
||||
{/* 注意:其余的返回内容需要保持在Spin组件外部 */}
|
||||
<div className={`${styles.botListContainer}`}>
|
||||
<CreateCardComponent
|
||||
width={'100%'}
|
||||
height={'10rem'}
|
||||
plusSize={'90px'}
|
||||
onClick={handleCreateBotClick}
|
||||
/>
|
||||
{botList.map((cardVO) => {
|
||||
return (
|
||||
<div
|
||||
key={cardVO.id}
|
||||
onClick={() => {
|
||||
selectBot(cardVO.id);
|
||||
}}
|
||||
>
|
||||
<BotCard
|
||||
botCardVO={cardVO}
|
||||
setBotEnableCallback={(id, enable) => {
|
||||
setBotList(
|
||||
botList.map((bot) => {
|
||||
if (bot.id === id) {
|
||||
return { ...bot, enable: enable };
|
||||
}
|
||||
return bot;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('bots.selectFromSidebar')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,31 @@ import DynamicFormItemComponent from '@/app/home/components/dynamic-form/Dynamic
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Resolve the value referenced by a `show_if.field` string.
|
||||
*
|
||||
* Fields prefixed with `__system.` are looked up in the caller-supplied
|
||||
* `systemContext` dictionary (e.g. `__system.is_wizard` → `systemContext.is_wizard`).
|
||||
* All other field names are resolved from the live form values first, then
|
||||
* fall back to `externalDependentValues`.
|
||||
*/
|
||||
function resolveShowIfValue(
|
||||
field: string,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (field.startsWith('__system.')) {
|
||||
const key = field.slice('__system.'.length);
|
||||
return systemContext?.[key];
|
||||
}
|
||||
if (watchedValues[field] !== undefined) {
|
||||
return watchedValues[field];
|
||||
}
|
||||
return externalDependentValues?.[field];
|
||||
}
|
||||
|
||||
export default function DynamicFormComponent({
|
||||
itemConfigList,
|
||||
@@ -22,6 +47,7 @@ export default function DynamicFormComponent({
|
||||
onFileUploaded,
|
||||
isEditing,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
}: {
|
||||
itemConfigList: IDynamicFormItemSchema[];
|
||||
onSubmit?: (val: object) => unknown;
|
||||
@@ -29,6 +55,9 @@ export default function DynamicFormComponent({
|
||||
onFileUploaded?: (fileKey: string) => void;
|
||||
isEditing?: boolean;
|
||||
externalDependentValues?: Record<string, unknown>;
|
||||
/** Extra variables accessible via the `__system.*` namespace in show_if conditions.
|
||||
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
|
||||
systemContext?: Record<string, unknown>;
|
||||
}) {
|
||||
const isInitialMount = useRef(true);
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
@@ -60,6 +89,13 @@ export default function DynamicFormComponent({
|
||||
fallbacks: [],
|
||||
};
|
||||
}
|
||||
if (item.type === 'prompt-editor') {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
// Default to a single empty system prompt entry
|
||||
return [{ role: 'system', content: '' }];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -240,14 +276,12 @@ export default function DynamicFormComponent({
|
||||
<div className="space-y-4">
|
||||
{itemConfigList.map((config) => {
|
||||
if (config.show_if) {
|
||||
const dependValue =
|
||||
watchedValues[
|
||||
config.show_if.field as keyof typeof watchedValues
|
||||
] !== undefined
|
||||
? watchedValues[
|
||||
config.show_if.field as keyof typeof watchedValues
|
||||
]
|
||||
: externalDependentValues?.[config.show_if.field];
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.show_if.field,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
if (
|
||||
config.show_if.operator === 'eq' &&
|
||||
@@ -273,6 +307,46 @@ export default function DynamicFormComponent({
|
||||
// All fields are disabled when editing (creation_settings are immutable)
|
||||
const isFieldDisabled = !!isEditing;
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (config.type === 'boolean') {
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row items-center justify-between rounded-lg border p-4 max-w-2xl',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
{extractI18nObject(config.label)}
|
||||
</FormLabel>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
onFileUploaded={onFileUploaded}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Plus, X, Eye, Wrench } from 'lucide-react';
|
||||
import { Plus, X, Eye, Wrench, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function DynamicFormItemComponent({
|
||||
config,
|
||||
@@ -181,27 +181,28 @@ export default function DynamicFormItemComponent({
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
className="max-w-xs"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.STRING:
|
||||
return <Input {...field} />;
|
||||
return <Input className="max-w-md" {...field} />;
|
||||
|
||||
case DynamicFormItemType.TEXT:
|
||||
return <Textarea {...field} className="min-h-[120px]" />;
|
||||
return <Textarea {...field} className="min-h-[120px] max-w-2xl" />;
|
||||
|
||||
case DynamicFormItemType.BOOLEAN:
|
||||
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
||||
|
||||
case DynamicFormItemType.STRING_ARRAY:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 max-w-md">
|
||||
{field.value.map((item: string, index: number) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<div key={index} className="flex gap-1.5 items-center">
|
||||
<Input
|
||||
className="w-[200px]"
|
||||
className="flex-1"
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newValue = [...field.value];
|
||||
@@ -209,9 +210,11 @@ export default function DynamicFormItemComponent({
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
const newValue = field.value.filter(
|
||||
(_: string, i: number) => i !== index,
|
||||
@@ -219,24 +222,19 @@ export default function DynamicFormItemComponent({
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5 text-red-500"
|
||||
>
|
||||
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-dashed text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
field.onChange([...field.value, '']);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 mr-1.5" />
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -245,7 +243,7 @@ export default function DynamicFormItemComponent({
|
||||
case DynamicFormItemType.SELECT:
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectTrigger className="max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('common.select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -274,31 +272,33 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('models.selectModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedModels).map(([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="max-w-md">
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('models.selectModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedModels).map(([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
|
||||
@@ -314,25 +314,27 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedEmbeddingModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="max-w-md">
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedEmbeddingModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
||||
@@ -495,11 +497,11 @@ export default function DynamicFormItemComponent({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-destructive"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFallbackModel(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -512,10 +514,10 @@ export default function DynamicFormItemComponent({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
className="w-full border-dashed text-muted-foreground hover:text-foreground"
|
||||
onClick={addFallbackModel}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="size-4 mr-1.5" />
|
||||
{t('models.fallback.addFallback')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -541,7 +543,25 @@ export default function DynamicFormItemComponent({
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />
|
||||
{field.value && field.value !== '__none__' ? (
|
||||
(() => {
|
||||
const selectedKb = knowledgeBases.find(
|
||||
(kb) => kb.uuid === field.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedKb?.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{selectedKb.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span>{selectedKb?.name ?? field.value}</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -553,7 +573,12 @@ export default function DynamicFormItemComponent({
|
||||
<SelectLabel>{engineName}</SelectLabel>
|
||||
{kbs.map((base) => (
|
||||
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
|
||||
{base.name}
|
||||
<div className="flex items-center gap-2">
|
||||
{base.emoji && (
|
||||
<span className="text-sm shrink-0">{base.emoji}</span>
|
||||
)}
|
||||
<span>{base.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -597,6 +622,11 @@ export default function DynamicFormItemComponent({
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{currentKb.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{currentKb.emoji}
|
||||
</span>
|
||||
)}
|
||||
{currentKb.name}
|
||||
{currentKb.knowledge_engine?.name && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
|
||||
@@ -686,7 +716,14 @@ export default function DynamicFormItemComponent({
|
||||
aria-label={`Select ${base.name}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{base.name}</div>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{base.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{base.emoji}
|
||||
</span>
|
||||
)}
|
||||
{base.name}
|
||||
</div>
|
||||
{base.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{base.description}
|
||||
@@ -738,10 +775,18 @@ export default function DynamicFormItemComponent({
|
||||
</Select>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.PROMPT_EDITOR:
|
||||
case DynamicFormItemType.PROMPT_EDITOR: {
|
||||
// Guard: field.value may be undefined when the form resets or
|
||||
// initialValues haven't propagated yet. Fall back to a default
|
||||
// single system-prompt entry to prevent the .map() crash.
|
||||
const promptItems: { role: string; content: string }[] = Array.isArray(
|
||||
field.value,
|
||||
)
|
||||
? field.value
|
||||
: [{ role: 'system', content: '' }];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{field.value.map(
|
||||
{promptItems.map(
|
||||
(item: { role: string; content: string }, index: number) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
{/* 角色选择 */}
|
||||
@@ -753,7 +798,7 @@ export default function DynamicFormItemComponent({
|
||||
<Select
|
||||
value={item.role}
|
||||
onValueChange={(value) => {
|
||||
const newValue = [...field.value];
|
||||
const newValue = [...(field.value ?? promptItems)];
|
||||
newValue[index] = { ...newValue[index], role: value };
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
@@ -774,7 +819,7 @@ export default function DynamicFormItemComponent({
|
||||
className="w-[300px]"
|
||||
value={item.content}
|
||||
onChange={(e) => {
|
||||
const newValue = [...field.value];
|
||||
const newValue = [...(field.value ?? promptItems)];
|
||||
newValue[index] = {
|
||||
...newValue[index],
|
||||
content: e.target.value,
|
||||
@@ -788,7 +833,7 @@ export default function DynamicFormItemComponent({
|
||||
type="button"
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
onClick={() => {
|
||||
const newValue = field.value.filter(
|
||||
const newValue = (field.value ?? promptItems).filter(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(_: any, i: number) => i !== index,
|
||||
);
|
||||
@@ -812,13 +857,17 @@ export default function DynamicFormItemComponent({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
field.onChange([...field.value, { role: 'user', content: '' }]);
|
||||
field.onChange([
|
||||
...(field.value ?? promptItems),
|
||||
{ role: 'user', content: '' },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t('common.addRound')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case DynamicFormItemType.FILE:
|
||||
return (
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
.sidebarContainer {
|
||||
box-sizing: border-box;
|
||||
width: 11rem;
|
||||
height: 100vh;
|
||||
background-color: #eee;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding-block: 1rem;
|
||||
padding-left: 0.4rem;
|
||||
user-select: none;
|
||||
/* box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1); */
|
||||
}
|
||||
|
||||
:global(.dark) .sidebarContainer {
|
||||
background-color: #0a0a0b !important;
|
||||
}
|
||||
|
||||
.langbotIconContainer {
|
||||
width: 200px;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.langbotIcon {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:global(.dark) .langbotIcon {
|
||||
box-shadow: 0 0 10px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.langbotTextContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.langbotText {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
:global(.dark) .langbotText {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.langbotVersion {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #6c6c6c;
|
||||
}
|
||||
|
||||
:global(.dark) .langbotVersion {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #a0a0a0 !important;
|
||||
}
|
||||
|
||||
.sidebarTopContainer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebarItemsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebarChildContainer {
|
||||
width: 9.8rem;
|
||||
height: 3rem;
|
||||
padding-left: 1.6rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
/* background-color: aqua; */
|
||||
}
|
||||
|
||||
.sidebarSelected {
|
||||
background-color: #2288ee;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .sidebarSelected {
|
||||
background-color: #2288ee;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px 0 rgba(34, 136, 238, 0.3);
|
||||
}
|
||||
|
||||
.sidebarUnselected {
|
||||
color: #6c6c6c;
|
||||
}
|
||||
|
||||
:global(.dark) .sidebarUnselected {
|
||||
color: #a0a0a0 !important;
|
||||
}
|
||||
|
||||
.sidebarUnselected:hover {
|
||||
background-color: rgba(34, 136, 238, 0.1);
|
||||
color: #2288ee;
|
||||
}
|
||||
|
||||
:global(.dark) .sidebarUnselected:hover {
|
||||
background-color: rgba(34, 136, 238, 0.2);
|
||||
color: #66baff;
|
||||
}
|
||||
|
||||
.sidebarChildIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: rgba(96, 149, 209, 0);
|
||||
}
|
||||
|
||||
.sidebarChildName {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebarBottomContainer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebarBottomChildContainer {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import styles from './HomeSidebar.module.css';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
export type SidebarSection = 'home' | 'extensions' | 'standalone';
|
||||
|
||||
export interface ISidebarChildVO {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -8,6 +9,7 @@ export interface ISidebarChildVO {
|
||||
route: string;
|
||||
description: string;
|
||||
helpLink: I18nObject;
|
||||
section?: SidebarSection;
|
||||
}
|
||||
|
||||
export class SidebarChildVO {
|
||||
@@ -17,6 +19,7 @@ export class SidebarChildVO {
|
||||
route: string;
|
||||
description: string;
|
||||
helpLink: I18nObject;
|
||||
section: SidebarSection;
|
||||
|
||||
constructor(props: ISidebarChildVO) {
|
||||
this.id = props.id;
|
||||
@@ -25,29 +28,6 @@ export class SidebarChildVO {
|
||||
this.route = props.route;
|
||||
this.description = props.description;
|
||||
this.helpLink = props.helpLink;
|
||||
this.section = props.section ?? 'home';
|
||||
}
|
||||
}
|
||||
|
||||
export function SidebarChild({
|
||||
icon,
|
||||
name,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sidebarChildContainer} ${
|
||||
isSelected ? styles.sidebarSelected : styles.sidebarUnselected
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={`${styles.sidebarChildIcon}`}>{icon}</div>
|
||||
<span className={`${styles.sidebarChildName}`}>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
242
web/src/app/home/components/home-sidebar/SidebarDataContext.tsx
Normal file
242
web/src/app/home/components/home-sidebar/SidebarDataContext.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { isNewerVersion } from '@/app/utils/versionCompare';
|
||||
|
||||
// Lightweight entity item for sidebar display
|
||||
export interface SidebarEntityItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji?: string;
|
||||
iconURL?: string;
|
||||
updatedAt?: string; // ISO timestamp for sorting by most recently edited
|
||||
// Bot-specific fields
|
||||
enabled?: boolean;
|
||||
// MCP-specific fields
|
||||
runtimeStatus?: 'connecting' | 'connected' | 'error';
|
||||
// Plugin-specific fields
|
||||
installSource?: string;
|
||||
installInfo?: Record<string, unknown>;
|
||||
hasUpdate?: boolean;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
// Install action types that can be triggered from sidebar
|
||||
export type PluginInstallAction = 'local' | 'github' | null;
|
||||
|
||||
// Entity lists and refresh functions exposed via context
|
||||
export interface SidebarDataContextValue {
|
||||
bots: SidebarEntityItem[];
|
||||
pipelines: SidebarEntityItem[];
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
mcpServers: SidebarEntityItem[];
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
refreshPlugins: () => Promise<void>;
|
||||
refreshMCPServers: () => Promise<void>;
|
||||
refreshAll: () => Promise<void>;
|
||||
// Breadcrumb: entity name shown when viewing a detail page
|
||||
detailEntityName: string | null;
|
||||
setDetailEntityName: (name: string | null) => void;
|
||||
// Pending plugin install action triggered from sidebar
|
||||
pendingPluginInstallAction: PluginInstallAction;
|
||||
setPendingPluginInstallAction: (action: PluginInstallAction) => void;
|
||||
}
|
||||
|
||||
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
|
||||
|
||||
export function SidebarDataProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [bots, setBots] = useState<SidebarEntityItem[]>([]);
|
||||
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
|
||||
useState<PluginInstallAction>(null);
|
||||
|
||||
const refreshBots = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getBots();
|
||||
setBots(
|
||||
resp.bots.map((bot) => ({
|
||||
id: bot.uuid || '',
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
iconURL: httpClient.getAdapterIconURL(bot.adapter),
|
||||
updatedAt: bot.updated_at,
|
||||
enabled: bot.enable ?? true,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bots for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshPipelines = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getPipelines();
|
||||
setPipelines(
|
||||
resp.pipelines.map((p) => ({
|
||||
id: p.uuid || '',
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
emoji: p.emoji,
|
||||
updatedAt: p.updated_at,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pipelines for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshKnowledgeBases = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBases();
|
||||
setKnowledgeBases(
|
||||
resp.bases.map((kb) => ({
|
||||
id: kb.uuid || '',
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji,
|
||||
updatedAt: kb.updated_at,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshPlugins = useCallback(async () => {
|
||||
try {
|
||||
const [pluginsResp, marketplaceResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
getCloudServiceClientSync()
|
||||
.getMarketplacePlugins(1, 100)
|
||||
.catch(() => ({ plugins: [] })),
|
||||
]);
|
||||
|
||||
// Build marketplace version lookup: "author/name" -> latest_version
|
||||
const marketplaceVersions = new Map<string, string>();
|
||||
for (const mp of marketplaceResp.plugins) {
|
||||
if (mp.latest_version) {
|
||||
marketplaceVersions.set(`${mp.author}/${mp.name}`, mp.latest_version);
|
||||
}
|
||||
}
|
||||
|
||||
setPlugins(
|
||||
pluginsResp.plugins.map((plugin) => {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const compositeKey = `${author}/${name}`;
|
||||
const installedVersion = meta.version ?? '';
|
||||
|
||||
let hasUpdate = false;
|
||||
if (plugin.install_source === 'marketplace') {
|
||||
const latestVersion = marketplaceVersions.get(compositeKey);
|
||||
if (latestVersion) {
|
||||
hasUpdate = isNewerVersion(latestVersion, installedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
debug: plugin.debug,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugins for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshMCPServers = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getMCPServers();
|
||||
setMCPServers(
|
||||
resp.servers.map((server) => ({
|
||||
id: server.name,
|
||||
name: server.name,
|
||||
enabled: server.enable,
|
||||
runtimeStatus: server.runtime_info?.status,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MCP servers for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([
|
||||
refreshBots(),
|
||||
refreshPipelines(),
|
||||
refreshKnowledgeBases(),
|
||||
refreshPlugins(),
|
||||
refreshMCPServers(),
|
||||
]);
|
||||
}, [
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
refreshPlugins,
|
||||
refreshMCPServers,
|
||||
]);
|
||||
|
||||
// Fetch all entity lists on mount
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
|
||||
return (
|
||||
<SidebarDataContext.Provider
|
||||
value={{
|
||||
bots,
|
||||
pipelines,
|
||||
knowledgeBases,
|
||||
plugins,
|
||||
mcpServers,
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
refreshPlugins,
|
||||
refreshMCPServers,
|
||||
refreshAll,
|
||||
detailEntityName,
|
||||
setDetailEntityName,
|
||||
pendingPluginInstallAction,
|
||||
setPendingPluginInstallAction,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebarData(): SidebarDataContextValue {
|
||||
const ctx = useContext(SidebarDataContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useSidebarData must be used within a SidebarDataProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||
import styles from './HomeSidebar.module.css';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const t = (key: string) => {
|
||||
@@ -7,12 +6,54 @@ const t = (key: string) => {
|
||||
};
|
||||
|
||||
export const sidebarConfigList = [
|
||||
// ── Quick Start ──
|
||||
new SidebarChildVO({
|
||||
id: 'wizard',
|
||||
name: t('sidebar.quickStart'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M13 9H21L11 24V15H4L13 0V9ZM11 11V7.22063L7.53238 13H13V17.3944L17.263 11H11Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/wizard',
|
||||
description: t('wizard.sidebarDescription'),
|
||||
helpLink: {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
section: 'standalone',
|
||||
}),
|
||||
|
||||
// ── Home section ──
|
||||
new SidebarChildVO({
|
||||
id: 'monitoring',
|
||||
name: t('monitoring.title'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/monitoring',
|
||||
description: t('monitoring.description'),
|
||||
helpLink: {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'bots',
|
||||
name: t('bots.title'),
|
||||
icon: (
|
||||
<svg
|
||||
className={`${styles.sidebarChildIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
@@ -27,13 +68,13 @@ export const sidebarConfigList = [
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'pipelines',
|
||||
name: t('pipelines.title'),
|
||||
icon: (
|
||||
<svg
|
||||
className={`${styles.sidebarChildIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
@@ -48,26 +89,7 @@ export const sidebarConfigList = [
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme',
|
||||
},
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'monitoring',
|
||||
name: t('monitoring.title'),
|
||||
icon: (
|
||||
<svg
|
||||
className={`${styles.sidebarChildIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/monitoring',
|
||||
description: t('monitoring.description'),
|
||||
helpLink: {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'knowledge',
|
||||
@@ -88,13 +110,15 @@ export const sidebarConfigList = [
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
|
||||
// ── Extensions section ──
|
||||
new SidebarChildVO({
|
||||
id: 'plugins',
|
||||
name: t('plugins.title'),
|
||||
name: t('sidebar.installedPlugins'),
|
||||
icon: (
|
||||
<svg
|
||||
className={`${styles.sidebarChildIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
@@ -109,5 +133,47 @@ export const sidebarConfigList = [
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'market',
|
||||
name: t('sidebar.pluginMarket'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M21 13.242V20H22V22H2V20H3V13.242C1.79401 12.435 1 11.0602 1 9.5C1 8.67286 1.25027 7.90335 1.67755 7.2612L4.5547 2.36088C4.80513 1.93859 5.26028 1.67578 5.76 1.67578H18.24C18.7397 1.67578 19.1949 1.93859 19.4453 2.36088L22.3225 7.2612C22.7497 7.90335 23 8.67286 23 9.5C23 11.0602 22.206 12.435 21 13.242ZM19 13.972C18.4511 14.0706 17.8794 14.0706 17.3305 13.972C16.1644 13.7566 15.1377 13.0712 14.5 12.1C13.8623 13.0712 12.8356 13.7566 11.6695 13.972C11.1206 14.0706 10.5489 14.0706 10 13.972C9.45108 14.0706 8.87938 14.0706 8.33053 13.972C7.16437 13.7566 6.13771 13.0712 5.5 12.1C4.86229 13.0712 3.83563 13.7566 2.66947 13.972C2.44883 14.0124 2.22434 14.0352 2 14.0404V20H5V15H10V20H19V13.972Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/market',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'mcp',
|
||||
name: t('sidebar.mcpServers'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/mcp',
|
||||
description: t('mcp.title'),
|
||||
helpLink: {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import styles from './HomeTittleBar.module.css';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
export default function HomeTitleBar({
|
||||
title,
|
||||
subtitle,
|
||||
helpLink,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
helpLink: I18nObject;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${styles.titleBarContainer}`}>
|
||||
<div className={`${styles.titleText}`}>{title}</div>
|
||||
<div className={`${styles.subtitleText}`}>
|
||||
{subtitle}
|
||||
<span className={`${styles.helpLink}`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
window.open(extractI18nObject(helpLink), '_blank');
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
className="w-[1rem] h-[1rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
.titleBarContainer {
|
||||
width: 100%;
|
||||
padding-top: 1rem;
|
||||
height: 4rem;
|
||||
opacity: 1;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
margin-left: 3.2rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: #585858;
|
||||
}
|
||||
|
||||
:global(.dark) .titleText {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.subtitleText {
|
||||
margin-left: 3.2rem;
|
||||
font-size: 0.8rem;
|
||||
color: #808080;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.dark) .subtitleText {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.helpLink {
|
||||
margin-left: 0.2rem;
|
||||
font-size: 0.8rem;
|
||||
color: #8b8b8b;
|
||||
}
|
||||
|
||||
:global(.dark) .helpLink {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
279
web/src/app/home/knowledge/KBDetailContent.tsx
Normal file
279
web/src/app/home/knowledge/KBDetailContent.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
|
||||
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
|
||||
import KBRetrieveGeneric from '@/app/home/knowledge/components/kb-retrieve/KBRetrieveGeneric';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function KBDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
|
||||
useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('knowledge.createKnowledgeBase'));
|
||||
} else {
|
||||
const kb = knowledgeBases.find((k) => k.id === id);
|
||||
setDetailEntityName(kb?.name ?? id);
|
||||
}
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, knowledgeBases, setDetailEntityName, t]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('metadata');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
|
||||
const loadKbInfo = useCallback(
|
||||
async (kbId: string) => {
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBase(kbId);
|
||||
setKbInfo(resp.base);
|
||||
} catch (e) {
|
||||
console.error('Failed to load KB info:', e);
|
||||
toast.error(
|
||||
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Load KB info for determining capabilities (e.g. doc_ingestion)
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
loadKbInfo(id);
|
||||
}
|
||||
}, [id, isCreateMode, loadKbInfo]);
|
||||
|
||||
const hasDocumentCapability = (): boolean => {
|
||||
if (!kbInfo || !kbInfo.knowledge_engine) return false;
|
||||
return (
|
||||
kbInfo.knowledge_engine.capabilities?.includes('doc_ingestion') ?? false
|
||||
);
|
||||
};
|
||||
|
||||
function handleKbDeleted() {
|
||||
refreshKnowledgeBases();
|
||||
router.push('/home/knowledge');
|
||||
}
|
||||
|
||||
function handleNewKbCreated(newKbId: string) {
|
||||
refreshKnowledgeBases();
|
||||
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
|
||||
}
|
||||
|
||||
function handleKbUpdated() {
|
||||
refreshKnowledgeBases();
|
||||
loadKbInfo(id);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
await httpClient.deleteKnowledgeBase(id);
|
||||
setShowDeleteConfirm(false);
|
||||
handleKbDeleted();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const retrieveFunction = async (kbId: string, query: string) => {
|
||||
return await httpClient.retrieveKnowledgeBase(kbId, query);
|
||||
};
|
||||
|
||||
// ==================== Create Mode ====================
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.createKnowledgeBase')}
|
||||
</h1>
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.editKnowledgeBase')}
|
||||
</h1>
|
||||
<Button type="submit" form="kb-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
<Tabs
|
||||
key={id}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger value="metadata" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('knowledge.metadata')}
|
||||
</TabsTrigger>
|
||||
{hasDocumentCapability() && (
|
||||
<TabsTrigger value="documents" className="gap-1.5">
|
||||
<FolderOpen className="size-3.5" />
|
||||
{t('knowledge.documents')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="retrieve" className="gap-1.5">
|
||||
<Search className="size-3.5" />
|
||||
{t('knowledge.retrieve')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab: Metadata */}
|
||||
<TabsContent
|
||||
value="metadata"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<KBForm
|
||||
initKbId={id}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
|
||||
{/* Danger Zone Card */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('knowledge.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('knowledge.deleteKbAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('knowledge.deleteKbHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Documents */}
|
||||
{hasDocumentCapability() && (
|
||||
<TabsContent
|
||||
value="documents"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<KBDoc
|
||||
kbId={id}
|
||||
ragEngineName={kbInfo?.knowledge_engine?.name}
|
||||
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Tab: Retrieve */}
|
||||
<TabsContent
|
||||
value="retrieve"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<KBRetrieveGeneric kbId={id} retrieveFunction={retrieveFunction} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('knowledge.deleteKnowledgeBaseConfirmation')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{t('knowledge.deleteKnowledgeBaseConfirmation')}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
|
||||
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
|
||||
import KBRetrieveGeneric from '@/app/home/knowledge/components/kb-retrieve/KBRetrieveGeneric';
|
||||
|
||||
interface KBDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
kbId?: string;
|
||||
onFormCancel: () => void;
|
||||
onKbDeleted: () => void;
|
||||
onNewKbCreated: (kbId: string) => void;
|
||||
onKbUpdated: (kbId: string) => void;
|
||||
}
|
||||
|
||||
export default function KBDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
kbId: propKbId,
|
||||
onFormCancel,
|
||||
onKbDeleted,
|
||||
onNewKbCreated,
|
||||
onKbUpdated,
|
||||
}: KBDetailDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [kbId, setKbId] = useState<string | undefined>(propKbId);
|
||||
const [activeMenu, setActiveMenu] = useState('metadata');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setKbId(propKbId);
|
||||
setActiveMenu('metadata');
|
||||
if (propKbId) {
|
||||
loadKbInfo(propKbId);
|
||||
} else {
|
||||
setKbInfo(null);
|
||||
}
|
||||
}, [propKbId, open]);
|
||||
|
||||
async function loadKbInfo(id: string) {
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBase(id);
|
||||
setKbInfo(resp.base);
|
||||
} catch (e) {
|
||||
console.error('Failed to load KB info:', e);
|
||||
toast.error(
|
||||
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this KB supports document management
|
||||
const hasDocumentCapability = (): boolean => {
|
||||
if (!kbInfo || !kbInfo.knowledge_engine) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
kbInfo.knowledge_engine.capabilities?.includes('doc_ingestion') ?? false
|
||||
);
|
||||
};
|
||||
|
||||
// Build menu based on KB capabilities
|
||||
const menu = [
|
||||
{
|
||||
key: 'metadata',
|
||||
label: t('knowledge.metadata'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// Show documents only if capability is present
|
||||
...(hasDocumentCapability()
|
||||
? [
|
||||
{
|
||||
key: 'documents',
|
||||
label: t('knowledge.documents'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'retrieve',
|
||||
label: t('knowledge.retrieve'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M18.031 16.617l4.283 4.282-1.415 1.415-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9 9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617zm-2.006-.742A6.977 6.977 0 0 0 18 11c0-3.868-3.133-7-7-7-3.868 0-7 3.132-7 7 0 3.867 3.132 7 7 7a6.977 6.977 0 0 0 4.875-1.975l.15-.15z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await httpClient.deleteKnowledgeBase(kbId ?? '');
|
||||
onKbDeleted();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete KB:', e);
|
||||
toast.error(
|
||||
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
} finally {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Retrieve function
|
||||
const retrieveFunction = async (id: string, query: string) => {
|
||||
return await httpClient.retrieveKnowledgeBase(id, query);
|
||||
};
|
||||
|
||||
if (!kbId) {
|
||||
// New KB creation
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
|
||||
<main className="flex flex-1 flex-col h-[70vh]">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>{t('knowledge.createKnowledgeBase')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={onNewKbCreated}
|
||||
onKbUpdated={onKbUpdated}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</main>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[50rem] max-h-[75vh] flex">
|
||||
<SidebarProvider className="items-start w-full flex">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{menu.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={activeMenu === item.key}
|
||||
onClick={() => setActiveMenu(item.key)}
|
||||
>
|
||||
<a href="#">
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
<main className="flex flex-1 flex-col h-[75vh] min-w-0 overflow-x-hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>
|
||||
{activeMenu === 'metadata'
|
||||
? t('knowledge.editKnowledgeBase')
|
||||
: activeMenu === 'documents'
|
||||
? t('knowledge.editDocument')
|
||||
: t('knowledge.retrieveTest')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
{activeMenu === 'metadata' && (
|
||||
<KBForm
|
||||
initKbId={kbId}
|
||||
onNewKbCreated={onNewKbCreated}
|
||||
onKbUpdated={onKbUpdated}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'documents' && hasDocumentCapability() && (
|
||||
<KBDoc
|
||||
kbId={kbId}
|
||||
ragEngineName={kbInfo?.knowledge_engine?.name}
|
||||
ragEngineCapabilities={
|
||||
kbInfo?.knowledge_engine?.capabilities
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'retrieve' && (
|
||||
<KBRetrieveGeneric
|
||||
kbId={kbId}
|
||||
retrieveFunction={retrieveFunction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{activeMenu === 'metadata' && (
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{t('knowledge.deleteKnowledgeBaseConfirmation')}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
height: 10rem;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #e4e4e7;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -15,15 +15,15 @@
|
||||
|
||||
:global(.dark) .cardContainer {
|
||||
background-color: #1f1f22;
|
||||
box-shadow: 0;
|
||||
border-color: #27272a;
|
||||
}
|
||||
|
||||
.cardContainer:hover {
|
||||
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
|
||||
border-color: #a1a1aa;
|
||||
}
|
||||
|
||||
:global(.dark) .cardContainer:hover {
|
||||
box-shadow: 0;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.basicInfoContainer {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
Select,
|
||||
@@ -39,9 +46,7 @@ import { UUID } from 'uuidjs';
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, { message: t('knowledge.kbNameRequired') }),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, { message: t('knowledge.kbDescriptionRequired') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
ragEngineId: z
|
||||
.string()
|
||||
@@ -50,17 +55,13 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
|
||||
/**
|
||||
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
|
||||
* Same pattern as ExternalKBForm uses for retriever config
|
||||
*/
|
||||
function parseCreationSchema(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schemaItems: any | any[] | undefined,
|
||||
): IDynamicFormItemSchema[] {
|
||||
if (!schemaItems) return [];
|
||||
|
||||
// Handle wrapped schema (e.g. { schema: [...] }) which might be returned by the API
|
||||
const items = Array.isArray(schemaItems) ? schemaItems : schemaItems.schema;
|
||||
|
||||
if (!items || !Array.isArray(items)) return [];
|
||||
|
||||
return items.map(
|
||||
@@ -83,10 +84,12 @@ export default function KBForm({
|
||||
initKbId,
|
||||
onNewKbCreated,
|
||||
onKbUpdated,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initKbId?: string;
|
||||
onNewKbCreated: (kbId: string) => void;
|
||||
onKbUpdated: (kbId: string) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]);
|
||||
@@ -100,13 +103,17 @@ export default function KBForm({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Dirty tracking: snapshot of saved state for comparison
|
||||
const savedSnapshotRef = useRef<string>('');
|
||||
const isInitializing = useRef(true);
|
||||
|
||||
const formSchema = getFormSchema(t);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: t('knowledge.defaultDescription'),
|
||||
description: '',
|
||||
emoji: '📚',
|
||||
ragEngineId: '',
|
||||
},
|
||||
@@ -117,6 +124,27 @@ export default function KBForm({
|
||||
(e) => e.plugin_id === selectedEngineId,
|
||||
);
|
||||
|
||||
// Dirty tracking: compare current form + dynamic settings against saved snapshot
|
||||
const watchedFormValues = form.watch();
|
||||
useEffect(() => {
|
||||
if (!savedSnapshotRef.current || isInitializing.current) return;
|
||||
const currentSnapshot = JSON.stringify({
|
||||
form: watchedFormValues,
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
const dirty = currentSnapshot !== savedSnapshotRef.current;
|
||||
onDirtyChange?.(dirty);
|
||||
}, [watchedFormValues, configSettings, retrievalSettings, onDirtyChange]);
|
||||
|
||||
const captureSnapshot = () => {
|
||||
savedSnapshotRef.current = JSON.stringify({
|
||||
form: form.getValues(),
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRagEngines().then(() => {
|
||||
if (initKbId) {
|
||||
@@ -131,7 +159,6 @@ export default function KBForm({
|
||||
const firstEngine = ragEngines[0];
|
||||
setSelectedEngineId(firstEngine.plugin_id);
|
||||
form.setValue('ragEngineId', firstEngine.plugin_id);
|
||||
// Initialize config settings with defaults
|
||||
const formItems = parseCreationSchema(firstEngine.creation_schema);
|
||||
if (formItems.length > 0) {
|
||||
setConfigSettings(getDefaultValues(formItems));
|
||||
@@ -157,6 +184,7 @@ export default function KBForm({
|
||||
|
||||
const loadKbConfig = async (kbId: string) => {
|
||||
try {
|
||||
isInitializing.current = true;
|
||||
setIsEditing(true);
|
||||
|
||||
const res = await httpClient.getKnowledgeBase(kbId);
|
||||
@@ -165,15 +193,24 @@ export default function KBForm({
|
||||
const engineId = kb.knowledge_engine_plugin_id || '';
|
||||
setSelectedEngineId(engineId);
|
||||
|
||||
form.setValue('name', kb.name);
|
||||
form.setValue('description', kb.description);
|
||||
form.setValue('emoji', kb.emoji || '📚');
|
||||
form.setValue('ragEngineId', engineId);
|
||||
form.reset({
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji || '📚',
|
||||
ragEngineId: engineId,
|
||||
});
|
||||
|
||||
setConfigSettings(kb.creation_settings || {});
|
||||
setRetrievalSettings(kb.retrieval_settings || {});
|
||||
|
||||
// Capture snapshot after a tick so dynamic forms have emitted initial values
|
||||
setTimeout(() => {
|
||||
captureSnapshot();
|
||||
isInitializing.current = false;
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
console.error('Failed to load KB config:', err);
|
||||
isInitializing.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,7 +218,6 @@ export default function KBForm({
|
||||
setSelectedEngineId(engineId);
|
||||
form.setValue('ragEngineId', engineId);
|
||||
|
||||
// Find engine and initialize config settings with defaults from schema
|
||||
const engine = ragEngines.find((e) => e.plugin_id === engineId);
|
||||
if (engine) {
|
||||
const formItems = parseCreationSchema(engine.creation_schema);
|
||||
@@ -202,7 +238,7 @@ export default function KBForm({
|
||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||
const kbData: KnowledgeBase = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
description: data.description ?? '',
|
||||
emoji: data.emoji,
|
||||
knowledge_engine_plugin_id: selectedEngineId,
|
||||
creation_settings: configSettings,
|
||||
@@ -210,10 +246,11 @@ export default function KBForm({
|
||||
};
|
||||
|
||||
if (initKbId) {
|
||||
// Update knowledge base
|
||||
httpClient
|
||||
.updateKnowledgeBase(initKbId, kbData)
|
||||
.then((res) => {
|
||||
captureSnapshot();
|
||||
onDirtyChange?.(false);
|
||||
onKbUpdated(res.uuid);
|
||||
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
|
||||
})
|
||||
@@ -225,7 +262,6 @@ export default function KBForm({
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Create knowledge base
|
||||
httpClient
|
||||
.createKnowledgeBase(kbData)
|
||||
.then((res) => {
|
||||
@@ -241,15 +277,11 @@ export default function KBForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Convert creation schema to dynamic form items (same as ExternalKBForm)
|
||||
// Memoize to avoid regenerating UUIDs on every render, which would cause
|
||||
// DynamicFormComponent's useEffect to re-fire and trigger an infinite loop.
|
||||
const configFormItems = useMemo(
|
||||
() => parseCreationSchema(selectedEngine?.creation_schema),
|
||||
[selectedEngine?.creation_schema],
|
||||
);
|
||||
|
||||
// Convert retrieval schema to dynamic form items
|
||||
const retrievalFormItems = useMemo(
|
||||
() => parseCreationSchema(selectedEngine?.retrieval_schema),
|
||||
[selectedEngine?.retrieval_schema],
|
||||
@@ -282,65 +314,21 @@ export default function KBForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="kb-form"
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Knowledge Engine Selector */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ragEngineId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.knowledgeEngine')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={isEditing}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleEngineChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue
|
||||
placeholder={t('knowledge.selectKnowledgeEngine')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
{ragEngines.map((engine) => (
|
||||
<SelectItem
|
||||
key={engine.plugin_id}
|
||||
value={engine.plugin_id}
|
||||
>
|
||||
{extractI18nObject(engine.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedEngine?.description && (
|
||||
<FormDescription>
|
||||
{extractI18nObject(selectedEngine.description)}
|
||||
</FormDescription>
|
||||
)}
|
||||
{isEditing && (
|
||||
<FormDescription>
|
||||
{t('knowledge.cannotChangeKnowledgeEngine')}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="kb-form"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Card 1: Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('knowledge.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name and Emoji in same row */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
@@ -350,7 +338,7 @@ export default function KBForm({
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('knowledge.kbName')}
|
||||
<span className="text-red-500">*</span>
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@@ -383,10 +371,7 @@ export default function KBForm({
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.kbDescription')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormLabel>{t('knowledge.kbDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -395,47 +380,143 @@ export default function KBForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Engine specific fields (dynamic form from creation_schema) */}
|
||||
{configFormItems.length > 0 && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t('knowledge.engineSettings')}
|
||||
</div>
|
||||
<div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={configFormItems}
|
||||
initialValues={configSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setConfigSettings(val as Record<string, unknown>)
|
||||
}
|
||||
isEditing={isEditing}
|
||||
externalDependentValues={retrievalSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Knowledge Engine Selector */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ragEngineId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.knowledgeEngine')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={isEditing}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleEngineChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
{field.value ? (
|
||||
(() => {
|
||||
const [author, name] = field.value.split('/');
|
||||
const engine = ragEngines.find(
|
||||
(e) => e.plugin_id === field.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>
|
||||
{engine
|
||||
? extractI18nObject(engine.name)
|
||||
: field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('knowledge.selectKnowledgeEngine')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
{ragEngines.map((engine) => {
|
||||
const [author, name] = engine.plugin_id.split('/');
|
||||
return (
|
||||
<SelectItem
|
||||
key={engine.plugin_id}
|
||||
value={engine.plugin_id}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{extractI18nObject(engine.name)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedEngine?.description && (
|
||||
<FormDescription>
|
||||
{extractI18nObject(selectedEngine.description)}
|
||||
</FormDescription>
|
||||
)}
|
||||
{isEditing && (
|
||||
<FormDescription>
|
||||
{t('knowledge.cannotChangeKnowledgeEngine')}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Retrieval settings (dynamic form from retrieval_schema) */}
|
||||
{retrievalFormItems.length > 0 && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t('knowledge.retrievalSettings')}
|
||||
</div>
|
||||
<div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={retrievalFormItems}
|
||||
initialValues={retrievalSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setRetrievalSettings(val as Record<string, unknown>)
|
||||
}
|
||||
externalDependentValues={configSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
{/* Card 2: Engine Settings (dynamic form from creation_schema) */}
|
||||
{configFormItems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.engineSettingsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={configFormItems}
|
||||
initialValues={configSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setConfigSettings(val as Record<string, unknown>)
|
||||
}
|
||||
isEditing={isEditing}
|
||||
externalDependentValues={retrievalSettings}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
|
||||
{retrievalFormItems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('knowledge.retrievalSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.retrievalSettingsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={retrievalFormItems}
|
||||
initialValues={retrievalSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setRetrievalSettings(val as Record<string, unknown>)
|
||||
}
|
||||
externalDependentValues={configSettings}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
|
||||
import styles from './knowledgeBase.module.css';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
|
||||
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
|
||||
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
|
||||
import KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import KBDetailContent from './KBDetailContent';
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const { t } = useTranslation();
|
||||
const [knowledgeBaseList, setKnowledgeBaseList] = useState<KnowledgeBaseVO[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedKbId, setSelectedKbId] = useState<string>('');
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
const { refreshKnowledgeBases } = useSidebarData();
|
||||
|
||||
// Migration dialog state
|
||||
// Migration dialog state — checked on page load regardless of detail view
|
||||
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
|
||||
const [migrationInternalCount, setMigrationInternalCount] = useState(0);
|
||||
const [migrationExternalCount, setMigrationExternalCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
getKnowledgeBaseList();
|
||||
checkMigrationStatus();
|
||||
}, []);
|
||||
|
||||
@@ -42,75 +36,27 @@ export default function KnowledgePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getKnowledgeBaseList() {
|
||||
const resp = await httpClient.getKnowledgeBases();
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
const kbs = resp.bases.map((kb: KnowledgeBase) => {
|
||||
const lastUpdatedTimeAgo = Math.floor(
|
||||
(currentTime.getTime() -
|
||||
new Date(kb.updated_at ?? currentTime.getTime()).getTime()) /
|
||||
1000 /
|
||||
60 /
|
||||
60 /
|
||||
24,
|
||||
);
|
||||
|
||||
const lastUpdatedTimeAgoText =
|
||||
lastUpdatedTimeAgo > 0
|
||||
? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`
|
||||
: t('knowledge.today');
|
||||
|
||||
return new KnowledgeBaseVO({
|
||||
id: kb.uuid || '',
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji,
|
||||
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
|
||||
ragEngine: kb.knowledge_engine,
|
||||
ragEnginePluginId: kb.knowledge_engine_plugin_id,
|
||||
});
|
||||
});
|
||||
|
||||
setKnowledgeBaseList(kbs);
|
||||
function handleMigrationComplete() {
|
||||
refreshKnowledgeBases();
|
||||
}
|
||||
|
||||
const handleKBCardClick = (kbId: string) => {
|
||||
setSelectedKbId(kbId);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateKBClick = () => {
|
||||
setSelectedKbId('');
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleFormCancel = () => {
|
||||
setDetailDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleKbDeleted = () => {
|
||||
getKnowledgeBaseList();
|
||||
setDetailDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleNewKbCreated = (newKbId: string) => {
|
||||
getKnowledgeBaseList();
|
||||
setSelectedKbId(newKbId);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleKbUpdated = () => {
|
||||
getKnowledgeBaseList();
|
||||
};
|
||||
|
||||
const handleMigrationComplete = () => {
|
||||
getKnowledgeBaseList();
|
||||
};
|
||||
if (detailId) {
|
||||
return (
|
||||
<>
|
||||
<KBMigrationDialog
|
||||
open={migrationDialogOpen}
|
||||
onOpenChange={setMigrationDialogOpen}
|
||||
internalKbCount={migrationInternalCount}
|
||||
externalKbCount={migrationExternalCount}
|
||||
onMigrationComplete={handleMigrationComplete}
|
||||
/>
|
||||
<KBDetailContent id={detailId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<KBMigrationDialog
|
||||
open={migrationDialogOpen}
|
||||
onOpenChange={setMigrationDialogOpen}
|
||||
@@ -118,33 +64,9 @@ export default function KnowledgePage() {
|
||||
externalKbCount={migrationExternalCount}
|
||||
onMigrationComplete={handleMigrationComplete}
|
||||
/>
|
||||
|
||||
<KBDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
kbId={selectedKbId || undefined}
|
||||
onFormCancel={handleFormCancel}
|
||||
onKbDeleted={handleKbDeleted}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
/>
|
||||
|
||||
<div className={styles.knowledgeListContainer}>
|
||||
<CreateCardComponent
|
||||
width={'100%'}
|
||||
height={'10rem'}
|
||||
plusSize={'90px'}
|
||||
onClick={handleCreateKBClick}
|
||||
/>
|
||||
|
||||
{knowledgeBaseList.map((kb) => {
|
||||
return (
|
||||
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
|
||||
<KBCard kbCardVO={kb} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('knowledge.selectFromSidebar')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/* 主布局容器 */
|
||||
.homeLayoutContainer {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
:global(.dark) .homeLayoutContainer {
|
||||
background-color: #0a0a0b;
|
||||
}
|
||||
|
||||
/* 侧边栏区域 */
|
||||
.sidebar {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
:global(.dark) .sidebar {
|
||||
background-color: #0a0a0b;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main {
|
||||
background-color: #fafafa;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* height: 100vh; */
|
||||
width: calc(100% - 1.2rem);
|
||||
height: calc(100% - 1.2rem);
|
||||
overflow: hidden;
|
||||
border-radius: 1.5rem 0 0 1.5rem;
|
||||
margin-left: 0.6rem;
|
||||
margin-top: 0.6rem;
|
||||
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .main {
|
||||
background-color: #151518;
|
||||
box-shadow: 0 0 6px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
padding: 1.5rem;
|
||||
padding-left: 2rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
:global(.dark) .mainContent {
|
||||
background-color: #151518;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import styles from './layout.module.css';
|
||||
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
||||
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
|
||||
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
|
||||
import React, {
|
||||
useState,
|
||||
@@ -12,21 +10,46 @@ import React, {
|
||||
Suspense,
|
||||
} from 'react';
|
||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||
import {
|
||||
SidebarDataProvider,
|
||||
useSidebarData,
|
||||
} from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import { userInfo, initializeUserInfo } from '@/app/infra/http';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
|
||||
// Routes that belong to the "Extensions" section
|
||||
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
|
||||
|
||||
function isExtensionsRoute(pathname: string): boolean {
|
||||
return EXTENSIONS_ROUTES.some(
|
||||
(route) => pathname === route || pathname.startsWith(route + '/'),
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [subtitle, setSubtitle] = useState<string>('');
|
||||
const [helpLink, setHelpLink] = useState<I18nObject>({
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
});
|
||||
|
||||
// Initialize user info if not already initialized
|
||||
useEffect(() => {
|
||||
if (!userInfo) {
|
||||
@@ -34,30 +57,96 @@ export default function HomeLayout({
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarDataProvider>
|
||||
<HomeLayoutInner>{children}</HomeLayoutInner>
|
||||
</SidebarDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [helpLink, setHelpLink] = useState<I18nObject>({
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
});
|
||||
const { detailEntityName } = useSidebarData();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
|
||||
setTitle(child.name);
|
||||
setSubtitle(child.description);
|
||||
setHelpLink(child.helpLink);
|
||||
}, []);
|
||||
|
||||
// Memoize the main content area to prevent re-renders when sidebar state changes
|
||||
const mainContent = useMemo(() => children, [children]);
|
||||
|
||||
const resolvedHelpLink = extractI18nObject(helpLink);
|
||||
|
||||
// Determine breadcrumb section label and default link based on current route
|
||||
const isExtensions = isExtensionsRoute(pathname);
|
||||
const sectionLabel = isExtensions
|
||||
? t('sidebar.extensions')
|
||||
: t('sidebar.home');
|
||||
const sectionLink = isExtensions ? '/home/plugins' : '/home/monitoring';
|
||||
|
||||
return (
|
||||
<div className={styles.homeLayoutContainer}>
|
||||
<aside className={styles.sidebar}>
|
||||
<Suspense fallback={<div />}>
|
||||
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
|
||||
</Suspense>
|
||||
</aside>
|
||||
<SidebarProvider>
|
||||
<Suspense fallback={<div />}>
|
||||
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
|
||||
</Suspense>
|
||||
|
||||
<div className={styles.main}>
|
||||
<HomeTitleBar title={title} subtitle={subtitle} helpLink={helpLink} />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={sectionLink}>{sectionLabel}</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
{detailEntityName && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{detailEntityName}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
{resolvedHelpLink && (
|
||||
<>
|
||||
<BreadcrumbItem>
|
||||
<a
|
||||
href={resolvedHelpLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<CircleHelp className="size-3.5" />
|
||||
</a>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.mainContent}>{mainContent}</main>
|
||||
<div className="flex-1 overflow-hidden p-4 pt-0">{mainContent}</div>
|
||||
|
||||
<SurveyWidget />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
196
web/src/app/home/market/page.tsx
Normal file
196
web/src/app/home/market/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download } from 'lucide-react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
enum PluginInstallStatus {
|
||||
ASK_CONFIRM = 'ask_confirm',
|
||||
INSTALLING = 'installing',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!systemInfo?.enable_marketplace) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
|
||||
<p className="text-muted-foreground">{t('plugins.marketplace')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MarketplaceContent />;
|
||||
}
|
||||
|
||||
function MarketplaceContent() {
|
||||
const { t } = useTranslation();
|
||||
const { refreshPlugins } = useSidebarData();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
|
||||
async function checkExtensionsLimit(): Promise<boolean> {
|
||||
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
|
||||
if (maxExtensions < 0) return true;
|
||||
try {
|
||||
const [pluginsResp, mcpResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
httpClient.getMCPServers(),
|
||||
]);
|
||||
const total =
|
||||
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
|
||||
if (total >= maxExtensions) {
|
||||
toast.error(
|
||||
t('limitation.maxExtensionsReached', { max: maxExtensions }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, let backend handle it
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function watchTask(taskId: number) {
|
||||
let alreadySuccess = false;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
httpClient.getAsyncTask(taskId).then((resp) => {
|
||||
if (resp.runtime.done) {
|
||||
clearInterval(interval);
|
||||
if (resp.runtime.exception) {
|
||||
setInstallError(resp.runtime.exception);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
} else {
|
||||
if (!alreadySuccess) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
alreadySuccess = true;
|
||||
}
|
||||
setModalOpen(false);
|
||||
refreshPlugins();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const handleInstallPlugin = useCallback(
|
||||
async (plugin: PluginV4) => {
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
});
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setModalOpen(true);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
function handleModalConfirm() {
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
httpClient
|
||||
.installPluginFromMarketplace(
|
||||
installInfo.plugin_author,
|
||||
installInfo.plugin_name,
|
||||
installInfo.plugin_version,
|
||||
)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
watchTask(taskId);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInstallError(err.msg);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<MarketPage installPlugin={handleInstallPlugin} />
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModalOpen(open);
|
||||
if (!open) {
|
||||
setInstallError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-4">
|
||||
<Download className="size-6" />
|
||||
<span>{t('plugins.installPlugin')}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">
|
||||
{t('plugins.askConfirm', {
|
||||
name: installInfo.plugin_name,
|
||||
version: installInfo.plugin_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installing')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installFailed')}</p>
|
||||
<p className="mb-2 text-red-500">{installError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleModalConfirm}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
301
web/src/app/home/mcp/MCPDetailContent.tsx
Normal file
301
web/src/app/home/mcp/MCPDetailContent.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||
import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function MCPDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
||||
useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('mcp.createServer'));
|
||||
} else {
|
||||
const server = mcpServers.find((s) => s.id === id);
|
||||
setDetailEntityName(server?.name ?? id);
|
||||
}
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, mcpServers, setDetailEntityName, t]);
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Track whether the form has unsaved changes
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
|
||||
// Ref to MCPForm for triggering test from header
|
||||
const formRef = useRef<MCPFormHandle>(null);
|
||||
const [mcpTesting, setMcpTesting] = useState(false);
|
||||
|
||||
// Enable state managed here so the header switch works
|
||||
const [serverEnabled, setServerEnabled] = useState(true);
|
||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||
|
||||
// Fetch server enable state
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
httpClient.getMCPServer(id).then((res) => {
|
||||
const server = res.server ?? res;
|
||||
setServerEnabled(server.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
}
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
const handleEnableToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
const prev = serverEnabled;
|
||||
setServerEnabled(checked);
|
||||
try {
|
||||
await httpClient.toggleMCPServer(id, checked);
|
||||
refreshMCPServers();
|
||||
} catch {
|
||||
setServerEnabled(prev);
|
||||
toast.error(t('mcp.modifyFailed'));
|
||||
}
|
||||
},
|
||||
[id, serverEnabled, refreshMCPServers, t],
|
||||
);
|
||||
|
||||
function handleFormSubmit() {
|
||||
// Re-sync enable state after form save
|
||||
httpClient.getMCPServer(id).then((res) => {
|
||||
const server = res.server ?? res;
|
||||
setServerEnabled(server.enable ?? true);
|
||||
});
|
||||
refreshMCPServers();
|
||||
}
|
||||
|
||||
function handleServerDeleted() {
|
||||
refreshMCPServers();
|
||||
router.push('/home/mcp');
|
||||
}
|
||||
|
||||
function handleNewServerCreated(serverName: string) {
|
||||
refreshMCPServers();
|
||||
router.push(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteMCPServer(id)
|
||||
.then(() => {
|
||||
setShowDeleteConfirm(false);
|
||||
toast.success(t('mcp.deleteSuccess'));
|
||||
handleServerDeleted();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('mcp.deleteFailed') + (err.msg || ''));
|
||||
});
|
||||
}
|
||||
|
||||
// Check extensions limit before creating
|
||||
async function checkExtensionsLimit(): Promise<boolean> {
|
||||
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
|
||||
if (maxExtensions < 0) return true;
|
||||
try {
|
||||
const [pluginsResp, mcpResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
httpClient.getMCPServers(),
|
||||
]);
|
||||
const total =
|
||||
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
|
||||
if (total >= maxExtensions) {
|
||||
toast.error(
|
||||
t('limitation.maxExtensionsReached', { max: maxExtensions }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, let backend handle it
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== Create Mode ====================
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('mcp.createServer')}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
onClick={async (e) => {
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onTestingChange={setMcpTesting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header: title + enable switch + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">{t('mcp.editServer')}</h1>
|
||||
{enableLoaded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mcp-enable-switch"
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="mcp-enable-switch"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t('common.enable')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
<Button type="submit" form="mcp-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
onTestingChange={setMcpTesting}
|
||||
/>
|
||||
|
||||
{/* Card: Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('mcp.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('mcp.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('mcp.deleteMCPAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('mcp.deleteMCPHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('mcp.confirmDeleteServer')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('mcp.confirmDeleteServer')}</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
926
web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
Normal file
926
web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
Normal file
@@ -0,0 +1,926 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
MCPServerRuntimeInfo,
|
||||
MCPTool,
|
||||
MCPServer,
|
||||
MCPSessionStatus,
|
||||
MCPServerExtraArgsSSE,
|
||||
MCPServerExtraArgsHttp,
|
||||
MCPServerExtraArgsStdio,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
|
||||
// Status display for test / connecting / error states
|
||||
function StatusDisplay({
|
||||
testing,
|
||||
runtimeInfo,
|
||||
t,
|
||||
}: {
|
||||
testing: boolean;
|
||||
runtimeInfo: MCPServerRuntimeInfo;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (testing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<svg
|
||||
className="w-5 h-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.testing')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<svg
|
||||
className="w-5 h-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.connecting')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
</div>
|
||||
{runtimeInfo.error_message && (
|
||||
<div className="text-sm text-red-500 pl-7">
|
||||
{runtimeInfo.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tools list component
|
||||
function ToolsList({ tools }: { tools: MCPTool[] }) {
|
||||
return (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{tools.map((tool, index) => (
|
||||
<Card key={index} className="py-3 shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{tool.name}</CardTitle>
|
||||
{tool.description && (
|
||||
<CardDescription className="text-xs">
|
||||
{tool.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
name: z
|
||||
.string({ required_error: t('mcp.nameRequired') })
|
||||
.min(1, { message: t('mcp.nameRequired') }),
|
||||
mode: z.enum(['sse', 'stdio', 'http']),
|
||||
timeout: z
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(30),
|
||||
ssereadtimeout: z
|
||||
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(300),
|
||||
url: z.string().optional(),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.object({ value: z.string() })).optional(),
|
||||
extra_args: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean']),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.mode === 'sse' || data.mode === 'http') {
|
||||
if (!data.url || data.url.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('mcp.urlRequired'),
|
||||
path: ['url'],
|
||||
});
|
||||
}
|
||||
} else if (data.mode === 'stdio') {
|
||||
if (!data.command || data.command.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('mcp.commandRequired'),
|
||||
path: ['command'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
||||
timeout: number;
|
||||
ssereadtimeout: number;
|
||||
};
|
||||
|
||||
interface MCPFormProps {
|
||||
initServerName?: string;
|
||||
onFormSubmit: () => void;
|
||||
onNewServerCreated: (serverName: string) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onTestingChange?: (testing: boolean) => void;
|
||||
}
|
||||
|
||||
// Handle exposed to parent via ref
|
||||
export interface MCPFormHandle {
|
||||
testMcp: () => void;
|
||||
isTesting: boolean;
|
||||
}
|
||||
|
||||
const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
{
|
||||
initServerName,
|
||||
onFormSubmit,
|
||||
onNewServerCreated,
|
||||
onDirtyChange,
|
||||
onTestingChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
const isEditMode = !!initServerName;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Track whether initial data loading is complete (to avoid marking form dirty)
|
||||
const isInitializing = useRef(true);
|
||||
|
||||
const [extraArgs, setExtraArgs] = useState<
|
||||
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
||||
>([]);
|
||||
const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]);
|
||||
const [mcpTesting, setMcpTesting] = useState(false);
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
|
||||
null,
|
||||
);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const watchMode = form.watch('mode');
|
||||
|
||||
// Notify parent when dirty state changes
|
||||
const { isDirty } = form.formState;
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
// Notify parent when testing state changes
|
||||
useEffect(() => {
|
||||
onTestingChange?.(mcpTesting);
|
||||
}, [mcpTesting, onTestingChange]);
|
||||
|
||||
// Expose test action and testing state to parent
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
testMcp: () => testMcp(),
|
||||
isTesting: mcpTesting,
|
||||
}),
|
||||
[mcpTesting],
|
||||
);
|
||||
|
||||
// Load server data
|
||||
useEffect(() => {
|
||||
isInitializing.current = true;
|
||||
if (isEditMode && initServerName) {
|
||||
loadServerForEdit(initServerName).finally(() => {
|
||||
isInitializing.current = false;
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
});
|
||||
setExtraArgs([]);
|
||||
setStdioArgs([]);
|
||||
setRuntimeInfo(null);
|
||||
isInitializing.current = false;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [initServerName]);
|
||||
|
||||
// Poll for updates when runtime_info status is CONNECTING
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isEditMode ||
|
||||
!initServerName ||
|
||||
!runtimeInfo ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTING
|
||||
) {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pollingIntervalRef.current) {
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
loadServerForEdit(initServerName);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isEditMode, initServerName, runtimeInfo?.status]);
|
||||
|
||||
async function loadServerForEdit(serverName: string) {
|
||||
try {
|
||||
const resp = await httpClient.getMCPServer(serverName);
|
||||
const server = resp.server ?? resp;
|
||||
|
||||
const formValues: FormValues = {
|
||||
name: server.name,
|
||||
mode: server.mode,
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
};
|
||||
|
||||
let newExtraArgs: {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
value: string;
|
||||
}[] = [];
|
||||
let newStdioArgs: { value: string }[] = [];
|
||||
|
||||
if (server.mode === 'sse' || server.mode === 'http') {
|
||||
formValues.url = server.extra_args.url;
|
||||
formValues.timeout = server.extra_args.timeout;
|
||||
|
||||
if (server.mode === 'sse') {
|
||||
formValues.ssereadtimeout = server.extra_args.ssereadtimeout;
|
||||
}
|
||||
|
||||
if (server.extra_args.headers) {
|
||||
newExtraArgs = Object.entries(server.extra_args.headers).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
type: 'string' as const,
|
||||
value: String(value),
|
||||
}),
|
||||
);
|
||||
formValues.extra_args = newExtraArgs;
|
||||
}
|
||||
} else if (server.mode === 'stdio') {
|
||||
formValues.command = server.extra_args.command;
|
||||
newStdioArgs = (server.extra_args.args || []).map((arg: string) => ({
|
||||
value: arg,
|
||||
}));
|
||||
formValues.args = newStdioArgs;
|
||||
|
||||
if (server.extra_args.env) {
|
||||
newExtraArgs = Object.entries(server.extra_args.env).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
type: 'string' as const,
|
||||
value: String(value),
|
||||
}),
|
||||
);
|
||||
formValues.extra_args = newExtraArgs;
|
||||
}
|
||||
}
|
||||
|
||||
setExtraArgs(newExtraArgs);
|
||||
setStdioArgs(newStdioArgs);
|
||||
|
||||
// Use form.reset so isDirty stays false after initial load
|
||||
form.reset(formValues);
|
||||
|
||||
if (server.runtime_info) {
|
||||
setRuntimeInfo(server.runtime_info);
|
||||
} else {
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load server:', error);
|
||||
toast.error(t('mcp.loadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
let serverConfig: MCPServer;
|
||||
|
||||
if (value.mode === 'sse' || value.mode === 'http') {
|
||||
const headers: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
headers[arg.key] = String(arg.value);
|
||||
});
|
||||
|
||||
if (value.mode === 'sse') {
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'sse',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers: headers,
|
||||
timeout: value.timeout,
|
||||
ssereadtimeout: value.ssereadtimeout,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'http',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers: headers,
|
||||
timeout: value.timeout,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const env: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
env[arg.key] = String(arg.value);
|
||||
});
|
||||
const args = value.args?.map((arg) => arg.value) || [];
|
||||
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'stdio',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
command: value.command!,
|
||||
args: args,
|
||||
env: env,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isEditMode && initServerName) {
|
||||
await httpClient.updateMCPServer(initServerName, serverConfig);
|
||||
toast.success(t('mcp.updateSuccess'));
|
||||
// Reset dirty baseline to current values
|
||||
form.reset(form.getValues());
|
||||
onFormSubmit();
|
||||
} else {
|
||||
await httpClient.createMCPServer(serverConfig);
|
||||
toast.success(t('mcp.createSuccess'));
|
||||
onNewServerCreated(value.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save MCP server:', error);
|
||||
const errMsg = (error as CustomApiError).msg || '';
|
||||
toast.error(
|
||||
(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')) + errMsg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testMcp() {
|
||||
setMcpTesting(true);
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
let extraArgsData:
|
||||
| MCPServerExtraArgsSSE
|
||||
| MCPServerExtraArgsHttp
|
||||
| MCPServerExtraArgsStdio;
|
||||
|
||||
if (mode === 'sse') {
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
headers: Object.fromEntries(
|
||||
extraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
ssereadtimeout: form.getValues('ssereadtimeout'),
|
||||
};
|
||||
} else if (mode === 'http') {
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
headers: Object.fromEntries(
|
||||
extraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
};
|
||||
} else {
|
||||
extraArgsData = {
|
||||
command: form.getValues('command')!,
|
||||
args: stdioArgs.map((arg) => arg.value),
|
||||
env: Object.fromEntries(extraArgs.map((arg) => [arg.key, arg.value])),
|
||||
};
|
||||
}
|
||||
|
||||
const { task_id } = await httpClient.testMCPServer('_', {
|
||||
name: form.getValues('name'),
|
||||
mode: mode,
|
||||
enable: true,
|
||||
extra_args: extraArgsData,
|
||||
} as MCPServer);
|
||||
|
||||
if (!task_id) {
|
||||
throw new Error(t('mcp.noTaskId'));
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const taskResp = await httpClient.getAsyncTask(task_id);
|
||||
|
||||
if (taskResp.runtime?.done) {
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
|
||||
if (taskResp.runtime.exception) {
|
||||
const errorMsg =
|
||||
taskResp.runtime.exception || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
setRuntimeInfo({
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: errorMsg,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
});
|
||||
} else {
|
||||
if (isEditMode) {
|
||||
await loadServerForEdit(form.getValues('name'));
|
||||
}
|
||||
toast.success(t('mcp.testSuccess'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
const errorMsg =
|
||||
(err as CustomApiError).msg || t('mcp.getTaskFailed');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setMcpTesting(false);
|
||||
const errorMsg = (err as Error).message || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const addExtraArg = () => {
|
||||
const newArgs = [
|
||||
...extraArgs,
|
||||
{ key: '', type: 'string' as const, value: '' },
|
||||
];
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
};
|
||||
|
||||
const removeExtraArg = (index: number) => {
|
||||
const newArgs = extraArgs.filter((_, i) => i !== index);
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
};
|
||||
|
||||
const updateExtraArg = (
|
||||
index: number,
|
||||
field: 'key' | 'type' | 'value',
|
||||
value: string,
|
||||
) => {
|
||||
const newArgs = [...extraArgs];
|
||||
newArgs[index] = { ...newArgs[index], [field]: value };
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
};
|
||||
|
||||
const addStdioArg = () => {
|
||||
const newArgs = [...stdioArgs, { value: '' }];
|
||||
setStdioArgs(newArgs);
|
||||
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
|
||||
};
|
||||
|
||||
const removeStdioArg = (index: number) => {
|
||||
const newArgs = stdioArgs.filter((_, i) => i !== index);
|
||||
setStdioArgs(newArgs);
|
||||
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
|
||||
};
|
||||
|
||||
const updateStdioArg = (index: number, value: string) => {
|
||||
const newArgs = [...stdioArgs];
|
||||
newArgs[index] = { value };
|
||||
setStdioArgs(newArgs);
|
||||
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="mcp-form"
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Runtime info: status + tools (edit mode only) */}
|
||||
{isEditMode && runtimeInfo && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t('mcp.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(mcpTesting ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
|
||||
<div className="p-3 rounded-lg border">
|
||||
<StatusDisplay
|
||||
testing={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!mcpTesting &&
|
||||
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
|
||||
runtimeInfo.tools?.length > 0 && (
|
||||
<>
|
||||
<div className="text-sm font-medium">
|
||||
{t('mcp.toolCount', {
|
||||
count: runtimeInfo.tools?.length || 0,
|
||||
})}
|
||||
</div>
|
||||
<ToolsList tools={runtimeInfo.tools} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Server configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('mcp.extraParametersDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('mcp.name')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.serverMode')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('mcp.selectMode')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">{t('mcp.http')}</SelectItem>
|
||||
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
|
||||
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(watchMode === 'sse' || watchMode === 'http') && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('mcp.url')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.timeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.timeout')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchMode === 'sse' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssereadtimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.sseTimeoutDescription')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchMode === 'stdio' && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('mcp.command')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.args')}</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{stdioArgs.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('mcp.args')}
|
||||
value={arg.value}
|
||||
onChange={(e) =>
|
||||
updateStdioArg(index, e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-red-500 hover:text-red-600"
|
||||
onClick={() => removeStdioArg(index)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addStdioArg}
|
||||
>
|
||||
{t('mcp.addArgument')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.headers')
|
||||
: t('mcp.env')}
|
||||
</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{extraArgs.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
onChange={(e) =>
|
||||
updateExtraArg(index, 'key', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
onChange={(e) =>
|
||||
updateExtraArg(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-red-500 hover:text-red-600"
|
||||
onClick={() => removeExtraArg(index)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addExtraArg}>
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.addHeader')
|
||||
: t('mcp.addEnvVar')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t('mcp.extraParametersDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
export default MCPForm;
|
||||
21
web/src/app/home/mcp/page.tsx
Normal file
21
web/src/app/home/mcp/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MCPDetailContent from './MCPDetailContent';
|
||||
|
||||
export default function MCPPage() {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
return <MCPDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('mcp.selectFromSidebar')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -172,7 +172,7 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0"
|
||||
className="shadow-sm flex-shrink-0"
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
{exporting ? (
|
||||
|
||||
@@ -76,7 +76,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
[Image]
|
||||
</span>
|
||||
@@ -87,8 +87,8 @@ export function MessageContentRenderer({
|
||||
<span key={index} className="inline-block align-middle mx-1">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Image"
|
||||
className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity border border-gray-200 dark:border-gray-700"
|
||||
alt="Message attachment"
|
||||
className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity border"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewImageUrl(imageUrl);
|
||||
@@ -104,7 +104,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 mr-1"
|
||||
@@ -123,7 +123,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 mr-1"
|
||||
@@ -142,7 +142,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 text-sm border-l-2 border-gray-400"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-muted-foreground/50"
|
||||
>
|
||||
{quote.origin
|
||||
?.filter((c) => (c as MessageChainComponent).type === 'Plain')
|
||||
@@ -159,7 +159,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
[{component.type}]
|
||||
</span>
|
||||
@@ -188,9 +188,7 @@ export function MessageContentRenderer({
|
||||
// If no visible components, show placeholder
|
||||
if (visibleComponents.length === 0) {
|
||||
return (
|
||||
<span className="text-gray-400 dark:text-gray-500 italic">
|
||||
[Empty message]
|
||||
</span>
|
||||
<span className="text-muted-foreground italic">[Empty message]</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,9 +217,7 @@ export function MessageContentRenderer({
|
||||
content === '""'
|
||||
) {
|
||||
return (
|
||||
<span className="text-gray-400 dark:text-gray-500 italic">
|
||||
[Empty message]
|
||||
</span>
|
||||
<span className="text-muted-foreground italic">[Empty message]</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
}, [details.message?.variables]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-border ml-4">
|
||||
{/* Context Info Section */}
|
||||
{details.message && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -41,37 +41,37 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
{details.message.platform && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.platform')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="font-medium text-foreground">
|
||||
{details.message.platform}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{details.message.userId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.user')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{details.message.userId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{details.message.runnerName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.runner')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="font-medium text-foreground">
|
||||
{details.message.runnerName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.level')}
|
||||
</div>
|
||||
<div
|
||||
@@ -80,7 +80,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: details.message.level === 'warning'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{details.message.level.toUpperCase()}
|
||||
@@ -92,8 +92,8 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
|
||||
{/* LLM Calls Section */}
|
||||
{details.llmCalls && details.llmCalls.length > 0 && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -136,13 +136,10 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{/* Individual LLM Calls */}
|
||||
<div className="space-y-2">
|
||||
{details.llmCalls.map((call, index) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="bg-white dark:bg-gray-900 rounded p-2 text-sm"
|
||||
>
|
||||
<div key={call.id} className="bg-background rounded p-2 text-sm">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
<span className="font-medium text-foreground">
|
||||
#{index + 1} {call.modelName}
|
||||
</span>
|
||||
<span
|
||||
@@ -155,27 +152,21 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{call.duration}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-500">
|
||||
In:
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">In:</span>{' '}
|
||||
{call.tokens.input.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-500">
|
||||
Out:
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">Out:</span>{' '}
|
||||
{call.tokens.output.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-500">
|
||||
Total:
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">Total:</span>{' '}
|
||||
{call.tokens.total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +183,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
|
||||
{/* Errors Section */}
|
||||
{details.errors && details.errors.length > 0 && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
@@ -236,8 +227,8 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{queryVariables &&
|
||||
Object.keys(queryVariables).length > 0 &&
|
||||
details.message?.runnerName !== 'local-agent' && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -250,22 +241,21 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
{Object.entries(queryVariables).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="bg-white dark:bg-gray-900 rounded p-2"
|
||||
>
|
||||
<div className="text-gray-500 dark:text-gray-400">{key}</div>
|
||||
<div key={key} className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">{key}</div>
|
||||
<div
|
||||
className="font-medium text-gray-900 dark:text-white truncate"
|
||||
className="font-medium text-foreground truncate"
|
||||
title={
|
||||
typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
>
|
||||
{value === null || value === undefined ? (
|
||||
<span className="text-gray-400 italic">null</span>
|
||||
<span className="text-muted-foreground italic">null</span>
|
||||
) : typeof value === 'string' ? (
|
||||
value || (
|
||||
<span className="text-gray-400 italic">empty</span>
|
||||
<span className="text-muted-foreground italic">
|
||||
empty
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
JSON.stringify(value)
|
||||
@@ -283,7 +273,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
(details.message?.runnerName === 'local-agent' ||
|
||||
!queryVariables ||
|
||||
Object.keys(queryVariables).length === 0) && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
<div className="text-sm text-muted-foreground text-center py-4">
|
||||
{t('monitoring.messageDetails.noData')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function MonitoringFilters({
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
{/* Bot Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<label className="text-sm font-medium text-foreground whitespace-nowrap">
|
||||
{t('monitoring.filters.bot')}
|
||||
</label>
|
||||
<Select
|
||||
@@ -122,7 +122,7 @@ export default function MonitoringFilters({
|
||||
onValueChange={handleBotChange}
|
||||
disabled={loadingBots}
|
||||
>
|
||||
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]">
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingBots
|
||||
@@ -146,7 +146,7 @@ export default function MonitoringFilters({
|
||||
|
||||
{/* Pipeline Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<label className="text-sm font-medium text-foreground whitespace-nowrap">
|
||||
{t('monitoring.filters.pipeline')}
|
||||
</label>
|
||||
<Select
|
||||
@@ -154,7 +154,7 @@ export default function MonitoringFilters({
|
||||
onValueChange={handlePipelineChange}
|
||||
disabled={loadingPipelines}
|
||||
>
|
||||
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]">
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingPipelines
|
||||
@@ -178,11 +178,11 @@ export default function MonitoringFilters({
|
||||
|
||||
{/* Time Range Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<label className="text-sm font-medium text-foreground whitespace-nowrap">
|
||||
{t('monitoring.filters.timeRange')}
|
||||
</label>
|
||||
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[150px]">
|
||||
<SelectTrigger className="h-9 w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -23,9 +23,9 @@ export default function MetricCard({
|
||||
}: MetricCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<Card className="transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
|
||||
@@ -35,17 +35,17 @@ export default function MetricCard({
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
|
||||
<div className="h-4 w-20 bg-gray-100 dark:bg-gray-800 animate-pulse rounded mt-2"></div>
|
||||
<div className="h-9 w-28 bg-muted animate-pulse rounded"></div>
|
||||
<div className="h-4 w-20 bg-muted animate-pulse rounded mt-2"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300 group">
|
||||
<Card className="transition-all duration-300 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
@@ -53,9 +53,7 @@ export default function MetricCard({
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-foreground mb-2">{value}</div>
|
||||
{trend && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
@@ -82,7 +80,7 @@ export default function MetricCard({
|
||||
</svg>
|
||||
{Math.abs(trend.value)}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
vs previous period
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -126,16 +126,16 @@ export default function TrafficChart({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
|
||||
<div className="h-5 w-32 bg-muted animate-pulse rounded"></div>
|
||||
<div className="flex gap-4">
|
||||
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
|
||||
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse w-full h-full bg-gray-100 dark:bg-gray-800 rounded"></div>
|
||||
<div className="animate-pulse w-full h-full bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -143,13 +143,13 @@ export default function TrafficChart({
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 mb-4">
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<h3 className="text-base font-semibold text-foreground mb-4">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground">
|
||||
<svg
|
||||
className="w-16 h-16 mb-4 text-gray-300 dark:text-gray-600"
|
||||
className="w-16 h-16 mb-4 text-muted-foreground/30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -170,8 +170,8 @@ export default function TrafficChart({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6 hover:shadow-md transition-shadow duration-300">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 mb-6">
|
||||
<div className="bg-card rounded-xl border p-6 transition-shadow duration-300">
|
||||
<h3 className="text-base font-semibold text-foreground mb-6">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
<div className="h-[300px]">
|
||||
@@ -192,38 +192,38 @@ export default function TrafficChart({
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#e5e7eb"
|
||||
className="dark:stroke-gray-700"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
||||
tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: 'var(--border)' }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
||||
tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: 'var(--border)' }}
|
||||
width={40}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
border: '1px solid #e5e7eb',
|
||||
backgroundColor: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
boxShadow:
|
||||
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
fontSize: '13px',
|
||||
padding: '12px',
|
||||
color: 'var(--foreground)',
|
||||
}}
|
||||
labelStyle={{
|
||||
fontWeight: 600,
|
||||
marginBottom: '8px',
|
||||
color: '#374151',
|
||||
color: 'var(--foreground)',
|
||||
}}
|
||||
itemStyle={{ padding: '4px 0' }}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
EmbeddingCall,
|
||||
} from '../types/monitoring';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
|
||||
/**
|
||||
* Custom hook for fetching and managing monitoring data
|
||||
@@ -120,7 +121,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
variables?: string;
|
||||
}) => ({
|
||||
id: msg.id,
|
||||
timestamp: new Date(msg.timestamp),
|
||||
timestamp: parseUTCTimestamp(msg.timestamp),
|
||||
botId: msg.bot_id,
|
||||
botName: msg.bot_name,
|
||||
pipelineId: msg.pipeline_id,
|
||||
@@ -154,7 +155,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
message_id?: string;
|
||||
}) => ({
|
||||
id: call.id,
|
||||
timestamp: new Date(call.timestamp),
|
||||
timestamp: parseUTCTimestamp(call.timestamp),
|
||||
modelName: call.model_name,
|
||||
tokens: {
|
||||
input: call.input_tokens,
|
||||
@@ -190,7 +191,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
call_type?: string;
|
||||
}) => ({
|
||||
id: call.id,
|
||||
timestamp: new Date(call.timestamp),
|
||||
timestamp: parseUTCTimestamp(call.timestamp),
|
||||
modelName: call.model_name,
|
||||
promptTokens: call.prompt_tokens,
|
||||
totalTokens: call.total_tokens,
|
||||
@@ -227,10 +228,10 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
pipelineName: session.pipeline_name,
|
||||
messageCount: session.message_count,
|
||||
duration:
|
||||
new Date(session.last_activity).getTime() -
|
||||
new Date(session.start_time).getTime(),
|
||||
lastActivity: new Date(session.last_activity),
|
||||
startTime: new Date(session.start_time),
|
||||
parseUTCTimestamp(session.last_activity).getTime() -
|
||||
parseUTCTimestamp(session.start_time).getTime(),
|
||||
lastActivity: parseUTCTimestamp(session.last_activity),
|
||||
startTime: parseUTCTimestamp(session.start_time),
|
||||
platform: session.platform,
|
||||
userId: session.user_id,
|
||||
}),
|
||||
@@ -250,7 +251,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
message_id?: string;
|
||||
}) => ({
|
||||
id: error.id,
|
||||
timestamp: new Date(error.timestamp),
|
||||
timestamp: parseUTCTimestamp(error.timestamp),
|
||||
errorType: error.error_type,
|
||||
errorMessage: error.error_message,
|
||||
botId: error.bot_id,
|
||||
|
||||
@@ -188,11 +188,11 @@ function MonitoringPageContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="w-full h-full overflow-y-auto">
|
||||
{/* Filters and Refresh Button - Sticky */}
|
||||
<div className="sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-[#fafafa] dark:bg-[#151518]">
|
||||
<div className="sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-background">
|
||||
<div className="ml-[2rem] mr-[1.5rem] px-[0.8rem]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-card rounded-xl border">
|
||||
<MonitoringFilters
|
||||
selectedBots={filterState.selectedBots}
|
||||
selectedPipelines={filterState.selectedPipelines}
|
||||
@@ -207,7 +207,7 @@ function MonitoringPageContent() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refetch}
|
||||
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0"
|
||||
className="shadow-sm flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
@@ -235,30 +235,21 @@ function MonitoringPageContent() {
|
||||
/>
|
||||
|
||||
{/* Tabs Section */}
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div className="bg-card rounded-xl border overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="bg-gray-100 dark:bg-[#1a1a1e] h-12 p-1">
|
||||
<TabsTrigger
|
||||
value="messages"
|
||||
className="px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
|
||||
>
|
||||
<TabsList className="h-12 p-1">
|
||||
<TabsTrigger value="messages" className="px-6 py-2">
|
||||
{t('monitoring.tabs.messages')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="modelCalls"
|
||||
className="px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
|
||||
>
|
||||
<TabsTrigger value="modelCalls" className="px-6 py-2">
|
||||
{t('monitoring.tabs.modelCalls')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="errors"
|
||||
className="px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
|
||||
>
|
||||
<TabsTrigger value="errors" className="px-6 py-2">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -290,11 +281,11 @@ function MonitoringPageContent() {
|
||||
.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden hover:shadow-md transition-all duration-200"
|
||||
className="border rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Message Header - Always Visible */}
|
||||
<div
|
||||
className="p-5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
className="p-5 cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => toggleMessageExpand(msg.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -302,39 +293,41 @@ function MonitoringPageContent() {
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedMessageId === msg.id ? (
|
||||
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-500" />
|
||||
<ChevronRight className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
ID: {msg.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
{msg.botName}
|
||||
</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{msg.pipelineName}
|
||||
</span>
|
||||
{msg.runnerName && (
|
||||
<>
|
||||
<span className="text-gray-400">
|
||||
<span className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{msg.runnerName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-base text-gray-800 dark:text-gray-200">
|
||||
<div className="text-base text-foreground">
|
||||
<MessageContentRenderer
|
||||
content={msg.messageContent}
|
||||
maxLines={3}
|
||||
@@ -345,7 +338,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Status and Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{msg.timestamp.toLocaleString()}
|
||||
</span>
|
||||
<span
|
||||
@@ -365,7 +358,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedMessageId === msg.id && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="border-t p-4 bg-muted">
|
||||
{loadingDetails[msg.id] && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<LoadingSpinner size="sm" text="" />
|
||||
@@ -386,9 +379,9 @@ function MonitoringPageContent() {
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.messages || data.messages.length === 0) && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<div className="text-center text-muted-foreground py-16">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
|
||||
className="w-16 h-16 mx-auto mb-4 text-muted-foreground/30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -427,14 +420,14 @@ function MonitoringPageContent() {
|
||||
{data.modelCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-xl p-5 hover:shadow-md transition-all duration-200"
|
||||
className="border rounded-xl p-5 transition-all duration-200"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{/* Query ID - only show if messageId exists */}
|
||||
{call.messageId && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {call.messageId}
|
||||
</span>
|
||||
<Button
|
||||
@@ -496,19 +489,19 @@ function MonitoringPageContent() {
|
||||
</span>
|
||||
</div>
|
||||
{/* Model Name */}
|
||||
<div className="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<div className="font-medium text-sm text-foreground mb-2">
|
||||
{call.modelName}
|
||||
</div>
|
||||
{/* Context Info - only for LLM calls */}
|
||||
{call.modelType === 'llm' &&
|
||||
call.botName &&
|
||||
call.pipelineName && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{call.botName} → {call.pipelineName}
|
||||
</div>
|
||||
)}
|
||||
{/* Token Info */}
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{call.modelType === 'llm' && call.tokens && (
|
||||
<>
|
||||
@@ -572,14 +565,14 @@ function MonitoringPageContent() {
|
||||
{/* Query Text for Embedding Retrieve */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.queryText && (
|
||||
<div className="mt-2 p-2 bg-gray-50 dark:bg-gray-800 rounded text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<div className="mt-2 p-2 bg-muted rounded text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
'monitoring.embeddingCalls.queryText',
|
||||
)}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
<span className="text-foreground">
|
||||
{call.queryText.length > 100
|
||||
? call.queryText.substring(0, 100) +
|
||||
'...'
|
||||
@@ -594,7 +587,7 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
||||
{call.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -607,9 +600,9 @@ function MonitoringPageContent() {
|
||||
(!data ||
|
||||
!data.modelCalls ||
|
||||
data.modelCalls.length === 0) && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<div className="text-center text-muted-foreground py-16">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
|
||||
className="w-16 h-16 mx-auto mb-4 text-muted-foreground/30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -642,7 +635,7 @@ function MonitoringPageContent() {
|
||||
{data.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden hover:shadow-md transition-all duration-200"
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Error Header - Always Visible */}
|
||||
<div
|
||||
@@ -664,7 +657,7 @@ function MonitoringPageContent() {
|
||||
<div className="flex-1">
|
||||
{/* Query ID */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {error.messageId || '-'}
|
||||
</span>
|
||||
{error.messageId && (
|
||||
@@ -689,11 +682,11 @@ function MonitoringPageContent() {
|
||||
{error.errorType}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.botName}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.pipelineName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -705,7 +698,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{error.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -714,7 +707,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedErrorId === error.id && (
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-white dark:bg-gray-900">
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
|
||||
{/* Error Details */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
@@ -727,33 +720,33 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
|
||||
{/* Context Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="font-medium text-foreground">
|
||||
{error.botName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="font-medium text-foreground">
|
||||
{error.pipelineName}
|
||||
</div>
|
||||
</div>
|
||||
{error.sessionId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{error.sessionId}
|
||||
</div>
|
||||
</div>
|
||||
@@ -763,11 +756,11 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Stack Trace */}
|
||||
{error.stackTrace && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.errors.stackTrace')}
|
||||
</h4>
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-60 bg-white dark:bg-gray-900 p-3 rounded whitespace-pre-wrap break-words">
|
||||
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
|
||||
{error.stackTrace}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -782,7 +775,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.errors || data.errors.length === 0) && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<div className="text-center text-muted-foreground py-16">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
|
||||
fill="none"
|
||||
|
||||
@@ -97,3 +97,22 @@ export function isDateInRange(date: Date, range: DateRange | null): boolean {
|
||||
export function parseDate(dateStr: string): Date {
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a UTC timestamp string from the backend into a Date object.
|
||||
*
|
||||
* The backend stores all monitoring timestamps in UTC but serializes them
|
||||
* as naive ISO strings (e.g. "2026-03-25T14:30:00") without a timezone
|
||||
* designator. JavaScript's `new Date()` would treat such strings as local
|
||||
* time, causing the displayed time to be off by the user's UTC offset.
|
||||
*
|
||||
* This function appends 'Z' when the string has no timezone info, so that
|
||||
* `new Date()` correctly interprets it as UTC.
|
||||
*/
|
||||
export function parseUTCTimestamp(timestamp: string): Date {
|
||||
// If the string already contains timezone info ('Z', '+', or '-' offset), parse as-is
|
||||
if (/Z|[+-]\d{2}:\d{2}$/.test(timestamp)) {
|
||||
return new Date(timestamp);
|
||||
}
|
||||
return new Date(timestamp + 'Z');
|
||||
}
|
||||
|
||||
162
web/src/app/home/pipelines/PipelineDetailContent.tsx
Normal file
162
web/src/app/home/pipelines/PipelineDetailContent.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
||||
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
|
||||
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Bug, BarChart3 } from 'lucide-react';
|
||||
|
||||
export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('pipelines.createPipeline'));
|
||||
} else {
|
||||
const pipeline = pipelines.find((p) => p.id === id);
|
||||
setDetailEntityName(pipeline?.name ?? id);
|
||||
}
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
|
||||
function handleFinish() {
|
||||
refreshPipelines();
|
||||
}
|
||||
|
||||
function handleNewPipelineCreated(newPipelineId: string) {
|
||||
refreshPipelines();
|
||||
router.push(`/home/pipelines?id=${encodeURIComponent(newPipelineId)}`);
|
||||
}
|
||||
|
||||
// ==================== Create Mode ====================
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('pipelines.createPipeline')}
|
||||
</h1>
|
||||
<Button type="submit" form="pipeline-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<PipelineFormComponent
|
||||
pipelineId={undefined}
|
||||
isEditMode={false}
|
||||
disableForm={false}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleDeletePipeline() {
|
||||
refreshPipelines();
|
||||
router.push('/home/pipelines');
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
|
||||
<Button type="submit" form="pipeline-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
<Tabs
|
||||
key={id}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger value="config" className="gap-1.5">
|
||||
<Settings className="size-3.5" />
|
||||
{t('pipelines.configuration')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="debug" className="gap-1.5">
|
||||
<Bug className="size-3.5" />
|
||||
{t('pipelines.debugChat')}
|
||||
{activeTab === 'debug' && (
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="monitoring" className="gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
{t('pipelines.monitoring.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab: Configuration */}
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<PipelineFormComponent
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={false}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => router.push('/home/pipelines')}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Debug */}
|
||||
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
|
||||
<DebugDialog
|
||||
open={activeTab === 'debug'}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Monitoring */}
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={id}
|
||||
onNavigateToMonitoring={() => {
|
||||
router.push('/home/monitoring');
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar';
|
||||
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
|
||||
import DebugDialog from './components/debug-dialog/DebugDialog';
|
||||
import PipelineExtension from './components/pipeline-extensions/PipelineExtension';
|
||||
import PipelineMonitoringTab from './components/monitoring-tab/PipelineMonitoringTab';
|
||||
|
||||
interface PipelineDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pipelineId?: string;
|
||||
isEditMode?: boolean;
|
||||
isDefaultPipeline?: boolean;
|
||||
onFinish: () => void;
|
||||
onNewPipelineCreated?: (pipelineId: string) => void;
|
||||
onDeletePipeline: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type DialogMode = 'config' | 'debug' | 'extensions' | 'monitoring';
|
||||
|
||||
export default function PipelineDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pipelineId: propPipelineId,
|
||||
isEditMode = false,
|
||||
onFinish,
|
||||
onNewPipelineCreated,
|
||||
onDeletePipeline,
|
||||
onCancel,
|
||||
}: PipelineDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [pipelineId, setPipelineId] = useState<string | undefined>(
|
||||
propPipelineId,
|
||||
);
|
||||
const [currentMode, setCurrentMode] = useState<DialogMode>('config');
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPipelineId(propPipelineId);
|
||||
setCurrentMode('config');
|
||||
}, [propPipelineId, open]);
|
||||
|
||||
const handleFinish = () => {
|
||||
onFinish();
|
||||
};
|
||||
|
||||
const handleNewPipelineCreated = (newPipelineId: string) => {
|
||||
setPipelineId(newPipelineId);
|
||||
setCurrentMode('config');
|
||||
if (onNewPipelineCreated) {
|
||||
onNewPipelineCreated(newPipelineId);
|
||||
}
|
||||
};
|
||||
|
||||
const menu = [
|
||||
{
|
||||
key: 'config',
|
||||
label: t('pipelines.configuration'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'extensions',
|
||||
label: t('pipelines.extensions.title'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'debug',
|
||||
label: t('pipelines.debugChat'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'monitoring',
|
||||
label: t('pipelines.monitoring.title'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const getDialogTitle = () => {
|
||||
if (currentMode === 'config') {
|
||||
return isEditMode
|
||||
? t('pipelines.editPipeline')
|
||||
: t('pipelines.createPipeline');
|
||||
}
|
||||
if (currentMode === 'extensions') {
|
||||
return t('pipelines.extensions.title');
|
||||
}
|
||||
if (currentMode === 'monitoring') {
|
||||
return t('pipelines.monitoring.title');
|
||||
}
|
||||
return t('pipelines.debugDialog.title');
|
||||
};
|
||||
|
||||
// 创建新流水线时的对话框
|
||||
if (!isEditMode) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
|
||||
<main className="flex flex-1 flex-col h-[70vh]">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>{t('pipelines.createPipeline')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<PipelineFormComponent
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
isEditMode={isEditMode}
|
||||
pipelineId={pipelineId}
|
||||
disableForm={false}
|
||||
showButtons={true}
|
||||
onDeletePipeline={onDeletePipeline}
|
||||
onCancel={() => {
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 编辑流水线时的对话框
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[80vw] h-[75vh] flex">
|
||||
<SidebarProvider className="items-start w-full flex h-full min-h-0">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden md:flex h-full min-h-0 w-40 border-r bg-white dark:bg-black"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{menu.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={currentMode === item.key}
|
||||
onClick={() => setCurrentMode(item.key as DialogMode)}
|
||||
>
|
||||
<a href="#">
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
<main className="flex flex-1 flex-col h-full min-h-0">
|
||||
<DialogHeader
|
||||
className="px-6 pt-6 pb-4 shrink-0 flex flex-row items-center justify-start"
|
||||
style={{ height: '4rem' }}
|
||||
>
|
||||
<DialogTitle>{getDialogTitle()}</DialogTitle>
|
||||
{currentMode === 'debug' && (
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
title={
|
||||
isWebSocketConnected
|
||||
? t('pipelines.debugDialog.connected')
|
||||
: t('pipelines.debugDialog.disconnected')
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{isWebSocketConnected
|
||||
? t('pipelines.debugDialog.connected')
|
||||
: t('pipelines.debugDialog.disconnected')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-6 pb-4 w-full"
|
||||
style={{ height: 'calc(100% - 4rem)' }}
|
||||
>
|
||||
{currentMode === 'config' && (
|
||||
<PipelineFormComponent
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
isEditMode={isEditMode}
|
||||
pipelineId={pipelineId}
|
||||
disableForm={false}
|
||||
showButtons={true}
|
||||
onDeletePipeline={onDeletePipeline}
|
||||
onCancel={() => {
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentMode === 'extensions' && pipelineId && (
|
||||
<PipelineExtension pipelineId={pipelineId} />
|
||||
)}
|
||||
|
||||
{currentMode === 'debug' && pipelineId && (
|
||||
<DebugDialog
|
||||
open={true}
|
||||
pipelineId={pipelineId}
|
||||
isEmbedded={true}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentMode === 'monitoring' && pipelineId && (
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={pipelineId}
|
||||
onNavigateToMonitoring={() => {
|
||||
router.push(`/home/monitoring?pipelineId=${pipelineId}`);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,17 @@ import rehypeSanitize from 'rehype-sanitize';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import '@/styles/github-markdown.css';
|
||||
import {
|
||||
User,
|
||||
Users,
|
||||
ImageIcon,
|
||||
Paperclip,
|
||||
Send,
|
||||
Reply,
|
||||
Music,
|
||||
Code,
|
||||
AlignLeft,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DebugDialogProps {
|
||||
open: boolean;
|
||||
@@ -71,7 +82,7 @@ export default function DebugDialog({
|
||||
const isInitializingRef = useRef<boolean>(false);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// 使用setTimeout确保在DOM更新后执行滚动
|
||||
// Use setTimeout to ensure scroll happens after DOM update
|
||||
setTimeout(() => {
|
||||
const scrollArea = document.querySelector('.scroll-area') as HTMLElement;
|
||||
if (scrollArea) {
|
||||
@@ -80,7 +91,7 @@ export default function DebugDialog({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
// 同时确保messagesEndRef也滚动到视图
|
||||
// Also ensure messagesEndRef scrolls into view
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 0);
|
||||
}, []);
|
||||
@@ -100,10 +111,10 @@ export default function DebugDialog({
|
||||
[sessionType],
|
||||
);
|
||||
|
||||
// 初始化WebSocket连接
|
||||
// Initialize WebSocket connection
|
||||
const initWebSocket = useCallback(
|
||||
async (pipelineId: string) => {
|
||||
// 防止重复初始化
|
||||
// Prevent duplicate initialization
|
||||
if (isInitializingRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -111,13 +122,13 @@ export default function DebugDialog({
|
||||
try {
|
||||
isInitializingRef.current = true;
|
||||
|
||||
// 断开旧连接
|
||||
// Disconnect old connection
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
wsClientRef.current = null;
|
||||
}
|
||||
|
||||
// 创建新连接
|
||||
// Create new connection
|
||||
const wsClient = new WebSocketClient(pipelineId, sessionType);
|
||||
|
||||
wsClient
|
||||
@@ -126,31 +137,31 @@ export default function DebugDialog({
|
||||
isInitializingRef.current = false;
|
||||
})
|
||||
.onMessage((wsMessage) => {
|
||||
// 将 WebSocketMessage 转换为 Message 类型
|
||||
// Convert WebSocketMessage to Message type
|
||||
const message: Message = {
|
||||
...wsMessage,
|
||||
message_chain: wsMessage.message_chain as MessageChainComponent[],
|
||||
};
|
||||
|
||||
setMessages((prevMessages) => {
|
||||
// 查找是否已存在相同ID的消息
|
||||
// Check if message with same ID already exists
|
||||
const existingIndex = prevMessages.findIndex(
|
||||
(m) => m.id === message.id,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// 更新已存在的消息(流式输出)
|
||||
// Update existing message (streaming output)
|
||||
const newMessages = [...prevMessages];
|
||||
newMessages[existingIndex] = message;
|
||||
return newMessages;
|
||||
} else {
|
||||
// 添加新消息
|
||||
// Add new message
|
||||
return [...prevMessages, message];
|
||||
}
|
||||
});
|
||||
})
|
||||
.onError((error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
console.error('WebSocket error:', error);
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
toast.error(t('pipelines.debugDialog.connectionError'));
|
||||
@@ -166,7 +177,7 @@ export default function DebugDialog({
|
||||
await wsClient.connect();
|
||||
wsClientRef.current = wsClient;
|
||||
} catch (error) {
|
||||
console.error('WebSocket连接失败:', error);
|
||||
console.error('WebSocket connection failed:', error);
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
toast.error(t('pipelines.debugDialog.connectionFailed'));
|
||||
@@ -175,17 +186,17 @@ export default function DebugDialog({
|
||||
[sessionType, t],
|
||||
);
|
||||
|
||||
// 在useEffect中监听messages变化时滚动
|
||||
// Scroll when messages change
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// 监听 open 和 pipelineId 变化,进入时连接,离开时断开
|
||||
// Watch open and pipelineId changes: connect on open, disconnect on close
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedPipelineId(pipelineId);
|
||||
} else {
|
||||
// 关闭对话框时立即断开WebSocket
|
||||
// Disconnect WebSocket immediately when dialog closes
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
wsClientRef.current = null;
|
||||
@@ -195,7 +206,7 @@ export default function DebugDialog({
|
||||
}
|
||||
|
||||
return () => {
|
||||
// 组件卸载时断开WebSocket
|
||||
// Disconnect WebSocket on component unmount
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
wsClientRef.current = null;
|
||||
@@ -204,17 +215,17 @@ export default function DebugDialog({
|
||||
};
|
||||
}, [open, pipelineId]);
|
||||
|
||||
// 监听 sessionType 和 selectedPipelineId 变化,重新加载消息和连接
|
||||
// Reload messages and reconnect when sessionType or selectedPipelineId changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// 清空当前消息,避免显示旧的消息
|
||||
// Clear current messages to avoid showing stale messages
|
||||
setMessages([]);
|
||||
loadMessages(selectedPipelineId);
|
||||
initWebSocket(selectedPipelineId);
|
||||
}
|
||||
}, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]);
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
// Notify parent of connection status changes
|
||||
useEffect(() => {
|
||||
onConnectionStatusChange?.(isConnected);
|
||||
}, [isConnected, onConnectionStatusChange]);
|
||||
@@ -321,9 +332,9 @@ export default function DebugDialog({
|
||||
|
||||
const messageChain = [];
|
||||
|
||||
// 添加引用消息(如果有)
|
||||
// Add quoted message if present
|
||||
if (quotedMessage) {
|
||||
// 获取被引用消息的Source组件以获取message_id
|
||||
// Get message_id from the quoted message Source component
|
||||
const sourceComponent = quotedMessage.message_chain.find(
|
||||
(c) => c.type === 'Source',
|
||||
) as Source | undefined;
|
||||
@@ -353,7 +364,7 @@ export default function DebugDialog({
|
||||
});
|
||||
}
|
||||
|
||||
// 添加文本
|
||||
// Add text content
|
||||
if (text_content) {
|
||||
messageChain.push({
|
||||
type: 'Plain',
|
||||
@@ -361,7 +372,7 @@ export default function DebugDialog({
|
||||
});
|
||||
}
|
||||
|
||||
// 上传图片并添加到消息链
|
||||
// Upload images and add to message chain
|
||||
for (const image of selectedImages) {
|
||||
try {
|
||||
const result = await httpClient.uploadWebSocketImage(
|
||||
@@ -373,20 +384,20 @@ export default function DebugDialog({
|
||||
path: result.file_key,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('图片上传失败:', error);
|
||||
console.error('Image upload failed:', error);
|
||||
toast.error(t('pipelines.debugDialog.imageUploadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
// 清空输入框、图片和引用消息
|
||||
// Clear input, images, and quoted message
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
setQuotedMessage(null);
|
||||
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
|
||||
setSelectedImages([]);
|
||||
|
||||
// 通过WebSocket发送消息
|
||||
// 不在本地添加消息,等待后端广播回来(带有正确的ID)
|
||||
// Send message via WebSocket
|
||||
// Do not add locally; wait for backend broadcast with correct ID
|
||||
wsClientRef.current.sendMessage(messageChain, streamOutput);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
@@ -407,7 +418,7 @@ export default function DebugDialog({
|
||||
|
||||
case 'At': {
|
||||
const atComponent = component as At;
|
||||
// 优先使用 display,如果没有则使用 target
|
||||
// Prefer display name, fall back to target
|
||||
const displayName =
|
||||
atComponent.display || atComponent.target?.toString() || '';
|
||||
return (
|
||||
@@ -420,7 +431,10 @@ export default function DebugDialog({
|
||||
case 'AtAll':
|
||||
return (
|
||||
<span key={index} className="inline-flex align-middle mx-1">
|
||||
<AtBadge targetName="全体成员" readonly={true} />
|
||||
<AtBadge
|
||||
targetName={t('pipelines.debugDialog.allMembers')}
|
||||
readonly={true}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -449,10 +463,10 @@ export default function DebugDialog({
|
||||
const file = component as MessageChainComponent & { name?: string };
|
||||
return (
|
||||
<div key={index} className="my-2 flex items-center gap-2 text-sm">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" />
|
||||
</svg>
|
||||
<span>[文件] {file.name || 'Unknown'}</span>
|
||||
<Paperclip className="size-4" />
|
||||
<span>
|
||||
[{t('pipelines.debugDialog.file')}] {file.name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -462,15 +476,13 @@ export default function DebugDialog({
|
||||
const voiceUrl = voice.url || (voice.base64 ? voice.base64 : '');
|
||||
|
||||
if (!voiceUrl) {
|
||||
return <span key={index}>[语音]</span>;
|
||||
return <span key={index}>[{t('pipelines.debugDialog.voice')}]</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="my-2 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-lg">
|
||||
<Music className="size-5" />
|
||||
<audio
|
||||
controls
|
||||
src={voiceUrl}
|
||||
@@ -480,7 +492,7 @@ export default function DebugDialog({
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
{voice.length && voice.length > 0 && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{voice.length}s
|
||||
</span>
|
||||
)}
|
||||
@@ -494,7 +506,7 @@ export default function DebugDialog({
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="mb-2 pl-3 border-l-2 border-gray-400 dark:border-gray-500"
|
||||
className="mb-2 pl-3 border-l-2 border-muted-foreground/50"
|
||||
>
|
||||
<div className="text-sm opacity-75">
|
||||
{quote.origin?.map((comp, idx) =>
|
||||
@@ -506,7 +518,7 @@ export default function DebugDialog({
|
||||
}
|
||||
|
||||
case 'Source':
|
||||
// Source 不显示
|
||||
// Source is not rendered
|
||||
return null;
|
||||
|
||||
default:
|
||||
@@ -515,7 +527,7 @@ export default function DebugDialog({
|
||||
};
|
||||
|
||||
const getMessageTimestamp = (message: Message): number => {
|
||||
// 首先尝试从message_chain中的Source组件获取时间戳
|
||||
// Try to get timestamp from Source component in message_chain
|
||||
const sourceComponent = message.message_chain.find(
|
||||
(c) => c.type === 'Source',
|
||||
) as Source | undefined;
|
||||
@@ -524,8 +536,8 @@ export default function DebugDialog({
|
||||
return sourceComponent.timestamp;
|
||||
}
|
||||
|
||||
// 如果没有Source组件,使用message.timestamp
|
||||
// 假设timestamp是ISO字符串,转换为Unix时间戳(秒)
|
||||
// Fall back to message.timestamp if no Source component
|
||||
// Assume ISO string, convert to Unix timestamp (seconds)
|
||||
if (message.timestamp) {
|
||||
return Math.floor(new Date(message.timestamp).getTime() / 1000);
|
||||
}
|
||||
@@ -542,13 +554,13 @@ export default function DebugDialog({
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// 判断是否是今天
|
||||
// Check if today
|
||||
const isToday = now.toDateString() === date.toDateString();
|
||||
if (isToday) {
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// 判断是否是昨天
|
||||
// Check if yesterday
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const isYesterday = yesterday.toDateString() === date.toDateString();
|
||||
@@ -556,7 +568,7 @@ export default function DebugDialog({
|
||||
return `${t('bots.yesterday')} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// 判断是否是今年
|
||||
// Check if this year
|
||||
const isThisYear = now.getFullYear() === date.getFullYear();
|
||||
if (isThisYear) {
|
||||
const month = date.getMonth() + 1;
|
||||
@@ -564,7 +576,7 @@ export default function DebugDialog({
|
||||
return t('bots.dateFormat', { month, day });
|
||||
}
|
||||
|
||||
// 更早的日期
|
||||
// Earlier dates
|
||||
return t('bots.earlier');
|
||||
};
|
||||
|
||||
@@ -685,49 +697,37 @@ export default function DebugDialog({
|
||||
|
||||
const renderContent = () => (
|
||||
<div className="flex flex-1 h-full min-h-0">
|
||||
<div className="w-14 bg-white dark:bg-black p-2 pl-0 flex-shrink-0 flex flex-col justify-start gap-2">
|
||||
<div className="w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-10 h-10 justify-center rounded-md transition-none ${
|
||||
className={cn(
|
||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
||||
sessionType === 'person'
|
||||
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
} border-0 shadow-none`}
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
onClick={() => setSessionType('person')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z"></path>
|
||||
</svg>
|
||||
<User className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-10 h-10 justify-center rounded-md transition-none ${
|
||||
className={cn(
|
||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
||||
sessionType === 'group'
|
||||
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
} border-0 shadow-none`}
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
onClick={() => setSessionType('group')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
|
||||
</svg>
|
||||
<Users className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black scroll-area">
|
||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 scroll-area">
|
||||
<div className="space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
@@ -746,17 +746,15 @@ export default function DebugDialog({
|
||||
className={cn(
|
||||
'max-w-3xl px-5 py-3 rounded-2xl',
|
||||
message.role === 'user'
|
||||
? 'user-message-bubble bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
|
||||
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
|
||||
: 'bg-muted text-foreground rounded-bl-none',
|
||||
)}
|
||||
>
|
||||
{renderMessageContent(message)}
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-2 flex items-center justify-between gap-2',
|
||||
message.role === 'user'
|
||||
? 'text-gray-600 dark:text-gray-300'
|
||||
: 'text-gray-500 dark:text-gray-400',
|
||||
'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -767,12 +765,11 @@ export default function DebugDialog({
|
||||
</span>
|
||||
{hasPlainText(message) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRawMode(message)}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded text-[10px] transition-colors',
|
||||
message.role === 'user'
|
||||
? 'hover:bg-blue-200 dark:hover:bg-blue-800'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
'hover:bg-accent',
|
||||
)}
|
||||
title={
|
||||
rawModeMessages.has(getMessageKey(message))
|
||||
@@ -782,58 +779,27 @@ export default function DebugDialog({
|
||||
>
|
||||
{rawModeMessages.has(getMessageKey(message)) ? (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z" />
|
||||
</svg>
|
||||
<Code className="size-3" />
|
||||
MD
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h7"
|
||||
/>
|
||||
</svg>
|
||||
<AlignLeft className="size-3" />
|
||||
{t('pipelines.debugDialog.showRaw')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuotedMessage(message)}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded text-[10px] transition-colors flex items-center gap-0.5',
|
||||
message.role === 'user'
|
||||
? 'hover:bg-blue-200 dark:hover:bg-blue-800'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
'hover:bg-accent',
|
||||
)}
|
||||
title={t('pipelines.debugDialog.reply')}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
<Reply className="size-3" />
|
||||
{t('pipelines.debugDialog.reply')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -849,18 +815,18 @@ export default function DebugDialog({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 引用消息预览区域 */}
|
||||
{/* Quoted message preview */}
|
||||
{quotedMessage && (
|
||||
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-2 bg-muted/50 border-t">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 pl-3 border-l-2 border-[#2288ee]">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<div className="flex-1 pl-3 border-l-2 border-primary">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{t('pipelines.debugDialog.replyTo')}{' '}
|
||||
{quotedMessage.role === 'user'
|
||||
? t('pipelines.debugDialog.userMessage')
|
||||
: t('pipelines.debugDialog.botMessage')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
|
||||
<div className="text-sm text-foreground/70 line-clamp-2">
|
||||
{quotedMessage.message_chain
|
||||
.filter((c) => c.type === 'Plain')
|
||||
.map((c) => (c as Plain).text)
|
||||
@@ -868,8 +834,9 @@ export default function DebugDialog({
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuotedMessage(null)}
|
||||
className="w-5 h-5 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
className="w-5 h-5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -877,20 +844,21 @@ export default function DebugDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片预览区域 */}
|
||||
{/* Image preview area */}
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="px-4 pb-2 bg-white dark:bg-black">
|
||||
<div className="px-4 pb-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{selectedImages.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<img
|
||||
src={image.preview}
|
||||
alt={`preview-${index}`}
|
||||
className="w-20 h-20 object-cover rounded-lg border border-gray-300 dark:border-gray-600"
|
||||
className="w-20 h-20 object-cover rounded-lg border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute -top-2 -right-2 w-5 h-5 bg-destructive text-destructive-foreground rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -900,17 +868,16 @@ export default function DebugDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 pb-0 bg-white dark:bg-black flex gap-2">
|
||||
<div className="p-4 pb-0 flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('pipelines.debugDialog.streamOutput')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={streamOutput}
|
||||
onCheckedChange={setStreamOutput}
|
||||
disabled={!isConnected}
|
||||
className="data-[state=checked]:bg-[#2288ee]"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
@@ -926,22 +893,10 @@ export default function DebugDialog({
|
||||
size="icon"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!isConnected || isUploading}
|
||||
className="w-10 h-10 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="上传图片"
|
||||
className="w-10 h-10 rounded-md hover:bg-accent"
|
||||
title={t('pipelines.debugDialog.uploadImage')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<ImageIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
@@ -961,25 +916,23 @@ export default function DebugDialog({
|
||||
: t('pipelines.debugDialog.groupChat'),
|
||||
})}
|
||||
disabled={!isConnected || isUploading}
|
||||
className="flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-[#2288ee] transition-none text-base disabled:opacity-50"
|
||||
className="flex-1 rounded-md px-3 py-2 transition-none text-base disabled:opacity-50"
|
||||
/>
|
||||
{showAtPopover && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-white dark:bg-gray-800 dark:border-gray-600 shadow-lg"
|
||||
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
|
||||
isHovering
|
||||
? 'bg-gray-100 dark:bg-gray-700'
|
||||
: 'bg-white dark:bg-gray-800',
|
||||
isHovering ? 'bg-accent' : '',
|
||||
)}
|
||||
onClick={handleAtSelect}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<span className="text-gray-800 dark:text-gray-200">
|
||||
<span>
|
||||
@websocketbot - {t('pipelines.debugDialog.atTips')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -997,16 +950,23 @@ export default function DebugDialog({
|
||||
!isConnected ||
|
||||
isUploading
|
||||
}
|
||||
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
|
||||
className="rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? '上传中...' : t('pipelines.debugDialog.send')}
|
||||
{isUploading ? (
|
||||
t('pipelines.debugDialog.uploading')
|
||||
) : (
|
||||
<>
|
||||
<Send className="size-4" />
|
||||
{t('pipelines.debugDialog.send')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果是嵌入模式,直接返回内容
|
||||
// Embedded mode: return content directly
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<>
|
||||
@@ -1022,10 +982,10 @@ export default function DebugDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// 原有的Dialog包装
|
||||
// Dialog wrapper mode
|
||||
return (
|
||||
<>
|
||||
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black">
|
||||
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl">
|
||||
{renderContent()}
|
||||
</DialogContent>
|
||||
<ImagePreviewDialog
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MessageContentRenderer } from '@/app/home/monitoring/components/Message
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
|
||||
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
|
||||
|
||||
interface PipelineMonitoringTabProps {
|
||||
pipelineId: string;
|
||||
@@ -120,7 +121,7 @@ export default function PipelineMonitoringTab({
|
||||
message: result.message
|
||||
? {
|
||||
id: result.message.id,
|
||||
timestamp: new Date(result.message.timestamp),
|
||||
timestamp: parseUTCTimestamp(result.message.timestamp),
|
||||
botId: result.message.bot_id,
|
||||
botName: result.message.bot_name,
|
||||
pipelineId: result.message.pipeline_id,
|
||||
@@ -137,7 +138,7 @@ export default function PipelineMonitoringTab({
|
||||
: undefined,
|
||||
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
||||
id: call.id,
|
||||
timestamp: new Date(call.timestamp),
|
||||
timestamp: parseUTCTimestamp(call.timestamp),
|
||||
modelName: call.model_name,
|
||||
status: call.status,
|
||||
duration: call.duration,
|
||||
@@ -150,7 +151,7 @@ export default function PipelineMonitoringTab({
|
||||
})),
|
||||
errors: result.errors.map((error: RawErrorData) => ({
|
||||
id: error.id,
|
||||
timestamp: new Date(error.timestamp),
|
||||
timestamp: parseUTCTimestamp(error.timestamp),
|
||||
errorType: error.error_type,
|
||||
errorMessage: error.error_message,
|
||||
stackTrace: error.stack_trace,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
height: 10rem;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #e4e4e7;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -15,15 +15,15 @@
|
||||
|
||||
:global(.dark) .cardContainer {
|
||||
background-color: #1f1f22;
|
||||
box-shadow: 0;
|
||||
border-color: #27272a;
|
||||
}
|
||||
|
||||
.cardContainer:hover {
|
||||
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
|
||||
border-color: #a1a1aa;
|
||||
}
|
||||
|
||||
:global(.dark) .cardContainer:hover {
|
||||
box-shadow: 0;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.basicInfoContainer {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
PipelineConfigTab,
|
||||
PipelineConfigStage,
|
||||
} from '@/app/infra/entities/pipeline';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -32,6 +31,25 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Info,
|
||||
Brain,
|
||||
Zap,
|
||||
Shield,
|
||||
FileOutput,
|
||||
Puzzle,
|
||||
Trash2,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
|
||||
|
||||
export default function PipelineFormComponent({
|
||||
onFinish,
|
||||
@@ -41,6 +59,7 @@ export default function PipelineFormComponent({
|
||||
showButtons = true,
|
||||
onDeletePipeline,
|
||||
onCancel,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
pipelineId?: string;
|
||||
isEditMode: boolean;
|
||||
@@ -49,7 +68,8 @@ export default function PipelineFormComponent({
|
||||
onFinish: () => void;
|
||||
onNewPipelineCreated: (pipelineId: string) => void;
|
||||
onDeletePipeline: () => void;
|
||||
onCancel: () => void;
|
||||
onCancel?: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -60,9 +80,7 @@ export default function PipelineFormComponent({
|
||||
? z.object({
|
||||
basic: z.object({
|
||||
name: z.string().min(1, { message: t('pipelines.nameRequired') }),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, { message: t('pipelines.descriptionRequired') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
}),
|
||||
ai: z.record(z.string(), z.any()),
|
||||
@@ -73,9 +91,7 @@ export default function PipelineFormComponent({
|
||||
: z.object({
|
||||
basic: z.object({
|
||||
name: z.string().min(1, { message: t('pipelines.nameRequired') }),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, { message: t('pipelines.descriptionRequired') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
}),
|
||||
ai: z.record(z.string(), z.any()).optional(),
|
||||
@@ -85,16 +101,58 @@ export default function PipelineFormComponent({
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
// 这里不好,可以改成enum等
|
||||
const formLabelList: FormLabel[] = isEditMode
|
||||
// Section navigation items with icons
|
||||
const SECTION_ICONS: Record<string, React.ElementType> = {
|
||||
basic: Info,
|
||||
ai: Brain,
|
||||
trigger: Zap,
|
||||
safety: Shield,
|
||||
output: FileOutput,
|
||||
extensions: Puzzle,
|
||||
};
|
||||
|
||||
const formLabelList: SectionItem[] = isEditMode
|
||||
? [
|
||||
{ label: t('pipelines.basicInfo'), name: 'basic' },
|
||||
{ label: t('pipelines.aiCapabilities'), name: 'ai' },
|
||||
{ label: t('pipelines.triggerConditions'), name: 'trigger' },
|
||||
{ label: t('pipelines.safetyControls'), name: 'safety' },
|
||||
{ label: t('pipelines.outputProcessing'), name: 'output' },
|
||||
{
|
||||
label: t('pipelines.basicInfo'),
|
||||
name: 'basic',
|
||||
icon: SECTION_ICONS.basic,
|
||||
},
|
||||
{
|
||||
label: t('pipelines.aiCapabilities'),
|
||||
name: 'ai',
|
||||
icon: SECTION_ICONS.ai,
|
||||
},
|
||||
{
|
||||
label: t('pipelines.triggerConditions'),
|
||||
name: 'trigger',
|
||||
icon: SECTION_ICONS.trigger,
|
||||
},
|
||||
{
|
||||
label: t('pipelines.safetyControls'),
|
||||
name: 'safety',
|
||||
icon: SECTION_ICONS.safety,
|
||||
},
|
||||
{
|
||||
label: t('pipelines.outputProcessing'),
|
||||
name: 'output',
|
||||
icon: SECTION_ICONS.output,
|
||||
},
|
||||
{
|
||||
label: t('pipelines.extensions.title'),
|
||||
name: 'extensions',
|
||||
icon: SECTION_ICONS.extensions,
|
||||
},
|
||||
]
|
||||
: [{ label: t('pipelines.basicInfo'), name: 'basic' }];
|
||||
: [
|
||||
{
|
||||
label: t('pipelines.basicInfo'),
|
||||
name: 'basic',
|
||||
icon: SECTION_ICONS.basic,
|
||||
},
|
||||
];
|
||||
|
||||
const [activeSection, setActiveSection] = useState(formLabelList[0].name);
|
||||
|
||||
const [aiConfigTabSchema, setAIConfigTabSchema] =
|
||||
useState<PipelineConfigTab>();
|
||||
@@ -128,6 +186,11 @@ export default function PipelineFormComponent({
|
||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||
}, [isEditMode, watchedValues]);
|
||||
|
||||
// Notify parent when dirty state changes
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
// get config schema from metadata
|
||||
httpClient.getGeneralPipelineMetadata().then((resp) => {
|
||||
@@ -190,7 +253,7 @@ export default function PipelineFormComponent({
|
||||
function handleCreate(values: FormValues) {
|
||||
const pipeline: Pipeline = {
|
||||
config: {},
|
||||
description: values.basic.description,
|
||||
description: values.basic.description ?? '',
|
||||
name: values.basic.name,
|
||||
emoji: values.basic.emoji,
|
||||
};
|
||||
@@ -217,7 +280,7 @@ export default function PipelineFormComponent({
|
||||
const pipeline: Pipeline = {
|
||||
config: realConfig,
|
||||
// created_at: '',
|
||||
description: values.basic.description,
|
||||
description: values.basic.description ?? '',
|
||||
// for_version: '',
|
||||
name: values.basic.name,
|
||||
emoji: values.basic.emoji,
|
||||
@@ -269,92 +332,98 @@ export default function PipelineFormComponent({
|
||||
stage: PipelineConfigStage,
|
||||
formName: keyof FormValues,
|
||||
) {
|
||||
// 如果是 AI 配置,需要特殊处理
|
||||
// Special handling for AI config section
|
||||
if (formName === 'ai') {
|
||||
// 获取当前选择的 runner
|
||||
// Get the currently selected runner
|
||||
const currentRunner = form.watch('ai.runner.runner');
|
||||
|
||||
// 如果是 runner 配置项,直接渲染
|
||||
// If this is the runner selector stage, render it directly
|
||||
if (stage.name === 'runner') {
|
||||
return (
|
||||
<div key={stage.name} className="space-y-4 mb-6">
|
||||
<div className="text-lg font-medium">
|
||||
{extractI18nObject(stage.label)}
|
||||
</div>
|
||||
{stage.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{extractI18nObject(stage.description)}
|
||||
</div>
|
||||
)}
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Card key={stage.name}>
|
||||
<CardHeader>
|
||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||
{stage.description && (
|
||||
<CardDescription>
|
||||
{extractI18nObject(stage.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果不是当前选择的 runner 对应的配置项,则不渲染
|
||||
// Do not render if not the currently selected runner
|
||||
if (stage.name !== currentRunner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 对于n8n-service-api配置,使用N8nAuthFormComponent处理表单联动
|
||||
// For n8n-service-api config, use N8nAuthFormComponent for form linkage
|
||||
if (stage.name === 'n8n-service-api') {
|
||||
return (
|
||||
<div key={stage.name} className="space-y-4 mb-6">
|
||||
<div className="text-lg font-medium">
|
||||
{extractI18nObject(stage.label)}
|
||||
</div>
|
||||
{stage.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{extractI18nObject(stage.description)}
|
||||
</div>
|
||||
)}
|
||||
<N8nAuthFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Card key={stage.name}>
|
||||
<CardHeader>
|
||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||
{stage.description && (
|
||||
<CardDescription>
|
||||
{extractI18nObject(stage.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<N8nAuthFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={stage.name} className="space-y-4 mb-6">
|
||||
<div className="text-lg font-medium">
|
||||
{extractI18nObject(stage.label)}
|
||||
</div>
|
||||
{stage.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{extractI18nObject(stage.description)}
|
||||
</div>
|
||||
)}
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Card key={stage.name}>
|
||||
<CardHeader>
|
||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||
{stage.description && (
|
||||
<CardDescription>
|
||||
{extractI18nObject(stage.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,7 +458,7 @@ export default function PipelineFormComponent({
|
||||
onFinish();
|
||||
toast.success(t('common.copySuccess'));
|
||||
setShowCopyConfirm(false);
|
||||
onCancel();
|
||||
onCancel?.();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('pipelines.createError') + err.msg);
|
||||
@@ -399,82 +468,66 @@ export default function PipelineFormComponent({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="!max-w-[70vw] max-w-6xl h-full p-0 flex flex-col bg-white dark:bg-black">
|
||||
<div className="h-full p-0 flex flex-col">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="pipeline-form"
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
||||
>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<Tabs
|
||||
defaultValue={formLabelList[0].name}
|
||||
className="h-full flex flex-col flex-1 min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
{formLabelList.map((formLabel) => (
|
||||
<TabsTrigger key={formLabel.name} value={formLabel.name}>
|
||||
{formLabel.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div
|
||||
id="pipeline-form-content"
|
||||
className="flex-1 overflow-y-auto min-h-0"
|
||||
>
|
||||
{formLabelList.map((formLabel) => (
|
||||
<TabsContent
|
||||
key={formLabel.name}
|
||||
value={formLabel.name}
|
||||
className="overflow-y-auto max-h-full"
|
||||
>
|
||||
{formLabel.name === 'basic' && (
|
||||
<div className="space-y-6">
|
||||
{/* Name and Emoji in same row */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('common.name')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.emoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<EmojiPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col md:flex-row min-h-0">
|
||||
{/* Vertical section navigation (only show when multiple sections) */}
|
||||
{formLabelList.length > 1 && (
|
||||
<nav className="shrink-0 mb-4 md:mb-0 md:w-44 md:pr-4 md:mr-4 md:border-r overflow-x-auto md:overflow-x-visible md:overflow-y-auto">
|
||||
<ul className="flex md:flex-col gap-1 md:space-y-1">
|
||||
{formLabelList.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.name)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer whitespace-nowrap',
|
||||
activeSection === section.name
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{section.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Content panel */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* Basic info section */}
|
||||
{activeSection === 'basic' && (
|
||||
<div className="space-y-6">
|
||||
{/* Basic Information Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('pipelines.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('pipelines.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name and Emoji in same row */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.description"
|
||||
name="basic.name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('common.description')}
|
||||
<span className="text-red-500">*</span>
|
||||
{t('common.name')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@@ -483,56 +536,149 @@ export default function PipelineFormComponent({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.emoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<EmojiPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
{formLabel.name === 'ai' && aiConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{aiConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'ai'),
|
||||
)}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formLabel.name === 'trigger' &&
|
||||
triggerConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{triggerConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'trigger'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Copy pipeline (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t('pipelines.copyPipelineAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pipelines.copyPipelineHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="size-4 mr-1.5" />
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{formLabel.name === 'safety' &&
|
||||
safetyConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{safetyConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'safety'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Danger Zone (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('pipelines.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('pipelines.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('pipelines.deletePipelineAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDefaultPipeline
|
||||
? t('pipelines.defaultPipelineCannotDelete')
|
||||
: t('pipelines.deletePipelineHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDefaultPipeline}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formLabel.name === 'output' &&
|
||||
outputConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{outputConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'output'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
{/* Dynamic config sections (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<>
|
||||
{activeSection === 'ai' && aiConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{aiConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'ai'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'trigger' && triggerConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{triggerConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'trigger'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'safety' && safetyConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{safetyConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'safety'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'output' && outputConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{outputConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'output'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'extensions' && pipelineId && (
|
||||
<PipelineExtension pipelineId={pipelineId} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/* 按钮栏移到 Tabs 外部,始终固定底部 */}
|
||||
{/* Button bar pinned to bottom */}
|
||||
{showButtons && (
|
||||
<div className="flex justify-end items-center gap-2 pt-4 border-t mb-0 bg-white dark:bg-black sticky bottom-0 z-10">
|
||||
<div className="flex justify-end items-center gap-2 pt-4 border-t mb-0 sticky bottom-0 z-10">
|
||||
{isEditMode && hasUnsavedChanges && (
|
||||
<div className="text-amber-600 dark:text-amber-400 text-sm flex items-center gap-1.5 mr-auto">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||||
@@ -551,7 +697,7 @@ export default function PipelineFormComponent({
|
||||
)}
|
||||
|
||||
{isEditMode && isDefaultPipeline && (
|
||||
<div className="text-gray-500 text-sm h-full flex items-center mr-2">
|
||||
<div className="text-muted-foreground text-sm h-full flex items-center mr-2">
|
||||
{t('pipelines.defaultPipelineCannotDelete')}
|
||||
</div>
|
||||
)}
|
||||
@@ -570,15 +716,12 @@ export default function PipelineFormComponent({
|
||||
<Button type="submit" form="pipeline-form">
|
||||
{isEditMode ? t('common.save') : t('common.submit')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -599,7 +742,7 @@ export default function PipelineFormComponent({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 复制确认对话框 */}
|
||||
{/* Copy confirmation dialog */}
|
||||
<Dialog open={showCopyConfirm} onOpenChange={setShowCopyConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -617,7 +760,8 @@ export default function PipelineFormComponent({
|
||||
</>
|
||||
);
|
||||
}
|
||||
interface FormLabel {
|
||||
interface SectionItem {
|
||||
label: string;
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
.formItemSubtitle {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.changeFormButtonGroupContainer {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1,195 +1,21 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';
|
||||
import PipelineCard from '@/app/home/pipelines/components/pipeline-card/PipelineCard';
|
||||
import styles from './pipelineConfig.module.css';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PipelineDialog from './PipelineDetailDialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import PipelineDetailContent from './PipelineDetailContent';
|
||||
|
||||
export default function PluginConfigPage() {
|
||||
export default function PipelineConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [isEditForm, setIsEditForm] = useState(false);
|
||||
const [pipelineList, setPipelineList] = useState<PipelineCardVO[]>([]);
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState('');
|
||||
const [sortByValue, setSortByValue] = useState<string>('created_at');
|
||||
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
|
||||
const searchParams = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
useEffect(() => {
|
||||
// Load sort preference from localStorage
|
||||
const savedSortBy = localStorage.getItem('pipeline_sort_by');
|
||||
const savedSortOrder = localStorage.getItem('pipeline_sort_order');
|
||||
|
||||
if (savedSortBy && savedSortOrder) {
|
||||
setSortByValue(savedSortBy);
|
||||
setSortOrderValue(savedSortOrder);
|
||||
getPipelines(savedSortBy, savedSortOrder);
|
||||
} else {
|
||||
getPipelines();
|
||||
}
|
||||
}, []);
|
||||
|
||||
function getPipelines(
|
||||
sortBy: string = sortByValue,
|
||||
sortOrder: string = sortOrderValue,
|
||||
) {
|
||||
httpClient
|
||||
.getPipelines(sortBy, sortOrder)
|
||||
.then((value) => {
|
||||
const currentTime = new Date();
|
||||
const pipelineList = value.pipelines.map((pipeline) => {
|
||||
const lastUpdatedTimeAgo = Math.floor(
|
||||
(currentTime.getTime() -
|
||||
new Date(
|
||||
pipeline.updated_at ?? currentTime.getTime(),
|
||||
).getTime()) /
|
||||
1000 /
|
||||
60 /
|
||||
60 /
|
||||
24,
|
||||
);
|
||||
|
||||
const lastUpdatedTimeAgoText =
|
||||
lastUpdatedTimeAgo > 0
|
||||
? ` ${lastUpdatedTimeAgo} ${t('pipelines.daysAgo')}`
|
||||
: t('pipelines.today');
|
||||
|
||||
return new PipelineCardVO({
|
||||
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
|
||||
description: pipeline.description,
|
||||
id: pipeline.uuid ?? '',
|
||||
name: pipeline.name,
|
||||
emoji: pipeline.emoji,
|
||||
isDefault: pipeline.is_default ?? false,
|
||||
});
|
||||
});
|
||||
setPipelineList(pipelineList);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t('pipelines.getPipelineListError') + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
const handlePipelineClick = (pipelineId: string) => {
|
||||
setSelectedPipelineId(pipelineId);
|
||||
setIsEditForm(true);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
const maxPipelines = systemInfo.limitation?.max_pipelines ?? -1;
|
||||
if (maxPipelines >= 0 && pipelineList.length >= maxPipelines) {
|
||||
toast.error(t('limitation.maxPipelinesReached', { max: maxPipelines }));
|
||||
return;
|
||||
}
|
||||
setIsEditForm(false);
|
||||
setSelectedPipelineId('');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
function handleSortChange(value: string) {
|
||||
const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim());
|
||||
setSortByValue(newSortBy);
|
||||
setSortOrderValue(newSortOrder);
|
||||
|
||||
// Save sort preference to localStorage
|
||||
localStorage.setItem('pipeline_sort_by', newSortBy);
|
||||
localStorage.setItem('pipeline_sort_order', newSortOrder);
|
||||
|
||||
getPipelines(newSortBy, newSortOrder);
|
||||
if (detailId) {
|
||||
return <PipelineDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.configPageContainer}>
|
||||
<PipelineDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
pipelineId={selectedPipelineId || undefined}
|
||||
isEditMode={isEditForm}
|
||||
onFinish={() => {
|
||||
getPipelines();
|
||||
}}
|
||||
onNewPipelineCreated={(pipelineId) => {
|
||||
getPipelines();
|
||||
setSelectedPipelineId(pipelineId);
|
||||
setIsEditForm(true);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onDeletePipeline={() => {
|
||||
getPipelines();
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-between items-center mb-4 px-[0.8rem]">
|
||||
<Select
|
||||
value={`${sortByValue},${sortOrderValue}`}
|
||||
onValueChange={handleSortChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] cursor-pointer bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('pipelines.sortBy')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectItem
|
||||
value="created_at,DESC"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{t('pipelines.newestCreated')}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="created_at,ASC"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{t('pipelines.earliestCreated')}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="updated_at,DESC"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{t('pipelines.recentlyEdited')}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="updated_at,ASC"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{t('pipelines.earliestEdited')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className={styles.pipelineListContainer}>
|
||||
<CreateCardComponent
|
||||
width={'100%'}
|
||||
height={'10rem'}
|
||||
plusSize={'90px'}
|
||||
onClick={handleCreateNew}
|
||||
/>
|
||||
|
||||
{pipelineList.map((pipeline) => {
|
||||
return (
|
||||
<div
|
||||
key={pipeline.id}
|
||||
onClick={() => handlePipelineClick(pipeline.id)}
|
||||
>
|
||||
<PipelineCard cardVO={pipeline} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('pipelines.selectFromSidebar')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
96
web/src/app/home/plugins/PluginDetailContent.tsx
Normal file
96
web/src/app/home/plugins/PluginDetailContent.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
||||
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Bug } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Plugin detail page content.
|
||||
* The `id` prop is the composite key "author/name".
|
||||
*/
|
||||
export default function PluginDetailContent({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
|
||||
|
||||
// Parse "author/name" composite key
|
||||
const slashIndex = id.indexOf('/');
|
||||
const pluginAuthor = slashIndex >= 0 ? id.substring(0, slashIndex) : '';
|
||||
const pluginName = slashIndex >= 0 ? id.substring(slashIndex + 1) : id;
|
||||
|
||||
const plugin = plugins.find((p) => p.id === id);
|
||||
|
||||
// Set breadcrumb entity name
|
||||
useEffect(() => {
|
||||
setDetailEntityName(plugin?.name ?? `${pluginAuthor}/${pluginName}`);
|
||||
return () => setDetailEntityName(null);
|
||||
}, [plugin, pluginAuthor, pluginName, setDetailEntityName]);
|
||||
|
||||
function handleFormSubmit(timeout?: number) {
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
refreshPlugins();
|
||||
}, timeout);
|
||||
} else {
|
||||
refreshPlugins();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-3 pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{pluginAuthor}/{pluginName}
|
||||
</h1>
|
||||
{plugin?.debug ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-orange-400 text-orange-400"
|
||||
>
|
||||
<Bug className="size-3.5" />
|
||||
{t('plugins.debugging')}
|
||||
</Badge>
|
||||
) : plugin?.installSource === 'github' ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-blue-400 text-blue-400"
|
||||
>
|
||||
{t('plugins.fromGithub')}
|
||||
</Badge>
|
||||
) : plugin?.installSource === 'local' ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-green-400 text-green-400"
|
||||
>
|
||||
{t('plugins.fromLocal')}
|
||||
</Badge>
|
||||
) : plugin?.installSource === 'marketplace' ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.7rem] border-purple-400 text-purple-400"
|
||||
>
|
||||
{t('plugins.fromMarketplace')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col md:flex-row overflow-hidden min-h-0 gap-6 max-w-full">
|
||||
{/* Left side - Config */}
|
||||
<div className="md:w-[380px] md:flex-shrink-0 overflow-y-auto overflow-x-hidden">
|
||||
<PluginForm
|
||||
pluginAuthor={pluginAuthor}
|
||||
pluginName={pluginName}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
{/* Right side - Readme */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-w-0">
|
||||
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
|
||||
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
|
||||
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
||||
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
||||
import styles from '@/app/home/plugins/plugins.module.css';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
@@ -23,6 +22,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { toast } from 'sonner';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
export interface PluginInstalledComponentRef {
|
||||
refreshPluginList: () => void;
|
||||
@@ -37,13 +37,9 @@ enum PluginOperationType {
|
||||
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
(props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { refreshPlugins } = useSidebarData();
|
||||
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
|
||||
null,
|
||||
);
|
||||
const [readmeModalOpen, setReadmeModalOpen] = useState<boolean>(false);
|
||||
const [readmePlugin, setReadmePlugin] = useState<PluginCardVO | null>(null);
|
||||
const [showOperationModal, setShowOperationModal] = useState(false);
|
||||
const [operationType, setOperationType] = useState<PluginOperationType>(
|
||||
PluginOperationType.DELETE,
|
||||
@@ -60,6 +56,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
toast.success(successMessage);
|
||||
setShowOperationModal(false);
|
||||
getPluginList();
|
||||
refreshPlugins();
|
||||
},
|
||||
onError: () => {
|
||||
// Error is already handled in the hook state
|
||||
@@ -165,13 +162,8 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
}));
|
||||
|
||||
function handlePluginClick(plugin: PluginCardVO) {
|
||||
setSelectedPlugin(plugin);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleViewReadme(plugin: PluginCardVO) {
|
||||
setReadmePlugin(plugin);
|
||||
setReadmeModalOpen(true);
|
||||
const pluginId = `${plugin.author}/${plugin.name}`;
|
||||
router.push(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
|
||||
}
|
||||
|
||||
function handlePluginDelete(plugin: PluginCardVO) {
|
||||
@@ -355,56 +347,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.pluginListContainer}`}>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle>{t('plugins.pluginConfig')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
{selectedPlugin && (
|
||||
<PluginForm
|
||||
pluginAuthor={selectedPlugin.author}
|
||||
pluginName={selectedPlugin.name}
|
||||
onFormSubmit={(timeout?: number) => {
|
||||
setModalOpen(false);
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
getPluginList();
|
||||
}, timeout);
|
||||
} else {
|
||||
getPluginList();
|
||||
}
|
||||
}}
|
||||
onFormCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={readmeModalOpen} onOpenChange={setReadmeModalOpen}>
|
||||
<DialogContent className="sm:max-w-[900px] max-w-[90vw] max-h-[85vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-2 border-b">
|
||||
<DialogTitle>
|
||||
{readmePlugin &&
|
||||
`${readmePlugin.author}/${readmePlugin.name} - ${t(
|
||||
'plugins.readme',
|
||||
)}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{readmePlugin && (
|
||||
<PluginReadme
|
||||
pluginAuthor={readmePlugin.author}
|
||||
pluginName={readmePlugin.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{pluginList.map((vo, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
@@ -413,7 +355,6 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
onCardClick={() => handlePluginClick(vo)}
|
||||
onDeleteClick={() => handlePluginDelete(vo)}
|
||||
onUpgradeClick={() => handlePluginUpdate(vo)}
|
||||
onViewReadme={() => handleViewReadme(vo)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,7 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BugIcon,
|
||||
ExternalLink,
|
||||
Ellipsis,
|
||||
Trash,
|
||||
ArrowUp,
|
||||
Settings,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
|
||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -27,28 +19,20 @@ export default function PluginCardComponent({
|
||||
onCardClick,
|
||||
onDeleteClick,
|
||||
onUpgradeClick,
|
||||
onViewReadme,
|
||||
}: {
|
||||
cardVO: PluginCardVO;
|
||||
onCardClick: () => void;
|
||||
onDeleteClick: (cardVO: PluginCardVO) => void;
|
||||
onUpgradeClick: (cardVO: PluginCardVO) => void;
|
||||
onViewReadme: (cardVO: PluginCardVO) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] hover:scale-[1.005]"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!dropdownOpen) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}}
|
||||
className="w-[100%] h-[10rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-[1.2rem] cursor-pointer dark:bg-[#1f1f22] relative transition-all duration-200 hover:border-[#a1a1aa] dark:hover:border-[#3f3f46]"
|
||||
onClick={() => onCardClick()}
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
{/* Icon - fixed width */}
|
||||
@@ -145,7 +129,10 @@ export default function PluginCardComponent({
|
||||
</div>
|
||||
|
||||
{/* Menu button - fixed width and position */}
|
||||
<div className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0">
|
||||
<div
|
||||
className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-center"></div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
@@ -153,9 +140,6 @@ export default function PluginCardComponent({
|
||||
open={dropdownOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDropdownOpen(open);
|
||||
if (!open) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -233,45 +217,6 @@ export default function PluginCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover overlay with action buttons */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 z-10 ${
|
||||
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewReadme(cardVO);
|
||||
}}
|
||||
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
{t('plugins.readme')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCardClick();
|
||||
}}
|
||||
variant="outline"
|
||||
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
|
||||
isHovered
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
{t('plugins.config')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,12 +13,10 @@ export default function PluginForm({
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
onFormSubmit,
|
||||
onFormCancel,
|
||||
}: {
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
onFormSubmit: (timeout?: number) => void;
|
||||
onFormCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [pluginInfo, setPluginInfo] = useState<Plugin>();
|
||||
@@ -195,20 +193,19 @@ export default function PluginForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('plugins.cancel')}
|
||||
</Button>
|
||||
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ function MarketPageContent({
|
||||
const pageSize = 12; // 每页12个
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
// 排序选项
|
||||
const sortOptions: SortOption[] = [
|
||||
@@ -151,7 +152,15 @@ function MarketPageContent({
|
||||
);
|
||||
|
||||
const data: ApiRespMarketplacePlugins = response;
|
||||
const newPlugins = data.plugins.map(transformToVO);
|
||||
const newPlugins = data.plugins
|
||||
.filter((plugin) => {
|
||||
// Hide plugins that only contain deprecated KnowledgeRetriever components
|
||||
const keys = Object.keys(plugin.components || {});
|
||||
return !(
|
||||
keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')
|
||||
);
|
||||
})
|
||||
.map(transformToVO);
|
||||
const total = data.total;
|
||||
|
||||
if (reset || page === 1) {
|
||||
@@ -250,10 +259,14 @@ function MarketPageContent({
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (isComposingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置新的定时器
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
handleSearch(value);
|
||||
}, 300);
|
||||
}, 500);
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
@@ -398,6 +411,13 @@ function MarketPageContent({
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchInputChange(e.target.value)}
|
||||
onCompositionStart={() => {
|
||||
isComposingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={(e) => {
|
||||
isComposingRef.current = false;
|
||||
handleSearchInputChange((e.target as HTMLInputElement).value);
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// Immediately search, clear debounce timer
|
||||
@@ -422,68 +442,70 @@ function MarketPageContent({
|
||||
{/* Component filter and sort */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
|
||||
{/* Component filter */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 min-w-0 max-w-full">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{t('market.filterByComponent')}:
|
||||
</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
spacing={2}
|
||||
size="sm"
|
||||
value={componentFilter}
|
||||
onValueChange={(value) => {
|
||||
if (value) handleComponentFilterChange(value);
|
||||
}}
|
||||
className="justify-start"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="all"
|
||||
aria-label="All components"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
<div className="overflow-x-auto max-w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
spacing={2}
|
||||
size="sm"
|
||||
value={componentFilter}
|
||||
onValueChange={(value) => {
|
||||
if (value) handleComponentFilterChange(value);
|
||||
}}
|
||||
className="justify-start flex-nowrap"
|
||||
>
|
||||
{t('market.allComponents')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Tool"
|
||||
aria-label="Tool"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Wrench className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Tool')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Command"
|
||||
aria-label="Command"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Hash className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Command')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="EventListener"
|
||||
aria-label="EventListener"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<AudioWaveform className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.EventListener')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="KnowledgeEngine"
|
||||
aria-label="KnowledgeEngine"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Book className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.KnowledgeEngine')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Parser"
|
||||
aria-label="Parser"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Parser')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroupItem
|
||||
value="all"
|
||||
aria-label="All components"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
{t('market.allComponents')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Tool"
|
||||
aria-label="Tool"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Wrench className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Tool')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Command"
|
||||
aria-label="Command"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Hash className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Command')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="EventListener"
|
||||
aria-label="EventListener"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<AudioWaveform className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.EventListener')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="KnowledgeEngine"
|
||||
aria-label="KnowledgeEngine"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Book className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.KnowledgeEngine')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Parser"
|
||||
aria-label="Parser"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Parser')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
|
||||
@@ -59,7 +59,11 @@ function RecommendationListRow({
|
||||
const [perPage, setPerPage] = useState(4);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const plugins = list.plugins || [];
|
||||
const plugins = (list.plugins || []).filter((plugin) => {
|
||||
// Hide plugins that only contain deprecated KnowledgeRetriever components
|
||||
const keys = Object.keys(plugin.components || {});
|
||||
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'));
|
||||
});
|
||||
|
||||
// Measure how many columns the CSS grid actually renders
|
||||
const measureCols = useCallback(() => {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { PluginMarketCardVO } from './PluginMarketCardVO';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
Wrench,
|
||||
AudioWaveform,
|
||||
@@ -15,7 +9,6 @@ import {
|
||||
ExternalLink,
|
||||
Book,
|
||||
FileText,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -90,16 +83,9 @@ export default function PluginMarketCardComponent({
|
||||
Parser: <FileText className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
// Plugins that only contain KnowledgeRetriever components are deprecated
|
||||
const isDeprecated = (() => {
|
||||
if (!cardVO.components) return false;
|
||||
const keys = Object.keys(cardVO.components);
|
||||
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative"
|
||||
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-3 sm:p-[1rem] hover:border-[#a1a1aa] dark:hover:border-[#3f3f46] transition-all duration-200 dark:bg-[#1f1f22] relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
@@ -121,30 +107,6 @@ export default function PluginMarketCardComponent({
|
||||
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
|
||||
{cardVO.label}
|
||||
</div>
|
||||
{isDeprecated && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
|
||||
>
|
||||
{t('market.deprecated')}
|
||||
<Info className="w-2.5 h-2.5" />
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-[240px] text-xs"
|
||||
>
|
||||
{t('market.deprecatedTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function MCPCardComponent({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] dark:shadow-none p-[1.2rem] cursor-pointer transition-all duration-200 hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.1)] dark:hover:shadow-none"
|
||||
className="w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-[1.2rem] cursor-pointer transition-all duration-200 hover:border-[#a1a1aa] dark:hover:border-[#3f3f46]"
|
||||
onClick={onCardClick}
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
|
||||
@@ -2,19 +2,9 @@
|
||||
import PluginInstalledComponent, {
|
||||
PluginInstalledComponentRef,
|
||||
} from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent';
|
||||
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
|
||||
import MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent';
|
||||
import MCPFormDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPFormDialog';
|
||||
import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog';
|
||||
import PluginDetailContent from './PluginDetailContent';
|
||||
import styles from './plugins.module.css';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
PlusIcon,
|
||||
ChevronDownIcon,
|
||||
@@ -47,14 +37,21 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
enum PluginInstallStatus {
|
||||
WAIT_INPUT = 'wait_input',
|
||||
@@ -83,12 +80,28 @@ interface GithubAsset {
|
||||
}
|
||||
|
||||
export default function PluginConfigPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
// Show plugin detail view when ?id= query param is present
|
||||
if (detailId) {
|
||||
return <PluginDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
return <PluginListView />;
|
||||
}
|
||||
|
||||
function PluginListView() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('installed');
|
||||
const router = useRouter();
|
||||
const {
|
||||
refreshPlugins,
|
||||
pendingPluginInstallAction,
|
||||
setPendingPluginInstallAction,
|
||||
} = useSidebarData();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [installSource, setInstallSource] = useState<string>('local');
|
||||
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const [mcpSSEModalOpen, setMcpSSEModalOpen] = useState(false);
|
||||
const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
@@ -108,12 +121,6 @@ export default function PluginConfigPage() {
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
|
||||
const [editingServerName, setEditingServerName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [debugInfo, setDebugInfo] = useState<{
|
||||
debug_url: string;
|
||||
plugin_debug_key: string;
|
||||
@@ -166,6 +173,7 @@ export default function PluginConfigPage() {
|
||||
resetGithubState();
|
||||
setModalOpen(false);
|
||||
pluginInstalledRef.current?.refreshPluginList();
|
||||
refreshPlugins();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -318,17 +326,6 @@ export default function PluginConfigPage() {
|
||||
setInstallError(err.msg);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
});
|
||||
} else if (installSource === 'marketplace') {
|
||||
httpClient
|
||||
.installPluginFromMarketplace(
|
||||
installInfo.plugin_author,
|
||||
installInfo.plugin_name,
|
||||
installInfo.plugin_version,
|
||||
)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
watchTask(taskId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +412,28 @@ export default function PluginConfigPage() {
|
||||
[uploadPluginFile, isPluginSystemReady, t],
|
||||
);
|
||||
|
||||
// Auto-trigger install action from sidebar via shared context
|
||||
useEffect(() => {
|
||||
if (!pendingPluginInstallAction || statusLoading || !isPluginSystemReady)
|
||||
return;
|
||||
|
||||
// Consume the action immediately
|
||||
const action = pendingPluginInstallAction;
|
||||
setPendingPluginInstallAction(null);
|
||||
|
||||
if (action === 'local') {
|
||||
// Small delay to ensure file input ref is ready
|
||||
setTimeout(() => fileInputRef.current?.click(), 100);
|
||||
} else if (action === 'github') {
|
||||
setInstallSource('github');
|
||||
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setInstallError(null);
|
||||
resetGithubState();
|
||||
setModalOpen(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingPluginInstallAction, statusLoading, isPluginSystemReady]);
|
||||
|
||||
const handleShowDebugInfo = async () => {
|
||||
try {
|
||||
const info = await httpClient.getPluginDebugInfo();
|
||||
@@ -526,218 +545,144 @@ export default function PluginConfigPage() {
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full h-full flex flex-col"
|
||||
>
|
||||
<div className="flex flex-row justify-between items-center px-[0.8rem] flex-shrink-0">
|
||||
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
|
||||
<TabsTrigger value="installed" className="px-6 py-4 cursor-pointer">
|
||||
{t('plugins.installed')}
|
||||
</TabsTrigger>
|
||||
{systemInfo.enable_marketplace && (
|
||||
<TabsTrigger value="market" className="px-6 py-4 cursor-pointer">
|
||||
{t('plugins.marketplace')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="mcp-servers"
|
||||
className="px-6 py-4 cursor-pointer"
|
||||
|
||||
{/* Header bar with debug info and install button */}
|
||||
<div className="flex flex-row justify-end items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
|
||||
<Popover open={debugPopoverOpen} onOpenChange={setDebugPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-4 py-5 cursor-pointer"
|
||||
onClick={handleShowDebugInfo}
|
||||
>
|
||||
{t('mcp.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
{t('plugins.debugInfo')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[380px]" align="end">
|
||||
<div className="space-y-3">
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Bug className="w-4 h-4" />
|
||||
<h4 className="font-semibold text-sm">
|
||||
{t('plugins.debugInfoTitle')}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end items-center gap-2">
|
||||
{activeTab === 'installed' && (
|
||||
<Popover
|
||||
open={debugPopoverOpen}
|
||||
onOpenChange={setDebugPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-4 py-5 cursor-pointer"
|
||||
onClick={handleShowDebugInfo}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
{t('plugins.debugInfo')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[380px]" align="end">
|
||||
<div className="space-y-3">
|
||||
{/* Header with icon and title */}
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Bug className="w-4 h-4" />
|
||||
<h4 className="font-semibold text-sm">
|
||||
{t('plugins.debugInfoTitle')}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Debug URL row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
||||
{t('plugins.debugUrl')}:
|
||||
</label>
|
||||
<Input
|
||||
value={debugInfo?.debug_url || ''}
|
||||
readOnly
|
||||
className="w-[220px] font-mono text-xs h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() =>
|
||||
handleCopyDebugInfo(debugInfo?.debug_url || '', 'url')
|
||||
}
|
||||
>
|
||||
{copiedDebugUrl ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Debug Key row */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
||||
{t('plugins.debugKey')}:
|
||||
</label>
|
||||
<Input
|
||||
value={
|
||||
debugInfo?.plugin_debug_key ||
|
||||
t('plugins.noDebugKey')
|
||||
}
|
||||
readOnly
|
||||
className="w-[220px] font-mono text-xs h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() =>
|
||||
handleCopyDebugInfo(
|
||||
debugInfo?.plugin_debug_key || '',
|
||||
'key',
|
||||
)
|
||||
}
|
||||
disabled={!debugInfo?.plugin_debug_key}
|
||||
>
|
||||
{copiedDebugKey ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{!debugInfo?.plugin_debug_key && (
|
||||
<p className="text-xs text-muted-foreground ml-[58px]">
|
||||
{t('plugins.debugKeyDisabled')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default" className="px-6 py-4 cursor-pointer">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{activeTab === 'mcp-servers'
|
||||
? t('mcp.add')
|
||||
: t('plugins.install')}
|
||||
<ChevronDownIcon className="ml-2 w-4 h-4" />
|
||||
{/* Debug URL row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
||||
{t('plugins.debugUrl')}:
|
||||
</label>
|
||||
<Input
|
||||
value={debugInfo?.debug_url || ''}
|
||||
readOnly
|
||||
className="w-[220px] font-mono text-xs h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() =>
|
||||
handleCopyDebugInfo(debugInfo?.debug_url || '', 'url')
|
||||
}
|
||||
>
|
||||
{copiedDebugUrl ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{activeTab === 'mcp-servers' ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
setActiveTab('mcp-servers');
|
||||
setIsEditMode(false);
|
||||
setEditingServerName(null);
|
||||
setMcpSSEModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{t('mcp.createServer')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setActiveTab('market');
|
||||
}}
|
||||
>
|
||||
<StoreIcon className="w-4 h-4" />
|
||||
{t('plugins.marketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleFileSelect}>
|
||||
<UploadIcon className="w-4 h-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
setInstallSource('github');
|
||||
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setInstallError(null);
|
||||
resetGithubState();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="installed" className="flex-1 overflow-y-auto mt-0">
|
||||
<PluginInstalledComponent ref={pluginInstalledRef} />
|
||||
</TabsContent>
|
||||
<TabsContent value="market" className="flex-1 overflow-y-auto mt-0">
|
||||
<MarketPage
|
||||
installPlugin={async (plugin: PluginV4) => {
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
setInstallSource('marketplace');
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
});
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="mcp-servers"
|
||||
className="flex-1 overflow-y-auto mt-0"
|
||||
>
|
||||
<MCPServerComponent
|
||||
key={refreshKey}
|
||||
onEditServer={(serverName) => {
|
||||
setEditingServerName(serverName);
|
||||
setIsEditMode(true);
|
||||
setMcpSSEModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Debug Key row */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium whitespace-nowrap min-w-[50px]">
|
||||
{t('plugins.debugKey')}:
|
||||
</label>
|
||||
<Input
|
||||
value={
|
||||
debugInfo?.plugin_debug_key || t('plugins.noDebugKey')
|
||||
}
|
||||
readOnly
|
||||
className="w-[220px] font-mono text-xs h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() =>
|
||||
handleCopyDebugInfo(
|
||||
debugInfo?.plugin_debug_key || '',
|
||||
'key',
|
||||
)
|
||||
}
|
||||
disabled={!debugInfo?.plugin_debug_key}
|
||||
>
|
||||
{copiedDebugKey ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{!debugInfo?.plugin_debug_key && (
|
||||
<p className="text-xs text-muted-foreground ml-[58px]">
|
||||
{t('plugins.debugKeyDisabled')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default" className="px-6 py-4 cursor-pointer">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
{t('plugins.install')}
|
||||
<ChevronDownIcon className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
router.push('/home/market');
|
||||
}}
|
||||
>
|
||||
<StoreIcon className="w-4 h-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleFileSelect}>
|
||||
<UploadIcon className="w-4 h-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
setInstallSource('github');
|
||||
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
|
||||
setInstallError(null);
|
||||
resetGithubState();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Installed plugins grid */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PluginInstalledComponent ref={pluginInstalledRef} />
|
||||
</div>
|
||||
|
||||
{/* Install plugin dialog (GitHub flow) */}
|
||||
<Dialog
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -886,19 +831,6 @@ export default function PluginConfigPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Marketplace Install Confirm */}
|
||||
{installSource === 'marketplace' &&
|
||||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">
|
||||
{t('plugins.askConfirm', {
|
||||
name: installInfo.plugin_name,
|
||||
version: installInfo.plugin_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GitHub Install Confirm */}
|
||||
{installSource === 'github' &&
|
||||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
@@ -1009,33 +941,6 @@ export default function PluginConfigPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MCPFormDialog
|
||||
open={mcpSSEModalOpen}
|
||||
onOpenChange={setMcpSSEModalOpen}
|
||||
serverName={editingServerName}
|
||||
isEditMode={isEditMode}
|
||||
onSuccess={() => {
|
||||
setEditingServerName(null);
|
||||
setIsEditMode(false);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setShowDeleteConfirmModal(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MCPDeleteConfirmDialog
|
||||
open={showDeleteConfirmModal}
|
||||
onOpenChange={setShowDeleteConfirmModal}
|
||||
serverName={editingServerName}
|
||||
onSuccess={() => {
|
||||
setMcpSSEModalOpen(false);
|
||||
setEditingServerName(null);
|
||||
setIsEditMode(false);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.cardContainer {
|
||||
background-color: #fff;
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e4e4e7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -12,15 +12,15 @@
|
||||
|
||||
:global(.dark) .cardContainer {
|
||||
background-color: #1f1f22;
|
||||
box-shadow: 0;
|
||||
border-color: #27272a;
|
||||
}
|
||||
|
||||
.cardContainer:hover {
|
||||
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.05);
|
||||
border-color: #a1a1aa;
|
||||
}
|
||||
|
||||
:global(.dark) .cardContainer:hover {
|
||||
box-shadow: 0;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.createCardContainer {
|
||||
|
||||
@@ -97,6 +97,10 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');
|
||||
}
|
||||
|
||||
public getGitHubRepoInfo(): Promise<GitHubRepoInfo> {
|
||||
return this.get<GitHubRepoInfo>('/api/v1/dist/info/repo');
|
||||
}
|
||||
|
||||
public getAllTags(): Promise<{ tags: PluginTag[] }> {
|
||||
return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');
|
||||
}
|
||||
@@ -134,3 +138,13 @@ export interface GitHubRelease {
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubRepoInfo {
|
||||
repo: {
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
open_issues_count: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
contributors: unknown[];
|
||||
}
|
||||
|
||||
1086
web/src/app/wizard/page.tsx
Normal file
1086
web/src/app/wizard/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
53
web/src/components/ui/avatar.tsx
Normal file
53
web/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn('aspect-square size-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -25,8 +25,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_STORAGE_KEY = 'sidebar_state';
|
||||
const SIDEBAR_WIDTH = '16rem';
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
||||
const SIDEBAR_WIDTH_ICON = '3rem';
|
||||
@@ -71,7 +70,13 @@ function SidebarProvider({
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const [_open, _setOpen] = React.useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||
if (stored !== null) return stored === 'true';
|
||||
}
|
||||
return defaultOpen;
|
||||
});
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
@@ -82,8 +87,10 @@ function SidebarProvider({
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
// Persist sidebar state to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(openState));
|
||||
}
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
@@ -139,7 +146,7 @@ function SidebarProvider({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh w-full overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -311,6 +318,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||
className={cn(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
'dark:md:peer-data-[variant=inset]:border dark:md:peer-data-[variant=inset]:border-sidebar-border',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -643,7 +651,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
|
||||
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 px-2.5 py-0.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user