mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
Compare commits
10 Commits
fix/wecomb
...
pr/6mvp6/2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bb73297e0 | ||
|
|
1c419e3591 | ||
|
|
b0a9be77b0 | ||
|
|
e02ade5a30 | ||
|
|
1a51ba8e7e | ||
|
|
e7b22d6ebf | ||
|
|
dddfa8ac79 | ||
|
|
99e2976826 | ||
|
|
71e44f0e54 | ||
|
|
4c904c2375 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -52,3 +52,11 @@ src/langbot/web/
|
||||
/dist
|
||||
/build
|
||||
*.egg-info
|
||||
|
||||
# Docker 部署产生的本地文件
|
||||
docker/data/
|
||||
docker/docker-compose.override.yaml
|
||||
|
||||
# 备份目录
|
||||
LangBot_backup_*/
|
||||
*.bak
|
||||
|
||||
@@ -10,14 +10,18 @@ FROM python:3.12.7-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Use Chinese mirror for faster and more reliable package downloads
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
COPY . .
|
||||
|
||||
COPY --from=node /app/web/out ./web/out
|
||||
|
||||
RUN apt update \
|
||||
&& apt install gcc -y \
|
||||
&& python -m pip install --no-cache-dir uv \
|
||||
&& uv sync \
|
||||
&& python -m pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple uv \
|
||||
&& uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
&& touch /.dockerenv
|
||||
|
||||
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
||||
@@ -64,6 +64,9 @@ class StreamSession:
|
||||
# 缓存最近一次片段,处理重试或超时兜底
|
||||
last_chunk: Optional[StreamChunk] = None
|
||||
|
||||
# 反馈 ID,用于接收用户点赞/点踩反馈
|
||||
feedback_id: Optional[str] = None
|
||||
|
||||
|
||||
class StreamSessionManager:
|
||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||
@@ -74,6 +77,7 @@ class StreamSessionManager:
|
||||
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
||||
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
||||
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
||||
self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射
|
||||
|
||||
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
||||
if not msg_id:
|
||||
@@ -83,6 +87,32 @@ class StreamSessionManager:
|
||||
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
||||
return self._sessions.get(stream_id)
|
||||
|
||||
def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]:
|
||||
"""根据 feedback_id 查找会话。
|
||||
|
||||
Args:
|
||||
feedback_id: 企业微信反馈事件中的反馈 ID。
|
||||
|
||||
Returns:
|
||||
Optional[StreamSession]: 找到的会话实例,未找到返回 None。
|
||||
"""
|
||||
if not feedback_id:
|
||||
return None
|
||||
stream_id = self._feedback_index.get(feedback_id)
|
||||
if stream_id:
|
||||
return self._sessions.get(stream_id)
|
||||
return None
|
||||
|
||||
def register_feedback_id(self, stream_id: str, feedback_id: str) -> None:
|
||||
"""注册 feedback_id 与 stream_id 的映射。
|
||||
|
||||
Args:
|
||||
stream_id: 企业微信流式会话 ID。
|
||||
feedback_id: 反馈 ID。
|
||||
"""
|
||||
if feedback_id and stream_id:
|
||||
self._feedback_index[feedback_id] = stream_id
|
||||
|
||||
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
||||
"""根据企业微信回调创建或获取会话。
|
||||
|
||||
@@ -597,14 +627,27 @@ class WecomBotClient:
|
||||
self.stream_sessions = StreamSessionManager(logger=logger)
|
||||
self.stream_poll_timeout = 0.5
|
||||
|
||||
self._feedback_callback: Optional[Callable] = None
|
||||
|
||||
def set_feedback_callback(self, callback: Callable) -> None:
|
||||
"""设置反馈回调函数。
|
||||
|
||||
Args:
|
||||
callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session)
|
||||
"""
|
||||
self._feedback_callback = callback
|
||||
|
||||
@staticmethod
|
||||
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
|
||||
def _build_stream_payload(
|
||||
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
|
||||
) -> dict[str, Any]:
|
||||
"""按照企业微信协议拼装返回报文。
|
||||
|
||||
Args:
|
||||
stream_id: 企业微信会话 ID。
|
||||
content: 推送的文本内容。
|
||||
finish: 是否为最终片段。
|
||||
feedback_id: 反馈 ID,用于接收用户点赞/点踩反馈。
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: 可直接加密返回的 payload。
|
||||
@@ -612,13 +655,16 @@ class WecomBotClient:
|
||||
Example:
|
||||
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
||||
"""
|
||||
stream_payload = {
|
||||
'id': stream_id,
|
||||
'finish': finish,
|
||||
'content': content,
|
||||
}
|
||||
if feedback_id:
|
||||
stream_payload['feedback'] = {'id': feedback_id}
|
||||
return {
|
||||
'msgtype': 'stream',
|
||||
'stream': {
|
||||
'id': stream_id,
|
||||
'finish': finish,
|
||||
'content': content,
|
||||
},
|
||||
'stream': stream_payload,
|
||||
}
|
||||
|
||||
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
@@ -674,9 +720,14 @@ class WecomBotClient:
|
||||
"""
|
||||
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
||||
|
||||
feedback_id = str(uuid.uuid4())
|
||||
session.feedback_id = feedback_id
|
||||
self.stream_sessions.register_feedback_id(session.stream_id, feedback_id)
|
||||
|
||||
message_data = await self.get_message(msg_json)
|
||||
if message_data:
|
||||
message_data['stream_id'] = session.stream_id
|
||||
message_data['feedback_id'] = feedback_id
|
||||
try:
|
||||
event = wecombotevent.WecomBotEvent(message_data)
|
||||
except Exception:
|
||||
@@ -685,7 +736,7 @@ class WecomBotClient:
|
||||
if is_new:
|
||||
asyncio.create_task(self._dispatch_event(event))
|
||||
|
||||
payload = self._build_stream_payload(session.stream_id, '', False)
|
||||
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
|
||||
return await self._encrypt_and_reply(payload, nonce)
|
||||
|
||||
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
@@ -810,11 +861,79 @@ class WecomBotClient:
|
||||
|
||||
msg_json = json.loads(decrypted_xml)
|
||||
|
||||
event = msg_json.get('event', {})
|
||||
event_type = event.get('eventtype', '')
|
||||
|
||||
if event_type == 'feedback_event':
|
||||
return await self._handle_feedback_event(msg_json, nonce)
|
||||
|
||||
if msg_json.get('msgtype') == 'stream':
|
||||
return await self._handle_post_followup_response(msg_json, nonce)
|
||||
|
||||
return await self._handle_post_initial_response(msg_json, nonce)
|
||||
|
||||
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
"""处理企业微信用户反馈事件(点赞/点踩)。
|
||||
|
||||
Args:
|
||||
msg_json: 解密后的企业微信反馈事件 JSON。
|
||||
nonce: 企业微信回调参数 nonce。
|
||||
|
||||
Returns:
|
||||
Tuple[Response, int]: Quart Response 及状态码。
|
||||
|
||||
Note:
|
||||
企业微信协议要求:反馈事件目前仅支持回复空包。
|
||||
"""
|
||||
try:
|
||||
feedback_event = msg_json.get('event', {}).get('feedback_event', {})
|
||||
feedback_id = feedback_event.get('id', '')
|
||||
feedback_type = feedback_event.get('type', 0)
|
||||
feedback_content = feedback_event.get('content', '')
|
||||
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||
|
||||
await self.logger.info(
|
||||
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||
)
|
||||
|
||||
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||
if session:
|
||||
await self.logger.info(
|
||||
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, '
|
||||
f'user_id={session.user_id}'
|
||||
)
|
||||
for handler in self._message_handlers.get('feedback', []):
|
||||
try:
|
||||
await handler(
|
||||
feedback_id=feedback_id,
|
||||
feedback_type=feedback_type,
|
||||
feedback_content=feedback_content,
|
||||
inaccurate_reasons=inaccurate_reasons,
|
||||
session=session,
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
|
||||
if self._feedback_callback:
|
||||
try:
|
||||
await self._feedback_callback(
|
||||
feedback_id=feedback_id,
|
||||
feedback_type=feedback_type,
|
||||
feedback_content=feedback_content,
|
||||
inaccurate_reasons=inaccurate_reasons,
|
||||
session=session,
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
else:
|
||||
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
||||
|
||||
except Exception:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
|
||||
return await self._encrypt_and_reply({}, nonce)
|
||||
|
||||
async def get_message(self, msg_json):
|
||||
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
||||
|
||||
@@ -883,6 +1002,15 @@ class WecomBotClient:
|
||||
|
||||
return decorator
|
||||
|
||||
def on_feedback(self):
|
||||
def decorator(func: Callable):
|
||||
if 'feedback' not in self._message_handlers:
|
||||
self._message_handlers['feedback'] = []
|
||||
self._message_handlers['feedback'].append(func)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||
if data:
|
||||
|
||||
@@ -133,3 +133,17 @@ class WecomBotEvent(dict):
|
||||
AI Bot ID
|
||||
"""
|
||||
return self.get('aibotid', '')
|
||||
|
||||
@property
|
||||
def feedback_id(self) -> str:
|
||||
"""
|
||||
反馈 ID,用于关联用户点赞/点踩反馈
|
||||
"""
|
||||
return self.get('feedback_id', '')
|
||||
|
||||
@property
|
||||
def stream_id(self) -> str:
|
||||
"""
|
||||
流式消息 ID
|
||||
"""
|
||||
return self.get('stream_id', '')
|
||||
|
||||
@@ -456,6 +456,31 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
'platform',
|
||||
'user_id',
|
||||
]
|
||||
elif export_type == 'feedback':
|
||||
data = await self.ap.monitoring_service.export_feedback(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
)
|
||||
headers = [
|
||||
'id',
|
||||
'timestamp',
|
||||
'feedback_id',
|
||||
'feedback_type',
|
||||
'feedback_content',
|
||||
'inaccurate_reasons',
|
||||
'bot_id',
|
||||
'bot_name',
|
||||
'pipeline_id',
|
||||
'pipeline_name',
|
||||
'session_id',
|
||||
'message_id',
|
||||
'stream_id',
|
||||
'user_id',
|
||||
'platform',
|
||||
]
|
||||
else:
|
||||
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
||||
|
||||
@@ -486,3 +511,63 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
return response, 200
|
||||
|
||||
@self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_feedback_stats() -> str:
|
||||
"""Get feedback statistics"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
stats = await self.ap.monitoring_service.get_feedback_stats(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
return self.success(data=stats)
|
||||
|
||||
@self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_feedback() -> str:
|
||||
"""Get feedback list"""
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
feedback_type_str = quart.request.args.get('feedbackType')
|
||||
start_time_str = quart.request.args.get('startTime')
|
||||
end_time_str = quart.request.args.get('endTime')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
|
||||
# Parse feedback type
|
||||
feedback_type = int(feedback_type_str) if feedback_type_str else None
|
||||
|
||||
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
feedback_type=feedback_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'feedback': feedback_list,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -265,6 +265,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, -1, 'Missing asset_url parameter')
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
|
||||
ctx.metadata['install_source'] = 'github'
|
||||
install_info = {
|
||||
'asset_url': asset_url,
|
||||
'owner': owner,
|
||||
@@ -295,12 +297,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
data = await quart.request.json
|
||||
|
||||
plugin_author = data.get('plugin_author', '')
|
||||
plugin_name = data.get('plugin_name', '')
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||
ctx.metadata['install_source'] = 'marketplace'
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-marketplace',
|
||||
label=f'Installing plugin from marketplace ...{data}',
|
||||
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
@@ -323,11 +330,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
}
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
|
||||
ctx.metadata['install_source'] = 'local'
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-local',
|
||||
label=f'Installing plugin from local ...{file.filename}',
|
||||
label=f'Installing plugin from local {file.filename}',
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import json
|
||||
|
||||
import quart
|
||||
import sqlalchemy
|
||||
|
||||
from .. import group
|
||||
from .....utils import constants
|
||||
from .....entity.persistence.metadata import Metadata
|
||||
|
||||
|
||||
@group.group_class('system', '/api/v1/system')
|
||||
@@ -9,6 +13,24 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
# Read wizard_status and wizard_progress from metadata table
|
||||
wizard_status = 'none'
|
||||
wizard_progress = None
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress']))
|
||||
)
|
||||
for row in result:
|
||||
if row.key == 'wizard_status':
|
||||
wizard_status = row.value
|
||||
elif row.key == 'wizard_progress':
|
||||
try:
|
||||
wizard_progress = json.loads(row.value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
wizard_progress = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'version': constants.semantic_version,
|
||||
@@ -27,17 +49,83 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'disable_models_service', False
|
||||
),
|
||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||
'wizard_status': wizard_status,
|
||||
'wizard_progress': wizard_progress,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""Mark wizard status in metadata table and clear progress.
|
||||
|
||||
Accepts JSON body: { "status": "skipped" | "completed" }
|
||||
"""
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
status = data.get('status', 'completed')
|
||||
if status not in ('skipped', 'completed'):
|
||||
return self.http_status(400, 400, f'Invalid wizard status: {status}')
|
||||
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status')
|
||||
)
|
||||
if result.first():
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status)
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(Metadata).values(key='wizard_status', value=status)
|
||||
)
|
||||
|
||||
# Clear wizard progress when wizard is completed/skipped
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress')
|
||||
)
|
||||
except Exception as e:
|
||||
return self.http_status(500, 500, f'Failed to update wizard status: {e}')
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""Save wizard progress to metadata table.
|
||||
|
||||
Accepts JSON body with wizard state fields:
|
||||
{ "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null,
|
||||
"bot_saved": bool, "selected_runner": str|null }
|
||||
"""
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
progress_json = json.dumps(data, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress')
|
||||
)
|
||||
if result.first():
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json)
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json)
|
||||
)
|
||||
except Exception as e:
|
||||
return self.http_status(500, 500, f'Failed to save wizard progress: {e}')
|
||||
|
||||
return self.success(data={})
|
||||
|
||||
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
task_type = quart.request.args.get('type')
|
||||
task_kind = quart.request.args.get('kind')
|
||||
|
||||
if task_type == '':
|
||||
task_type = None
|
||||
if task_kind == '':
|
||||
task_kind = None
|
||||
|
||||
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))
|
||||
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
|
||||
|
||||
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(task_id: str) -> str:
|
||||
|
||||
@@ -1183,3 +1183,268 @@ class MonitoringService:
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# ========== Feedback Methods ==========
|
||||
|
||||
async def record_feedback(
|
||||
self,
|
||||
feedback_id: str,
|
||||
feedback_type: int,
|
||||
feedback_content: str | None = None,
|
||||
inaccurate_reasons: list[str] | None = None,
|
||||
bot_id: str | None = None,
|
||||
bot_name: str | None = None,
|
||||
pipeline_id: str | None = None,
|
||||
pipeline_name: str | None = None,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
stream_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
platform: str | None = None,
|
||||
) -> str:
|
||||
"""Record user feedback (like/dislike) from AI Bot conversation.
|
||||
|
||||
Args:
|
||||
feedback_id: Unique feedback identifier from platform (e.g., WeChat Work)
|
||||
feedback_type: 1 = like (thumbs up), 2 = dislike (thumbs down)
|
||||
feedback_content: Optional user feedback text
|
||||
inaccurate_reasons: List of reasons for inaccurate response (for dislike)
|
||||
bot_id: Bot ID
|
||||
bot_name: Bot name
|
||||
pipeline_id: Pipeline ID
|
||||
pipeline_name: Pipeline name
|
||||
session_id: Session ID
|
||||
message_id: Message ID
|
||||
stream_id: Stream ID (for WeChat Work streaming messages)
|
||||
user_id: User ID
|
||||
platform: Platform name (e.g., 'wecom')
|
||||
|
||||
Returns:
|
||||
The record ID
|
||||
"""
|
||||
import json
|
||||
|
||||
record_id = str(uuid.uuid4())
|
||||
record_data = {
|
||||
'id': record_id,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'feedback_id': feedback_id,
|
||||
'feedback_type': feedback_type,
|
||||
'feedback_content': feedback_content,
|
||||
'inaccurate_reasons': json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None,
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'session_id': session_id,
|
||||
'message_id': message_id,
|
||||
'stream_id': stream_id,
|
||||
'user_id': user_id,
|
||||
'platform': platform,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
|
||||
)
|
||||
|
||||
return record_id
|
||||
|
||||
async def get_feedback_stats(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
) -> dict:
|
||||
"""Get feedback statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
|
||||
"""
|
||||
conditions = []
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||
|
||||
# Get total likes (feedback_type = 1)
|
||||
likes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
||||
persistence_monitoring.MonitoringFeedback.feedback_type == 1
|
||||
)
|
||||
if conditions:
|
||||
likes_query = likes_query.where(sqlalchemy.and_(*conditions))
|
||||
likes_result = await self.ap.persistence_mgr.execute_async(likes_query)
|
||||
total_likes = likes_result.scalar() or 0
|
||||
|
||||
# Get total dislikes (feedback_type = 2)
|
||||
dislikes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
||||
persistence_monitoring.MonitoringFeedback.feedback_type == 2
|
||||
)
|
||||
if conditions:
|
||||
dislikes_query = dislikes_query.where(sqlalchemy.and_(*conditions))
|
||||
dislikes_result = await self.ap.persistence_mgr.execute_async(dislikes_query)
|
||||
total_dislikes = dislikes_result.scalar() or 0
|
||||
|
||||
# Get total feedback count
|
||||
total_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
|
||||
if conditions:
|
||||
total_query = total_query.where(sqlalchemy.and_(*conditions))
|
||||
total_result = await self.ap.persistence_mgr.execute_async(total_query)
|
||||
total_feedback = total_result.scalar() or 0
|
||||
|
||||
# Calculate satisfaction rate
|
||||
satisfaction_rate = (total_likes / total_feedback * 100) if total_feedback > 0 else 0
|
||||
|
||||
# Get feedback by bot
|
||||
bot_stats_query = (
|
||||
sqlalchemy.select(
|
||||
persistence_monitoring.MonitoringFeedback.bot_id,
|
||||
persistence_monitoring.MonitoringFeedback.bot_name,
|
||||
sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id).label('total'),
|
||||
sqlalchemy.func.sum(
|
||||
sqlalchemy.case(
|
||||
(persistence_monitoring.MonitoringFeedback.feedback_type == 1, 1),
|
||||
else_=0
|
||||
)
|
||||
).label('likes'),
|
||||
sqlalchemy.func.sum(
|
||||
sqlalchemy.case(
|
||||
(persistence_monitoring.MonitoringFeedback.feedback_type == 2, 1),
|
||||
else_=0
|
||||
)
|
||||
).label('dislikes'),
|
||||
)
|
||||
.group_by(
|
||||
persistence_monitoring.MonitoringFeedback.bot_id,
|
||||
persistence_monitoring.MonitoringFeedback.bot_name,
|
||||
)
|
||||
)
|
||||
if conditions:
|
||||
bot_stats_query = bot_stats_query.where(sqlalchemy.and_(*conditions))
|
||||
bot_stats_result = await self.ap.persistence_mgr.execute_async(bot_stats_query)
|
||||
bot_stats = [
|
||||
{
|
||||
'bot_id': row.bot_id,
|
||||
'bot_name': row.bot_name,
|
||||
'total': row.total,
|
||||
'likes': row.likes or 0,
|
||||
'dislikes': row.dislikes or 0,
|
||||
}
|
||||
for row in bot_stats_result.all()
|
||||
]
|
||||
|
||||
return {
|
||||
'total_feedback': total_feedback,
|
||||
'total_likes': total_likes,
|
||||
'total_dislikes': total_dislikes,
|
||||
'satisfaction_rate': round(satisfaction_rate, 2),
|
||||
'by_bot': bot_stats,
|
||||
}
|
||||
|
||||
async def get_feedback_list(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
feedback_type: int | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get feedback list with filters."""
|
||||
conditions = []
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||
if feedback_type is not None:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type)
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||
|
||||
# Get total count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
|
||||
if conditions:
|
||||
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Get feedback list
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
||||
persistence_monitoring.MonitoringFeedback.timestamp.desc()
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
rows = result.all()
|
||||
|
||||
return (
|
||||
[
|
||||
self.ap.persistence_mgr.serialize_model(
|
||||
persistence_monitoring.MonitoringFeedback, row[0] if isinstance(row, tuple) else row
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
total,
|
||||
)
|
||||
|
||||
async def export_feedback(
|
||||
self,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
limit: int = 100000,
|
||||
) -> list[dict]:
|
||||
"""Export feedback as list of dictionaries for CSV conversion."""
|
||||
conditions = []
|
||||
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
||||
persistence_monitoring.MonitoringFeedback.timestamp.desc()
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': row[0].id if isinstance(row, tuple) else row.id,
|
||||
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
|
||||
'feedback_id': row[0].feedback_id if isinstance(row, tuple) else row.feedback_id,
|
||||
'feedback_type': 'like' if (row[0].feedback_type if isinstance(row, tuple) else row.feedback_type) == 1 else 'dislike',
|
||||
'feedback_content': row[0].feedback_content if isinstance(row, tuple) else row.feedback_content,
|
||||
'inaccurate_reasons': row[0].inaccurate_reasons if isinstance(row, tuple) else row.inaccurate_reasons,
|
||||
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
|
||||
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
|
||||
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
|
||||
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
|
||||
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
||||
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
|
||||
'stream_id': row[0].stream_id if isinstance(row, tuple) else row.stream_id,
|
||||
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
|
||||
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -17,9 +17,13 @@ class TaskContext:
|
||||
log: str
|
||||
"""Log"""
|
||||
|
||||
metadata: dict
|
||||
"""Structured metadata for progress reporting"""
|
||||
|
||||
def __init__(self):
|
||||
self.current_action = 'default'
|
||||
self.log = ''
|
||||
self.metadata = {}
|
||||
|
||||
def _log(self, msg: str):
|
||||
self.log += msg + '\n'
|
||||
@@ -38,7 +42,7 @@ class TaskContext:
|
||||
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {'current_action': self.current_action, 'log': self.log}
|
||||
return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata}
|
||||
|
||||
@staticmethod
|
||||
def new() -> TaskContext:
|
||||
@@ -211,9 +215,14 @@ class AsyncTaskManager:
|
||||
def get_tasks_dict(
|
||||
self,
|
||||
type: str = None,
|
||||
kind: str = None,
|
||||
) -> dict:
|
||||
return {
|
||||
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],
|
||||
'tasks': [
|
||||
t.to_dict()
|
||||
for t in self.tasks
|
||||
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
|
||||
],
|
||||
'id_index': TaskWrapper._id_index,
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,23 @@ class I18nString(pydantic.BaseModel):
|
||||
"""英文"""
|
||||
|
||||
zh_Hans: typing.Optional[str] = None
|
||||
"""中文"""
|
||||
"""简体中文"""
|
||||
|
||||
zh_Hant: typing.Optional[str] = None
|
||||
"""繁体中文"""
|
||||
|
||||
ja_JP: typing.Optional[str] = None
|
||||
"""日文"""
|
||||
|
||||
th_TH: typing.Optional[str] = None
|
||||
"""泰文"""
|
||||
|
||||
vi_VN: typing.Optional[str] = None
|
||||
"""越南文"""
|
||||
|
||||
es_ES: typing.Optional[str] = None
|
||||
"""西班牙文"""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转换为字典"""
|
||||
dic = {}
|
||||
@@ -29,8 +41,16 @@ class I18nString(pydantic.BaseModel):
|
||||
dic['en_US'] = self.en_US
|
||||
if self.zh_Hans is not None:
|
||||
dic['zh_Hans'] = self.zh_Hans
|
||||
if self.zh_Hant is not None:
|
||||
dic['zh_Hant'] = self.zh_Hant
|
||||
if self.ja_JP is not None:
|
||||
dic['ja_JP'] = self.ja_JP
|
||||
if self.th_TH is not None:
|
||||
dic['th_TH'] = self.th_TH
|
||||
if self.vi_VN is not None:
|
||||
dic['vi_VN'] = self.vi_VN
|
||||
if self.es_ES is not None:
|
||||
dic['es_ES'] = self.es_ES
|
||||
return dic
|
||||
|
||||
|
||||
|
||||
@@ -106,3 +106,26 @@ class MonitoringEmbeddingCall(Base):
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
|
||||
|
||||
|
||||
class MonitoringFeedback(Base):
|
||||
"""User feedback records (like/dislike) from AI Bot conversations"""
|
||||
|
||||
__tablename__ = 'monitoring_feedback'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
|
||||
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
|
||||
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
|
||||
# Context fields
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(25)
|
||||
class DBMigrateFeedbackStats(migration.DBMigration):
|
||||
"""Add monitoring_feedback table for storing user feedback from AI Bot conversations"""
|
||||
|
||||
async def _table_exists(self, table_name: str) -> bool:
|
||||
"""Check if a table exists (works for both SQLite and PostgreSQL)."""
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
|
||||
).bindparams(table_name=table_name)
|
||||
)
|
||||
return bool(result.scalar())
|
||||
else:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
|
||||
table_name=table_name
|
||||
)
|
||||
)
|
||||
return result.first() is not None
|
||||
|
||||
async def upgrade(self):
|
||||
"""Create monitoring_feedback table."""
|
||||
if await self._table_exists('monitoring_feedback'):
|
||||
self.ap.logger.debug('monitoring_feedback table already exists, skipping migration.')
|
||||
return
|
||||
|
||||
# Create monitoring_feedback table with all columns
|
||||
create_table_sql = '''
|
||||
CREATE TABLE monitoring_feedback (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
timestamp DATETIME NOT NULL,
|
||||
feedback_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
feedback_type INTEGER NOT NULL,
|
||||
feedback_content TEXT,
|
||||
inaccurate_reasons TEXT,
|
||||
bot_id VARCHAR(255),
|
||||
bot_name VARCHAR(255),
|
||||
pipeline_id VARCHAR(255),
|
||||
pipeline_name VARCHAR(255),
|
||||
session_id VARCHAR(255),
|
||||
message_id VARCHAR(255),
|
||||
stream_id VARCHAR(255),
|
||||
user_id VARCHAR(255),
|
||||
platform VARCHAR(255)
|
||||
)
|
||||
'''
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(create_table_sql))
|
||||
|
||||
# Create indexes
|
||||
indexes = [
|
||||
'CREATE INDEX ix_monitoring_feedback_timestamp ON monitoring_feedback (timestamp)',
|
||||
'CREATE UNIQUE INDEX ix_monitoring_feedback_feedback_id ON monitoring_feedback (feedback_id)',
|
||||
'CREATE INDEX ix_monitoring_feedback_bot_id ON monitoring_feedback (bot_id)',
|
||||
'CREATE INDEX ix_monitoring_feedback_pipeline_id ON monitoring_feedback (pipeline_id)',
|
||||
'CREATE INDEX ix_monitoring_feedback_session_id ON monitoring_feedback (session_id)',
|
||||
'CREATE INDEX ix_monitoring_feedback_message_id ON monitoring_feedback (message_id)',
|
||||
'CREATE INDEX ix_monitoring_feedback_stream_id ON monitoring_feedback (stream_id)',
|
||||
]
|
||||
for index_sql in indexes:
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(index_sql))
|
||||
|
||||
self.ap.logger.info('Created monitoring_feedback table with indexes.')
|
||||
|
||||
async def downgrade(self):
|
||||
"""Drop monitoring_feedback table."""
|
||||
if await self._table_exists('monitoring_feedback'):
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('DROP TABLE monitoring_feedback')
|
||||
)
|
||||
self.ap.logger.info('Dropped monitoring_feedback table.')
|
||||
@@ -353,3 +353,62 @@ class LLMCallMonitor:
|
||||
)
|
||||
|
||||
return False # Don't suppress exceptions
|
||||
|
||||
|
||||
class FeedbackMonitor:
|
||||
"""Helper for recording user feedback from AI Bot conversations"""
|
||||
|
||||
@staticmethod
|
||||
async def record_feedback(
|
||||
ap: app.Application,
|
||||
feedback_id: str,
|
||||
feedback_type: int,
|
||||
feedback_content: str | None = None,
|
||||
inaccurate_reasons: list[str] | None = None,
|
||||
bot_id: str | None = None,
|
||||
bot_name: str | None = None,
|
||||
pipeline_id: str | None = None,
|
||||
pipeline_name: str | None = None,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
stream_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
platform: str = 'wecom',
|
||||
):
|
||||
"""Record user feedback (like/dislike) from AI Bot conversation.
|
||||
|
||||
Args:
|
||||
ap: Application instance
|
||||
feedback_id: Unique feedback identifier from platform
|
||||
feedback_type: 1 = like, 2 = dislike
|
||||
feedback_content: Optional user feedback text
|
||||
inaccurate_reasons: List of reasons for inaccurate response
|
||||
bot_id: Bot UUID
|
||||
bot_name: Bot name
|
||||
pipeline_id: Pipeline UUID
|
||||
pipeline_name: Pipeline name
|
||||
session_id: Session ID
|
||||
message_id: Message ID
|
||||
stream_id: Stream ID
|
||||
user_id: User ID
|
||||
platform: Platform name (default: wecom)
|
||||
"""
|
||||
try:
|
||||
await ap.monitoring_service.record_feedback(
|
||||
feedback_id=feedback_id,
|
||||
feedback_type=feedback_type,
|
||||
feedback_content=feedback_content,
|
||||
inaccurate_reasons=inaccurate_reasons,
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
session_id=session_id,
|
||||
message_id=message_id,
|
||||
stream_id=stream_id,
|
||||
user_id=user_id,
|
||||
platform=platform,
|
||||
)
|
||||
ap.logger.info(f'Recorded feedback: feedback_id={feedback_id}, type={feedback_type}')
|
||||
except Exception as e:
|
||||
ap.logger.error(f'Failed to record feedback: {e}')
|
||||
|
||||
@@ -9,6 +9,7 @@ from ..core import app, entities as core_entities, taskmgr
|
||||
from ..discover import engine
|
||||
|
||||
from ..entity.persistence import bot as persistence_bot
|
||||
from ..entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
from ..entity.errors import platform as platform_errors
|
||||
|
||||
@@ -267,12 +268,35 @@ class PlatformManager:
|
||||
adapter_inst = self.adapter_dict[bot_entity.adapter](
|
||||
bot_entity.adapter_config,
|
||||
logger,
|
||||
ap=self.ap,
|
||||
)
|
||||
|
||||
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
||||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||||
adapter_inst.set_bot_uuid(bot_entity.uuid)
|
||||
|
||||
# 如果 adapter 支持 set_bot_info 方法,设置 bot 信息(用于监控记录)
|
||||
if hasattr(adapter_inst, 'set_bot_info'):
|
||||
pipeline_name = ''
|
||||
if bot_entity.use_pipeline_uuid:
|
||||
try:
|
||||
pipeline_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == bot_entity.use_pipeline_uuid
|
||||
)
|
||||
)
|
||||
pipeline_row = pipeline_result.first()
|
||||
if pipeline_row:
|
||||
pipeline_name = pipeline_row[0]
|
||||
except Exception:
|
||||
pass
|
||||
adapter_inst.set_bot_info(
|
||||
bot_id=bot_entity.uuid,
|
||||
bot_name=bot_entity.name,
|
||||
pipeline_id=bot_entity.use_pipeline_uuid or '',
|
||||
pipeline_name=pipeline_name,
|
||||
)
|
||||
|
||||
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
|
||||
|
||||
await runtime_bot.initialize()
|
||||
|
||||
@@ -5,19 +5,25 @@ metadata:
|
||||
label:
|
||||
en_US: OneBot v11
|
||||
zh_Hans: OneBot v11
|
||||
zh_Hant: OneBot v11
|
||||
description:
|
||||
en_US: OneBot v11 Adapter
|
||||
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
|
||||
en_US: OneBot v11 Adapter, used for QQ bots
|
||||
zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式
|
||||
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
|
||||
icon: onebot.png
|
||||
spec:
|
||||
categories:
|
||||
- protocol
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 主机
|
||||
zh_Hant: 主機
|
||||
description:
|
||||
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
||||
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||
zh_Hant: OneBot v11 監聽的反向 WS 主機,除非你知道自己在做什麼,否則請填 0.0.0.0
|
||||
type: string
|
||||
required: true
|
||||
default: 0.0.0.0
|
||||
@@ -25,9 +31,11 @@ spec:
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 端口
|
||||
zh_Hant: 連接埠
|
||||
description:
|
||||
en_US: Port
|
||||
zh_Hans: 监听的端口
|
||||
zh_Hant: 監聽的連接埠
|
||||
type: integer
|
||||
required: true
|
||||
default: 2280
|
||||
@@ -35,9 +43,11 @@ spec:
|
||||
label:
|
||||
en_US: Access Token
|
||||
zh_Hans: 访问令牌
|
||||
zh_Hant: 存取令牌
|
||||
description:
|
||||
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
|
||||
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
|
||||
zh_Hant: 自訂的與協定端的連線令牌,若協定端未設定,則不填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
@@ -139,7 +139,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片
|
||||
)
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
|
||||
required_keys = [
|
||||
'client_id',
|
||||
'client_secret',
|
||||
|
||||
@@ -5,16 +5,21 @@ metadata:
|
||||
label:
|
||||
en_US: DingTalk
|
||||
zh_Hans: 钉钉
|
||||
zh_Hant: 釘釘
|
||||
description:
|
||||
en_US: DingTalk Adapter
|
||||
zh_Hans: 钉钉适配器,请查看文档了解使用方式
|
||||
zh_Hant: 釘釘適配器,請查看文件了解使用方式
|
||||
icon: dingtalk.svg
|
||||
spec:
|
||||
categories:
|
||||
- china
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
en_US: Client ID
|
||||
zh_Hans: 客户端ID
|
||||
zh_Hant: 用戶端ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +27,7 @@ spec:
|
||||
label:
|
||||
en_US: Client Secret
|
||||
zh_Hans: 客户端密钥
|
||||
zh_Hant: 用戶端密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +35,7 @@ spec:
|
||||
label:
|
||||
en_US: Robot Code
|
||||
zh_Hans: 机器人代码
|
||||
zh_Hant: 機器人代碼
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +43,7 @@ spec:
|
||||
label:
|
||||
en_US: Robot Name
|
||||
zh_Hans: 机器人名称
|
||||
zh_Hant: 機器人名稱
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,6 +51,7 @@ spec:
|
||||
label:
|
||||
en_US: Markdown Card
|
||||
zh_Hans: 是否使用 Markdown 卡片
|
||||
zh_Hant: 是否使用 Markdown 卡片
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
@@ -50,9 +59,11 @@ spec:
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用钉钉卡片流式回复模式
|
||||
zh_Hant: 啟用釘釘卡片串流回覆模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
@@ -60,6 +71,7 @@ spec:
|
||||
label:
|
||||
en_US: Card Auto Layout
|
||||
zh_Hans: 卡片宽屏自动布局
|
||||
zh_Hant: 卡片寬螢幕自動佈局
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
@@ -67,6 +79,7 @@ spec:
|
||||
label:
|
||||
en_US: card template id
|
||||
zh_Hans: 卡片模板ID
|
||||
zh_Hant: 卡片範本ID
|
||||
type: string
|
||||
required: true
|
||||
default: "填写你的卡片template_id"
|
||||
|
||||
@@ -5,16 +5,34 @@ metadata:
|
||||
label:
|
||||
en_US: Discord
|
||||
zh_Hans: Discord
|
||||
zh_Hant: Discord
|
||||
ja_JP: Discord
|
||||
th_TH: Discord
|
||||
vi_VN: Discord
|
||||
es_ES: Discord
|
||||
description:
|
||||
en_US: Discord Adapter
|
||||
zh_Hans: Discord 适配器,请查看文档了解使用方式
|
||||
zh_Hans: Discord 适配器,需要可连接 Discord 服务器的网络环境
|
||||
zh_Hant: Discord 適配器,需要可連線 Discord 伺服器的網路環境
|
||||
ja_JP: Discord アダプター、Discord サーバーに接続可能なネットワーク環境が必要です
|
||||
th_TH: อะแดปเตอร์ Discord ต้องการสภาพแวดล้อมเครือข่ายที่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Discord ได้
|
||||
vi_VN: Bộ điều hợp Discord, cần môi trường mạng có thể kết nối với máy chủ Discord
|
||||
es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord
|
||||
icon: discord.svg
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
- global
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
en_US: Client ID
|
||||
zh_Hans: 客户端ID
|
||||
zh_Hant: 用戶端ID
|
||||
ja_JP: クライアント ID
|
||||
th_TH: รหัสไคลเอนต์
|
||||
vi_VN: ID khách hàng
|
||||
es_ES: ID de cliente
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +40,11 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
ja_JP: トークン
|
||||
th_TH: โทเค็น
|
||||
vi_VN: Mã thông báo
|
||||
es_ES: Token
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,16 +5,21 @@ metadata:
|
||||
label:
|
||||
en_US: KOOK
|
||||
zh_Hans: KOOK
|
||||
zh_Hant: KOOK
|
||||
description:
|
||||
en_US: KOOK Adapter (formerly KaiHeiLa)
|
||||
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
||||
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
|
||||
icon: kook.png
|
||||
spec:
|
||||
categories:
|
||||
- china
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Bot Token
|
||||
zh_Hans: 机器人令牌
|
||||
zh_Hant: 機器人令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,16 +5,26 @@ metadata:
|
||||
label:
|
||||
en_US: Lark
|
||||
zh_Hans: 飞书
|
||||
zh_Hant: 飛書
|
||||
ja_JP: Lark
|
||||
description:
|
||||
en_US: Lark Adapter
|
||||
zh_Hans: 飞书适配器,请查看文档了解使用方式
|
||||
en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details.
|
||||
zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
||||
zh_Hant: 飛書適配器,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
||||
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
|
||||
icon: lark.svg
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
- china
|
||||
- global
|
||||
config:
|
||||
- name: app_id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
zh_Hant: 應用ID
|
||||
ja_JP: アプリ ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +32,8 @@ spec:
|
||||
label:
|
||||
en_US: App Secret
|
||||
zh_Hans: 应用密钥
|
||||
zh_Hant: 應用密鑰
|
||||
ja_JP: アプリシークレット
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,9 +41,13 @@ spec:
|
||||
label:
|
||||
en_US: Bot Name
|
||||
zh_Hans: 机器人名称
|
||||
zh_Hant: 機器人名稱
|
||||
ja_JP: ボット名
|
||||
description:
|
||||
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
|
||||
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
|
||||
zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常接收訊息
|
||||
ja_JP: Lark のボット名と一致する必要があります。一致しない場合、グループ内でメッセージを受信できません
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -39,29 +55,63 @@ spec:
|
||||
label:
|
||||
en_US: Enable Webhook Mode
|
||||
zh_Hans: 启用Webhook模式
|
||||
zh_Hant: 啟用 Webhook 模式
|
||||
ja_JP: Webhook モードを有効化
|
||||
description:
|
||||
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
||||
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
|
||||
ja_JP: 有効にすると、ボットは Webhook モードでメッセージを受信します。無効の場合は WS 長期接続モードを使用します
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
ja_JP: Webhook コールバック URL
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your Lark app's webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到飞书应用的 Webhook 配置中
|
||||
zh_Hant: 複製此地址並貼到飛書應用的 Webhook 設定中
|
||||
ja_JP: この URL をコピーして Lark アプリの Webhook 設定に貼り付けてください
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
show_if:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
value: true
|
||||
- name: encrypt-key
|
||||
label:
|
||||
en_US: Encrypt Key
|
||||
zh_Hans: 加密密钥
|
||||
zh_Hant: 加密密鑰
|
||||
ja_JP: 暗号化キー
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
|
||||
zh_Hant: 僅在啟用 Webhook 模式時有效,請填寫加密密鑰
|
||||
ja_JP: Webhook モードが有効な場合にのみ有効です。暗号化キーを入力してください
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
show_if:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
value: true
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用飞书流式回复模式
|
||||
zh_Hant: 啟用飛書串流回覆模式
|
||||
ja_JP: ストリーミング返信モードを有効化
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容
|
||||
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
@@ -69,28 +119,40 @@ spec:
|
||||
label:
|
||||
en_US: App Type
|
||||
zh_Hans: 应用类型
|
||||
zh_Hant: 應用類型
|
||||
ja_JP: アプリタイプ
|
||||
description:
|
||||
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
||||
type: select
|
||||
options:
|
||||
- name: self
|
||||
label:
|
||||
en_US: Self-built Application
|
||||
zh_Hans: 自建应用
|
||||
zh_Hant: 自建應用
|
||||
ja_JP: カスタムアプリ
|
||||
- name: isv
|
||||
label:
|
||||
en_US: Store Application
|
||||
zh_Hans: 商店应用
|
||||
zh_Hant: 商店應用
|
||||
ja_JP: ストアアプリ
|
||||
required: false
|
||||
default: self
|
||||
- name: bot_added_welcome
|
||||
label:
|
||||
en_US: Bot Welcome Message
|
||||
zh_Hans: 机器人进群欢迎语
|
||||
zh_Hant: 機器人進群歡迎語
|
||||
ja_JP: ボット参加時のウェルカムメッセージ
|
||||
description:
|
||||
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
||||
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
||||
zh_Hant: 機器人進群歡迎語,支援 Markdown 格式
|
||||
ja_JP: ボットがグループに追加された際のウェルカムメッセージ。Markdown 形式に対応しています
|
||||
type: text
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
@@ -136,7 +136,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
|
||||
configuration = Configuration(access_token=config['channel_access_token'])
|
||||
line_webhook = WebhookHandler(config['channel_secret'])
|
||||
parser = WebhookParser(config['channel_secret'])
|
||||
|
||||
@@ -5,20 +5,52 @@ metadata:
|
||||
label:
|
||||
en_US: LINE
|
||||
zh_Hans: LINE
|
||||
zh_Hant: LINE
|
||||
th_TH: LINE
|
||||
vi_VN: LINE
|
||||
es_ES: LINE
|
||||
description:
|
||||
en_US: LINE Adapter
|
||||
zh_Hans: LINE适配器,请查看文档了解使用方式
|
||||
ja_JP: LINEアダプター、ドキュメントを参照してください
|
||||
zh_Hant: LINE適配器,請查看文檔了解使用方式
|
||||
en_US: LINE Adapter, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details
|
||||
zh_Hans: LINE适配器,需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式
|
||||
zh_Hant: LINE 適配器,需要公網地址以接收 LINE 訊息推送,請查看文件了解使用方式
|
||||
ja_JP: LINEアダプター、LINEのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
|
||||
th_TH: อะแดปเตอร์ LINE ต้องการ URL สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก LINE โปรดดูเอกสารประกอบสำหรับรายละเอียดการใช้งาน
|
||||
vi_VN: Bộ điều hợp LINE, cần URL công cộng để nhận thông báo tin nhắn LINE, vui lòng xem tài liệu để biết chi tiết cách sử dụng
|
||||
es_ES: Adaptador de LINE, requiere una URL pública para recibir notificaciones de mensajes de LINE, consulte la documentación para obtener detalles de uso
|
||||
icon: line.png
|
||||
spec:
|
||||
categories:
|
||||
- global
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
ja_JP: Webhook コールバック URL
|
||||
zh_Hant: Webhook 回調地址
|
||||
th_TH: URL การเรียกกลับ Webhook
|
||||
vi_VN: URL gọi lại Webhook
|
||||
es_ES: URL de devolución de llamada Webhook
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your LINE channel's webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到 LINE 频道的 Webhook 配置中
|
||||
ja_JP: この URL をコピーして LINE チャンネルの Webhook 設定に貼り付けてください
|
||||
zh_Hant: 複製此地址並貼到 LINE 頻道的 Webhook 設定中
|
||||
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่า Webhook ของช่อง LINE ของคุณ
|
||||
vi_VN: Sao chép URL này và dán vào cấu hình webhook của kênh LINE của bạn
|
||||
es_ES: Copie esta URL y péguela en la configuración de webhook de su canal LINE
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: channel_access_token
|
||||
label:
|
||||
en_US: Channel access token
|
||||
zh_Hans: 频道访问令牌
|
||||
ja_JP: チャンネルアクセストークン
|
||||
zh_Hant: 頻道訪問令牌
|
||||
zh_Hant: 頻道存取令牌
|
||||
th_TH: โทเค็นการเข้าถึงช่อง
|
||||
vi_VN: Mã truy cập kênh
|
||||
es_ES: Token de acceso del canal
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -27,12 +59,18 @@ spec:
|
||||
en_US: Channel secret
|
||||
zh_Hans: 消息密钥
|
||||
ja_JP: チャンネルシークレット
|
||||
zh_Hant: 消息密钥
|
||||
zh_Hant: 訊息密鑰
|
||||
th_TH: รหัสลับช่อง
|
||||
vi_VN: Khóa bí mật kênh
|
||||
es_ES: Secreto del canal
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||
zh_Hans: 请填写加密密钥
|
||||
ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください
|
||||
zh_Hant: 請填寫加密密钥
|
||||
zh_Hant: 請填寫加密密鑰
|
||||
th_TH: กรุณากรอกคีย์เข้ารหัส
|
||||
vi_VN: Vui lòng điền khóa mã hóa
|
||||
es_ES: Por favor, introduzca la clave de cifrado
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -60,7 +60,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
||||
bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True)
|
||||
bot_uuid: str = None
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
|
||||
# 校验必填项
|
||||
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
|
||||
missing_keys = [k for k in required_keys if k not in config]
|
||||
|
||||
@@ -5,23 +5,40 @@ metadata:
|
||||
label:
|
||||
en_US: Official Account
|
||||
zh_Hans: 微信公众号
|
||||
zh_Hant: 微信公眾號
|
||||
description:
|
||||
en_US: Official Account Adapter
|
||||
zh_Hans: 微信公众号适配器,请查看文档了解使用方式
|
||||
zh_Hans: 微信公众号适配器,需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||
icon: officialaccount.png
|
||||
spec:
|
||||
categories:
|
||||
- china
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your Official Account webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到微信公众号的 Webhook 配置中
|
||||
zh_Hant: 複製此地址並貼到微信公眾號的 Webhook 設定中
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
zh_Hant: 令牌
|
||||
required: true
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
zh_Hant: 訊息加解密密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +46,7 @@ spec:
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
zh_Hant: 應用ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +54,7 @@ spec:
|
||||
label:
|
||||
en_US: App Secret
|
||||
zh_Hans: 应用密钥
|
||||
zh_Hant: 應用密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,6 +62,7 @@ spec:
|
||||
label:
|
||||
en_US: Mode
|
||||
zh_Hans: 接入模式
|
||||
zh_Hant: 接入模式
|
||||
type: string
|
||||
required: true
|
||||
default: "drop"
|
||||
@@ -50,6 +70,7 @@ spec:
|
||||
label:
|
||||
en_US: Loading Message
|
||||
zh_Hans: 加载消息
|
||||
zh_Hant: 載入訊息
|
||||
type: string
|
||||
required: true
|
||||
default: "AI正在思考中,请发送任意内容获取回复。"
|
||||
@@ -57,9 +78,11 @@ spec:
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
zh_Hant: API 基礎 URL
|
||||
description:
|
||||
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
|
||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项
|
||||
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取微信公眾號 API,可根據文件修改此項
|
||||
type: string
|
||||
required: false
|
||||
default: "https://api.weixin.qq.com"
|
||||
|
||||
@@ -4,20 +4,27 @@ metadata:
|
||||
name: openclaw-weixin
|
||||
label:
|
||||
en_US: OpenClaw WeChat
|
||||
zh_Hans: OpenClaw 微信
|
||||
zh_Hans: 个人微信机器人
|
||||
zh_Hant: 個人微信機器人
|
||||
description:
|
||||
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
|
||||
zh_Hans: OpenClaw 微信适配器,通过扫码登录支持个人微信
|
||||
zh_Hans: 微信官方个人助手,扫码即可登录使用
|
||||
zh_Hant: 微信官方個人助手,掃碼即可登入使用
|
||||
icon: wechat.png
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
- china
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础地址
|
||||
zh_Hant: API 基礎地址
|
||||
description:
|
||||
en_US: The base URL of the OpenClaw WeChat backend API
|
||||
zh_Hans: OpenClaw 微信后端 API 的基础地址
|
||||
zh_Hant: OpenClaw 微信後端 API 的基礎地址
|
||||
type: string
|
||||
required: true
|
||||
default: "https://ilinkai.weixin.qq.com"
|
||||
@@ -25,9 +32,11 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
description:
|
||||
en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup.
|
||||
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。留空则启动时自动触发扫码登录。
|
||||
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。请留空并保存,将在启动时输出二维码到日志,扫码后即可自动登录。
|
||||
zh_Hant: 掃碼登入授權後取得的 Bearer 令牌。請留空並儲存,將在啟動時輸出 QR Code 到日誌,掃碼後即可自動登入。
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
@@ -35,9 +44,11 @@ spec:
|
||||
label:
|
||||
en_US: Account ID
|
||||
zh_Hans: 账号标识
|
||||
zh_Hant: 帳號標識
|
||||
description:
|
||||
en_US: A label for this WeChat account (used for display purposes)
|
||||
zh_Hans: 此微信账号的标识(用于显示)
|
||||
zh_Hant: 此微信帳號的標識(用於顯示)
|
||||
type: string
|
||||
required: false
|
||||
default: "openclaw-weixin"
|
||||
@@ -45,9 +56,11 @@ spec:
|
||||
label:
|
||||
en_US: Poll Timeout (seconds)
|
||||
zh_Hans: 轮询超时(秒)
|
||||
zh_Hant: 輪詢逾時(秒)
|
||||
description:
|
||||
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
|
||||
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
|
||||
zh_Hant: getUpdates 長輪詢逾時時間,伺服端最多持有請求的時長
|
||||
type: integer
|
||||
required: false
|
||||
default: 35
|
||||
|
||||
@@ -132,7 +132,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
|
||||
bot = QQOfficialClient(
|
||||
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
|
||||
)
|
||||
|
||||
@@ -5,16 +5,33 @@ metadata:
|
||||
label:
|
||||
en_US: QQ Official API
|
||||
zh_Hans: QQ 官方 API
|
||||
zh_Hant: QQ 官方 API
|
||||
description:
|
||||
en_US: QQ Official API (Webhook)
|
||||
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
|
||||
zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||
icon: qqofficial.svg
|
||||
spec:
|
||||
categories:
|
||||
- china
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
||||
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: appid
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
zh_Hant: 應用ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +39,7 @@ spec:
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
zh_Hant: 密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +47,7 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,36 +5,66 @@ metadata:
|
||||
label:
|
||||
en_US: Satori
|
||||
zh_Hans: Satori
|
||||
zh_Hant: Satori
|
||||
th_TH: Satori
|
||||
vi_VN: Satori
|
||||
es_ES: Satori
|
||||
description:
|
||||
en_US: SatoriAdapter
|
||||
zh_Hans: 古明地觉协议适配器
|
||||
zh_Hans: Satori 协议适配器,支持多种平台的接入,请查看文档了解使用方式
|
||||
zh_Hant: Satori 協定適配器,支援多種平台的接入,請查看文件了解使用方式
|
||||
th_TH: อะแดปเตอร์โปรโตคอล Satori รองรับการเชื่อมต่อหลายแพลตฟอร์ม โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||
vi_VN: Bộ điều hợp giao thức Satori, hỗ trợ kết nối nhiều nền tảng, vui lòng xem tài liệu để biết cách sử dụng
|
||||
es_ES: Adaptador del protocolo Satori, soporta acceso a múltiples plataformas, consulte la documentación para obtener instrucciones de uso
|
||||
icon: satori.png
|
||||
spec:
|
||||
categories:
|
||||
- protocol
|
||||
config:
|
||||
- name: platform
|
||||
label:
|
||||
en_US: Platform
|
||||
zh_Hans: 平台名称
|
||||
zh_Hant: 平台名稱
|
||||
th_TH: ชื่อแพลตฟอร์ม
|
||||
vi_VN: Tên nền tảng
|
||||
es_ES: Nombre de la plataforma
|
||||
type: string
|
||||
required: true
|
||||
default: "llonebot"
|
||||
description:
|
||||
en_US: The platform name (e.g., llonebot, discord, telegram)
|
||||
zh_Hans: 平台名称(如 llonebot, discord, telegram)
|
||||
zh_Hant: 平台名稱(如 llonebot、discord、telegram)
|
||||
th_TH: ชื่อแพลตฟอร์ม (เช่น llonebot, discord, telegram)
|
||||
vi_VN: "Tên nền tảng (ví dụ: llonebot, discord, telegram)"
|
||||
es_ES: El nombre de la plataforma (p. ej., llonebot, discord, telegram)
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 主机地址
|
||||
zh_Hant: 主機地址
|
||||
th_TH: ที่อยู่โฮสต์
|
||||
vi_VN: Địa chỉ máy chủ
|
||||
es_ES: Dirección del host
|
||||
type: string
|
||||
required: true
|
||||
default: "127.0.0.1"
|
||||
description:
|
||||
en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)
|
||||
zh_Hans: LLOneBot Satori服务器的主机地址(如 127.0.0.1, localhost, 192.168.1.100)
|
||||
zh_Hant: LLOneBot Satori 伺服器的主機地址(如 127.0.0.1、localhost、192.168.1.100)
|
||||
th_TH: ที่อยู่โฮสต์ของเซิร์ฟเวอร์ LLOneBot Satori (เช่น 127.0.0.1, localhost, 192.168.1.100)
|
||||
vi_VN: "Địa chỉ máy chủ LLOneBot Satori (ví dụ: 127.0.0.1, localhost, 192.168.1.100)"
|
||||
es_ES: La dirección del host del servidor LLOneBot Satori (p. ej., 127.0.0.1, localhost, 192.168.1.100)
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
zh_Hant: 監聽連接埠
|
||||
th_TH: พอร์ต
|
||||
vi_VN: Cổng
|
||||
es_ES: Puerto
|
||||
type: integer
|
||||
required: true
|
||||
default: 5600
|
||||
@@ -42,6 +72,10 @@ spec:
|
||||
label:
|
||||
en_US: Satori API Endpoint
|
||||
zh_Hans: Satori API 终结点
|
||||
zh_Hant: Satori API 端點
|
||||
th_TH: จุดปลาย Satori API
|
||||
vi_VN: Điểm cuối Satori API
|
||||
es_ES: Punto de acceso de la API Satori
|
||||
type: string
|
||||
required: true
|
||||
default: "http://localhost:5600/v1"
|
||||
@@ -49,6 +83,10 @@ spec:
|
||||
label:
|
||||
en_US: Satori WebSocket Endpoint
|
||||
zh_Hans: Satori WebSocket 终结点
|
||||
zh_Hant: Satori WebSocket 端點
|
||||
th_TH: จุดปลาย Satori WebSocket
|
||||
vi_VN: Điểm cuối Satori WebSocket
|
||||
es_ES: Punto de acceso WebSocket de Satori
|
||||
type: string
|
||||
required: true
|
||||
default: "ws://localhost:5600/v1/events"
|
||||
@@ -56,6 +94,10 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
th_TH: โทเค็น
|
||||
vi_VN: Mã thông báo
|
||||
es_ES: Token
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -99,7 +99,7 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
event_converter: SlackEventConverter = SlackEventConverter()
|
||||
config: dict
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
|
||||
required_keys = [
|
||||
'bot_token',
|
||||
'signing_secret',
|
||||
|
||||
@@ -5,16 +5,54 @@ metadata:
|
||||
label:
|
||||
en_US: Slack
|
||||
zh_Hans: Slack
|
||||
zh_Hant: Slack
|
||||
ja_JP: Slack
|
||||
th_TH: Slack
|
||||
vi_VN: Slack
|
||||
es_ES: Slack
|
||||
description:
|
||||
en_US: Slack Adapter
|
||||
zh_Hans: Slack 适配器,请查看文档了解使用方式
|
||||
zh_Hans: Slack 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式
|
||||
zh_Hant: Slack 適配器,需要公網地址以接收 Slack 訊息推送,請查看文件了解使用方式
|
||||
ja_JP: Slack アダプター、Slackのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
|
||||
th_TH: อะแดปเตอร์ Slack ต้องการที่อยู่สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก Slack โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||
vi_VN: Bộ điều hợp Slack, cần địa chỉ công cộng để nhận thông báo tin nhắn từ Slack, vui lòng xem tài liệu để biết cách sử dụng
|
||||
es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso
|
||||
icon: slack.png
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
- global
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
ja_JP: Webhook コールバック URL
|
||||
th_TH: URL การเรียกกลับ Webhook
|
||||
vi_VN: URL gọi lại Webhook
|
||||
es_ES: URL de devolución de llamada Webhook
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your Slack app's event subscription configuration
|
||||
zh_Hans: 复制此地址并粘贴到 Slack 应用的事件订阅配置中
|
||||
zh_Hant: 複製此地址並貼到 Slack 應用的事件訂閱設定中
|
||||
ja_JP: この URL をコピーして Slack アプリのイベントサブスクリプション設定に貼り付けてください
|
||||
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่าการสมัครรับเหตุการณ์ของแอป Slack ของคุณ
|
||||
vi_VN: Sao chép URL này và dán vào cấu hình đăng ký sự kiện của ứng dụng Slack của bạn
|
||||
es_ES: Copie esta URL y péguela en la configuración de suscripción de eventos de su aplicación Slack
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: bot_token
|
||||
label:
|
||||
en_US: Bot Token
|
||||
zh_Hans: 机器人令牌
|
||||
zh_Hant: 機器人令牌
|
||||
ja_JP: ボットトークン
|
||||
th_TH: โทเค็นบอท
|
||||
vi_VN: Mã thông báo Bot
|
||||
es_ES: Token del bot
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +60,11 @@ spec:
|
||||
label:
|
||||
en_US: signing_secret
|
||||
zh_Hans: 密钥
|
||||
zh_Hant: 密鑰
|
||||
ja_JP: 署名シークレット
|
||||
th_TH: คีย์ลายเซ็น
|
||||
vi_VN: Khóa ký
|
||||
es_ES: Secreto de firma
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,23 +5,46 @@ metadata:
|
||||
label:
|
||||
en_US: Telegram
|
||||
zh_Hans: 电报
|
||||
zh_Hant: Telegram
|
||||
ja_JP: Telegram
|
||||
th_TH: Telegram
|
||||
vi_VN: Telegram
|
||||
es_ES: Telegram
|
||||
description:
|
||||
en_US: Telegram Adapter
|
||||
zh_Hans: 电报适配器,请查看文档了解使用方式
|
||||
zh_Hans: Telegram 适配器,请查看文档了解使用方式
|
||||
zh_Hant: Telegram 適配器,請查看文件了解使用方式
|
||||
ja_JP: Telegram アダプター。使用方法の詳細については、ドキュメントを参照してください。
|
||||
th_TH: อะแดปเตอร์ Telegram โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||
vi_VN: Bộ điều hợp Telegram, vui lòng xem tài liệu để biết cách sử dụng
|
||||
es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso
|
||||
icon: telegram.svg
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
- global
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
ja_JP: トークン
|
||||
th_TH: โทเค็น
|
||||
vi_VN: Mã thông báo
|
||||
es_ES: Token
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
default: "token_from_botfather"
|
||||
- name: markdown_card
|
||||
label:
|
||||
en_US: Markdown Card
|
||||
zh_Hans: 是否使用 Markdown 卡片
|
||||
zh_Hant: 是否使用 Markdown 卡片
|
||||
ja_JP: Markdown カードを使用
|
||||
th_TH: การ์ด Markdown
|
||||
vi_VN: Thẻ Markdown
|
||||
es_ES: Tarjeta Markdown
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
@@ -29,9 +52,19 @@ spec:
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用电报流式回复模式
|
||||
zh_Hant: 啟用 Telegram 串流回覆模式
|
||||
ja_JP: ストリーミング返信モードを有効化
|
||||
th_TH: เปิดใช้งานโหมดตอบกลับแบบสตรีม
|
||||
vi_VN: Bật chế độ trả lời trực tuyến
|
||||
es_ES: Habilitar modo de respuesta en streaming
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of telegram reply mode
|
||||
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用 Telegram 串流方式來回覆內容
|
||||
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||
th_TH: หากเปิดใช้งาน บอทจะใช้โหมดสตรีมของ Telegram ในการตอบกลับ
|
||||
vi_VN: Nếu bật, bot sẽ sử dụng chế độ trả lời trực tuyến của Telegram
|
||||
es_ES: Si está habilitado, el bot usará el modo de respuesta en streaming de Telegram
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -5,11 +5,21 @@ metadata:
|
||||
label:
|
||||
en_US: "WebSocket Chat"
|
||||
zh_Hans: "WebSocket 聊天"
|
||||
zh_Hant: "WebSocket 聊天"
|
||||
th_TH: "แชท WebSocket"
|
||||
vi_VN: "Trò chuyện WebSocket"
|
||||
es_ES: "Chat WebSocket"
|
||||
description:
|
||||
en_US: "WebSocket adapter for bidirectional real-time communication"
|
||||
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
|
||||
zh_Hant: "用於雙向即時通訊的 WebSocket 適配器"
|
||||
th_TH: "อะแดปเตอร์ WebSocket สำหรับการสื่อสารแบบเรียลไทม์สองทิศทาง"
|
||||
vi_VN: "Bộ điều hợp WebSocket cho giao tiếp thời gian thực hai chiều"
|
||||
es_ES: "Adaptador WebSocket para comunicación bidireccional en tiempo real"
|
||||
icon: ""
|
||||
spec:
|
||||
categories:
|
||||
- protocol
|
||||
config: []
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -539,7 +539,7 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
|
||||
quart_app = quart.Quart(__name__)
|
||||
|
||||
message_converter = WeChatPadMessageConverter(config, logger)
|
||||
|
||||
@@ -4,17 +4,22 @@ metadata:
|
||||
name: wechatpad
|
||||
label:
|
||||
en_US: WeChatPad
|
||||
zh_CN: WeChatPad(个人微信ipad)
|
||||
zh_Hans: WeChatPad(个人微信ipad)
|
||||
zh_Hant: WeChatPad(個人微信iPad)
|
||||
description:
|
||||
en_US: WeChatPad Adapter
|
||||
zh_CN: WeChatPad 适配器
|
||||
zh_Hans: WeChatPad 适配器,基于WeChatPad的个人微信解决方案,请查看文档了解使用方式
|
||||
zh_Hant: WeChatPad 適配器,基於 WeChatPad 的個人微信解決方案,請查看文件了解使用方式
|
||||
icon: wechatpad.png
|
||||
spec:
|
||||
categories:
|
||||
- china
|
||||
config:
|
||||
- name: wechatpad_url
|
||||
label:
|
||||
en_US: WeChatPad ERL
|
||||
zh_CN: WeChatPad URL
|
||||
zh_Hant: WeChatPad URL
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +27,7 @@ spec:
|
||||
label:
|
||||
en_US: WeChatPad_Ws
|
||||
zh_CN: WeChatPad_Ws
|
||||
zh_Hant: WeChatPad_Ws
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +35,7 @@ spec:
|
||||
label:
|
||||
en_US: Admin_Key
|
||||
zh_CN: 管理员密匙
|
||||
zh_Hant: 管理員密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +43,7 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_CN: 令牌
|
||||
zh_Hant: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,6 +51,7 @@ spec:
|
||||
label:
|
||||
en_US: wxid
|
||||
zh_CN: wxid
|
||||
zh_Hant: wxid
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -206,7 +206,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
config: dict
|
||||
bot_uuid: str = None
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
def __init__(self, config: dict, logger: EventLogger, ap=None, **kwargs):
|
||||
# 校验必填项
|
||||
required_keys = [
|
||||
'corpid',
|
||||
|
||||
@@ -5,16 +5,34 @@ metadata:
|
||||
label:
|
||||
en_US: WeCom
|
||||
zh_Hans: 企业微信
|
||||
zh_Hant: 企業微信
|
||||
description:
|
||||
en_US: WeCom Adapter
|
||||
zh_Hans: 企业微信适配器,请查看文档了解使用方式
|
||||
zh_Hans: 企业微信内部机器人,请查看文档了解使用方式
|
||||
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
|
||||
icon: wecom.png
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
- china
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your WeCom app's webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到企业微信应用的 Webhook 配置中
|
||||
zh_Hant: 複製此地址並貼到企業微信應用的 Webhook 設定中
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
zh_Hant: 企業ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +40,7 @@ spec:
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥 (Secret)
|
||||
zh_Hant: 密鑰 (Secret)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +48,7 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌 (Token)
|
||||
zh_Hant: 令牌 (Token)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +56,7 @@ spec:
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,9 +64,11 @@ spec:
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
zh_Hant: API 基礎 URL
|
||||
description:
|
||||
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项
|
||||
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API,可根據文件填寫此項
|
||||
type: string
|
||||
required: false
|
||||
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||
|
||||
@@ -10,8 +10,10 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from ..logger import EventLogger
|
||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
||||
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient, StreamSession
|
||||
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
||||
from ...core import app as langbot_app
|
||||
from ...pipeline.monitoring_helper import FeedbackMonitor
|
||||
|
||||
|
||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@@ -192,8 +194,10 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
_ws_mode: bool = False
|
||||
bot_name: str = ''
|
||||
listeners: dict = {}
|
||||
ap: langbot_app.Application = None # Application reference for monitoring
|
||||
_bot_info: dict = None # Bot info for monitoring (bot_id, bot_name, pipeline_id, pipeline_name)
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
def __init__(self, config: dict, logger: EventLogger, ap: langbot_app.Application = None, **kwargs):
|
||||
enable_webhook = config.get('enable-webhook', False)
|
||||
bot_name = config.get('robot_name', '')
|
||||
|
||||
@@ -228,8 +232,14 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot_account_id=bot_account_id,
|
||||
bot_name=bot_name,
|
||||
event_converter=event_converter,
|
||||
**kwargs,
|
||||
)
|
||||
self.listeners = {}
|
||||
object.__setattr__(self, '_ws_mode', ws_mode)
|
||||
object.__setattr__(self, 'ap', ap)
|
||||
|
||||
# Register feedback handler for monitoring
|
||||
self._register_feedback_handler()
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
@@ -318,6 +328,66 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
def set_bot_info(
|
||||
self,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
):
|
||||
"""设置 bot 信息(用于监控记录)"""
|
||||
self._bot_info = {
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
}
|
||||
|
||||
def _register_feedback_handler(self):
|
||||
"""注册用户反馈处理器,用于持久化反馈数据到监控服务"""
|
||||
|
||||
async def handle_feedback(
|
||||
feedback_id: str,
|
||||
feedback_type: int,
|
||||
feedback_content: str,
|
||||
inaccurate_reasons: list[str],
|
||||
session: StreamSession,
|
||||
):
|
||||
"""处理用户反馈事件,持久化到监控服务"""
|
||||
if not self.ap or not self._bot_info:
|
||||
return
|
||||
|
||||
try:
|
||||
# Build session_id from session info
|
||||
session_id = None
|
||||
if session.chat_id:
|
||||
session_id = f'group_{session.chat_id}'
|
||||
elif session.user_id:
|
||||
session_id = f'person_{session.user_id}'
|
||||
|
||||
await FeedbackMonitor.record_feedback(
|
||||
ap=self.ap,
|
||||
feedback_id=feedback_id,
|
||||
feedback_type=feedback_type,
|
||||
feedback_content=feedback_content if feedback_content else None,
|
||||
inaccurate_reasons=inaccurate_reasons if inaccurate_reasons else None,
|
||||
bot_id=self._bot_info['bot_id'],
|
||||
bot_name=self._bot_info['bot_name'],
|
||||
pipeline_id=self._bot_info['pipeline_id'],
|
||||
pipeline_name=self._bot_info['pipeline_name'],
|
||||
session_id=session_id,
|
||||
message_id=session.msg_id if session else None,
|
||||
stream_id=session.stream_id if session else None,
|
||||
user_id=session.user_id if session else None,
|
||||
platform='wecom',
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
|
||||
|
||||
# Register the feedback handler with the bot client
|
||||
if hasattr(self.bot, 'on_feedback'):
|
||||
self.bot.on_feedback()(handle_feedback)
|
||||
|
||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||
_ws_mode = not self.config.get('enable-webhook', False)
|
||||
if _ws_mode:
|
||||
|
||||
@@ -5,16 +5,21 @@ metadata:
|
||||
label:
|
||||
en_US: WeComBot
|
||||
zh_Hans: 企业微信智能机器人
|
||||
zh_Hant: 企業微信智慧機器人
|
||||
description:
|
||||
en_US: WeComBot Adapter
|
||||
zh_Hans: 企业微信智能机器人适配器,请查看文档了解使用方式
|
||||
zh_Hans: 企业微信智能机器人,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
||||
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
||||
icon: wecombot.png
|
||||
spec:
|
||||
categories:
|
||||
- china
|
||||
config:
|
||||
- name: BotId
|
||||
label:
|
||||
en_US: BotId
|
||||
zh_Hans: 机器人ID (BotId)
|
||||
zh_Hant: 機器人ID (BotId)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +27,7 @@ spec:
|
||||
label:
|
||||
en_US: Robot Name
|
||||
zh_Hans: 机器人名称
|
||||
zh_Hant: 機器人名稱
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,19 +35,39 @@ spec:
|
||||
label:
|
||||
en_US: Enable Webhook Mode
|
||||
zh_Hans: 启用Webhook模式
|
||||
zh_Hant: 啟用 Webhook 模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
||||
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your WeComBot webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到企业微信智能机器人的 Webhook 配置中
|
||||
zh_Hant: 複製此地址並貼到企業微信智慧機器人的 Webhook 設定中
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
show_if:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
value: true
|
||||
- name: Secret
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 机器人密钥 (Secret)
|
||||
zh_Hant: 機器人密鑰 (Secret)
|
||||
description:
|
||||
en_US: Required for WebSocket long connection mode
|
||||
zh_Hans: 使用 WS 长连接模式时必填
|
||||
zh_Hant: 使用 WS 長連線模式時必填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
@@ -49,9 +75,11 @@ spec:
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
zh_Hant: 企業ID
|
||||
description:
|
||||
en_US: Required for Webhook mode
|
||||
zh_Hans: 使用 Webhook 模式时必填
|
||||
zh_Hant: 使用 Webhook 模式時必填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
@@ -59,9 +87,11 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌 (Token)
|
||||
zh_Hant: 令牌 (Token)
|
||||
description:
|
||||
en_US: Required for Webhook mode
|
||||
zh_Hans: 使用 Webhook 模式时必填
|
||||
zh_Hant: 使用 Webhook 模式時必填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
@@ -69,9 +99,11 @@ spec:
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
||||
description:
|
||||
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
|
||||
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
|
||||
zh_Hant: 使用 Webhook 模式時必填。WebSocket 模式下可選(用於檔案解密)
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
@@ -79,9 +111,11 @@ spec:
|
||||
label:
|
||||
en_US: Enable Stream Reply
|
||||
zh_Hans: 启用流式回复
|
||||
zh_Hant: 啟用串流回覆
|
||||
description:
|
||||
en_US: If enabled, the bot will use streaming mode to reply messages
|
||||
zh_Hans: 如果启用,机器人将使用流式模式回复消息
|
||||
zh_Hant: 如果啟用,機器人將使用串流模式回覆訊息
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
@@ -5,16 +5,33 @@ metadata:
|
||||
label:
|
||||
en_US: WeComCustomerService
|
||||
zh_Hans: 企业微信客服
|
||||
zh_Hant: 企業微信客服
|
||||
description:
|
||||
en_US: WeComCSAdapter
|
||||
zh_Hans: 企业微信客服适配器
|
||||
zh_Hans: 企业微信对外客服机器人,需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||
icon: wecom.png
|
||||
spec:
|
||||
categories:
|
||||
- china
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your WeCom Customer Service webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到企业微信客服的 Webhook 配置中
|
||||
zh_Hant: 複製此地址並貼到企業微信客服的 Webhook 設定中
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
zh_Hant: 企業ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +39,7 @@ spec:
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
zh_Hant: 密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +47,7 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +55,7 @@ spec:
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
zh_Hant: 訊息加解密密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,9 +63,11 @@ spec:
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
zh_Hant: API 基礎 URL
|
||||
description:
|
||||
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档修改此项
|
||||
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API,可根據文件修改此項
|
||||
type: string
|
||||
required: false
|
||||
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import time
|
||||
import zipfile
|
||||
from typing import Any
|
||||
import typing
|
||||
import os
|
||||
@@ -192,6 +195,30 @@ class PluginRuntimeConnector:
|
||||
|
||||
return await self.handler.ping()
|
||||
|
||||
def _extract_deps_metadata(
|
||||
self,
|
||||
file_bytes: bytes,
|
||||
task_context: taskmgr.TaskContext | None,
|
||||
):
|
||||
"""Extract dependency count from requirements.txt inside plugin zip."""
|
||||
if task_context is None:
|
||||
return
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith('requirements.txt'):
|
||||
content = zf.read(name).decode('utf-8', errors='ignore')
|
||||
deps = [
|
||||
line.strip()
|
||||
for line in content.splitlines()
|
||||
if line.strip() and not line.strip().startswith('#')
|
||||
]
|
||||
task_context.metadata['deps_total'] = len(deps)
|
||||
task_context.metadata['deps_list'] = deps
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def install_plugin(
|
||||
self,
|
||||
install_source: PluginInstallSource,
|
||||
@@ -201,23 +228,44 @@ class PluginRuntimeConnector:
|
||||
if install_source == PluginInstallSource.LOCAL:
|
||||
# transfer file before install
|
||||
file_bytes = install_info['plugin_file']
|
||||
self._extract_deps_metadata(file_bytes, task_context)
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
del install_info['plugin_file']
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
elif install_source == PluginInstallSource.GITHUB:
|
||||
# download and transfer file
|
||||
# download and transfer file with streaming progress
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
follow_redirects=True,
|
||||
timeout=20,
|
||||
timeout=60,
|
||||
) as client:
|
||||
response = await client.get(
|
||||
install_info['asset_url'],
|
||||
)
|
||||
response.raise_for_status()
|
||||
file_bytes = response.content
|
||||
async with client.stream('GET', install_info['asset_url']) as response:
|
||||
response.raise_for_status()
|
||||
total = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
chunks: list[bytes] = []
|
||||
start_time = time.time()
|
||||
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('downloading plugin package')
|
||||
task_context.metadata['download_total'] = total
|
||||
task_context.metadata['download_current'] = 0
|
||||
task_context.metadata['download_speed'] = 0
|
||||
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
chunks.append(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if task_context is not None:
|
||||
elapsed = time.time() - start_time
|
||||
task_context.metadata['download_current'] = downloaded
|
||||
task_context.metadata['download_total'] = total
|
||||
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||
|
||||
file_bytes = b''.join(chunks)
|
||||
self._extract_deps_metadata(file_bytes, task_context)
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
@@ -236,6 +284,11 @@ class PluginRuntimeConnector:
|
||||
if task_context is not None:
|
||||
task_context.trace(trace)
|
||||
|
||||
# Forward structured metadata from runtime
|
||||
metadata = ret.get('metadata', None)
|
||||
if metadata is not None and task_context is not None:
|
||||
task_context.metadata.update(metadata)
|
||||
|
||||
async def upgrade_plugin(
|
||||
self,
|
||||
plugin_author: str,
|
||||
|
||||
@@ -2,7 +2,7 @@ import langbot
|
||||
|
||||
semantic_version = f'v{langbot.__version__}'
|
||||
|
||||
required_database_version = 24
|
||||
required_database_version = 25
|
||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
@@ -23,30 +23,30 @@ stages:
|
||||
label:
|
||||
en_US: Local Agent
|
||||
zh_Hans: 内置 Agent
|
||||
- name: tbox-app-api
|
||||
label:
|
||||
en_US: Tbox App API
|
||||
zh_Hans: 蚂蚁百宝箱平台 API
|
||||
- name: dify-service-api
|
||||
label:
|
||||
en_US: Dify Service API
|
||||
zh_Hans: Dify 服务 API
|
||||
- name: dashscope-app-api
|
||||
label:
|
||||
en_US: Aliyun Dashscope App API
|
||||
zh_Hans: 阿里云百炼平台 API
|
||||
- name: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
zh_Hans: n8n 工作流 API
|
||||
- name: langflow-api
|
||||
label:
|
||||
en_US: Langflow API
|
||||
zh_Hans: Langflow API
|
||||
- name: coze-api
|
||||
label:
|
||||
en_US: Coze API
|
||||
zh_Hans: 扣子 API
|
||||
- name: tbox-app-api
|
||||
label:
|
||||
en_US: Tbox App API
|
||||
zh_Hans: 蚂蚁百宝箱平台 API
|
||||
- name: dashscope-app-api
|
||||
label:
|
||||
en_US: Aliyun Dashscope App API
|
||||
zh_Hans: 阿里云百炼平台 API
|
||||
- name: langflow-api
|
||||
label:
|
||||
en_US: Langflow API
|
||||
zh_Hans: Langflow API
|
||||
- name: local-agent
|
||||
label:
|
||||
en_US: Local Agent
|
||||
@@ -104,28 +104,6 @@ stages:
|
||||
field: __system.is_wizard
|
||||
operator: neq
|
||||
value: true
|
||||
- name: tbox-app-api
|
||||
label:
|
||||
en_US: Tbox App API
|
||||
zh_Hans: 蚂蚁百宝箱平台 API
|
||||
description:
|
||||
en_US: Configure the Tbox App API of the pipeline
|
||||
zh_Hans: 配置蚂蚁百宝箱平台 API
|
||||
config:
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
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
|
||||
@@ -140,6 +118,11 @@ stages:
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
options:
|
||||
- name: 'https://api.dify.ai/v1'
|
||||
label:
|
||||
en_US: Dify Cloud
|
||||
zh_Hans: Dify 云服务
|
||||
default: 'https://api.dify.ai/v1'
|
||||
- name: base-prompt
|
||||
label:
|
||||
@@ -178,54 +161,6 @@ stages:
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-api-key'
|
||||
- name: dashscope-app-api
|
||||
label:
|
||||
en_US: Aliyun Dashscope App API
|
||||
zh_Hans: 阿里云百炼平台 API
|
||||
description:
|
||||
en_US: Configure the Aliyun Dashscope App API of the pipeline
|
||||
zh_Hans: 配置阿里云百炼平台 API
|
||||
config:
|
||||
- name: app-type
|
||||
label:
|
||||
en_US: App Type
|
||||
zh_Hans: 应用类型
|
||||
type: select
|
||||
required: true
|
||||
default: agent
|
||||
options:
|
||||
- name: agent
|
||||
label:
|
||||
en_US: Agent
|
||||
zh_Hans: Agent
|
||||
- name: workflow
|
||||
label:
|
||||
en_US: Workflow
|
||||
zh_Hans: 工作流
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
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
|
||||
zh_Hans: 引用文本
|
||||
description:
|
||||
en_US: The text prompt when the references are included
|
||||
zh_Hans: 包含引用资料时的文本提示
|
||||
type: string
|
||||
required: false
|
||||
default: '参考资料来自:'
|
||||
- name: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
@@ -375,6 +310,140 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: 'response'
|
||||
- name: coze-api
|
||||
label:
|
||||
en_US: coze API
|
||||
zh_Hans: 扣子 API
|
||||
description:
|
||||
en_US: Configure the Coze API of the pipeline
|
||||
zh_Hans: 配置Coze API
|
||||
config:
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
zh_Hans: API 密钥
|
||||
description:
|
||||
en_US: The API key for the Coze server
|
||||
zh_Hans: Coze服务器的 API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: bot-id
|
||||
label:
|
||||
en_US: Bot ID
|
||||
zh_Hans: 机器人 ID
|
||||
description:
|
||||
en_US: The ID of the bot to run
|
||||
zh_Hans: 要运行的机器人 ID
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: api-base
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
description:
|
||||
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
|
||||
zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com)
|
||||
type: string
|
||||
options:
|
||||
- name: 'https://api.coze.cn'
|
||||
label:
|
||||
en_US: Coze China
|
||||
zh_Hans: Coze 中国版
|
||||
- name: 'https://api.coze.com'
|
||||
label:
|
||||
en_US: Coze Global
|
||||
zh_Hans: Coze 全球版
|
||||
default: "https://api.coze.cn"
|
||||
- name: auto-save-history
|
||||
label:
|
||||
en_US: Auto Save History
|
||||
zh_Hans: 自动保存历史
|
||||
description:
|
||||
en_US: Whether to automatically save conversation history
|
||||
zh_Hans: 是否自动保存对话历史
|
||||
type: boolean
|
||||
default: true
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Request Timeout
|
||||
zh_Hans: 请求超时
|
||||
description:
|
||||
en_US: Timeout in seconds for API requests
|
||||
zh_Hans: API 请求超时时间(秒)
|
||||
type: number
|
||||
default: 120
|
||||
- name: tbox-app-api
|
||||
label:
|
||||
en_US: Tbox App API
|
||||
zh_Hans: 蚂蚁百宝箱平台 API
|
||||
description:
|
||||
en_US: Configure the Tbox App API of the pipeline
|
||||
zh_Hans: 配置蚂蚁百宝箱平台 API
|
||||
config:
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
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: dashscope-app-api
|
||||
label:
|
||||
en_US: Aliyun Dashscope App API
|
||||
zh_Hans: 阿里云百炼平台 API
|
||||
description:
|
||||
en_US: Configure the Aliyun Dashscope App API of the pipeline
|
||||
zh_Hans: 配置阿里云百炼平台 API
|
||||
config:
|
||||
- name: app-type
|
||||
label:
|
||||
en_US: App Type
|
||||
zh_Hans: 应用类型
|
||||
type: select
|
||||
required: true
|
||||
default: agent
|
||||
options:
|
||||
- name: agent
|
||||
label:
|
||||
en_US: Agent
|
||||
zh_Hans: Agent
|
||||
- name: workflow
|
||||
label:
|
||||
en_US: Workflow
|
||||
zh_Hans: 工作流
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
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
|
||||
zh_Hans: 引用文本
|
||||
description:
|
||||
en_US: The text prompt when the references are included
|
||||
zh_Hans: 包含引用资料时的文本提示
|
||||
type: string
|
||||
required: false
|
||||
default: '参考资料来自:'
|
||||
- name: langflow-api
|
||||
label:
|
||||
en_US: Langflow API
|
||||
@@ -443,58 +512,3 @@ stages:
|
||||
type: json
|
||||
required: false
|
||||
default: '{}'
|
||||
- name: coze-api
|
||||
label:
|
||||
en_US: coze API
|
||||
zh_Hans: 扣子 API
|
||||
description:
|
||||
en_US: Configure the Coze API of the pipeline
|
||||
zh_Hans: 配置Coze API
|
||||
config:
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
zh_Hans: API 密钥
|
||||
description:
|
||||
en_US: The API key for the Coze server
|
||||
zh_Hans: Coze服务器的 API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: bot-id
|
||||
label:
|
||||
en_US: Bot ID
|
||||
zh_Hans: 机器人 ID
|
||||
description:
|
||||
en_US: The ID of the bot to run
|
||||
zh_Hans: 要运行的机器人 ID
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: api-base
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
description:
|
||||
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
|
||||
zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com)
|
||||
type: string
|
||||
default: "https://api.coze.cn"
|
||||
- name: auto-save-history
|
||||
label:
|
||||
en_US: Auto Save History
|
||||
zh_Hans: 自动保存历史
|
||||
description:
|
||||
en_US: Whether to automatically save conversation history
|
||||
zh_Hans: 是否自动保存对话历史
|
||||
type: boolean
|
||||
default: true
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Request Timeout
|
||||
zh_Hans: 请求超时
|
||||
description:
|
||||
en_US: Timeout in seconds for API requests
|
||||
zh_Hans: API 请求超时时间(秒)
|
||||
type: number
|
||||
default: 120
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:5300
|
||||
NEXT_PUBLIC_API_BASE_URL=http://192.168.1.97:5300
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.13",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
|
||||
4394
web/pnpm-lock.yaml
generated
4394
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -19,9 +19,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -37,6 +35,7 @@ import {
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
@@ -49,6 +48,10 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import {
|
||||
groupByCategory,
|
||||
getCategoryLabel,
|
||||
} from '@/app/infra/entities/adapter-categories';
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -110,29 +113,16 @@ export default function BotForm({
|
||||
const [, setIsLoading] = useState<boolean>(false);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const [extraCopied, setExtraCopied] = useState<boolean>(false);
|
||||
|
||||
// Watch adapter and adapter_config for filtering
|
||||
const currentAdapter = form.watch('adapter');
|
||||
const currentAdapterConfig = form.watch('adapter_config');
|
||||
|
||||
// Derive the filtered config list via useMemo instead of useEffect+setState
|
||||
// to avoid creating new array references that would cause DynamicFormComponent
|
||||
// to re-subscribe its form.watch, re-emit values, and trigger an infinite loop.
|
||||
// Only depend on the specific field we care about (enable-webhook) rather than
|
||||
// the entire currentAdapterConfig object, which changes on every emission.
|
||||
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
|
||||
const filteredDynamicFormConfigList = useMemo(() => {
|
||||
if (currentAdapter === 'lark' && enableWebhook === false) {
|
||||
// Hide encrypt-key field when webhook is disabled
|
||||
return dynamicFormConfigList.filter(
|
||||
(config) => config.name !== 'encrypt-key',
|
||||
);
|
||||
}
|
||||
// For non-Lark adapters or when webhook is enabled/undefined, show all fields
|
||||
return dynamicFormConfigList;
|
||||
}, [currentAdapter, enableWebhook, dynamicFormConfigList]);
|
||||
// Group adapters by category for the Select dropdown
|
||||
const groupedAdapters = useMemo(
|
||||
() => groupByCategory(adapterNameList),
|
||||
[adapterNameList],
|
||||
);
|
||||
|
||||
// Notify parent when dirty state changes
|
||||
const { isDirty } = form.formState;
|
||||
@@ -144,43 +134,6 @@ export default function BotForm({
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = (
|
||||
text: string,
|
||||
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setStatus(true);
|
||||
setTimeout(() => setStatus(false), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
fallbackCopy(text, setStatus);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text, setStatus);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopy = (
|
||||
text: string,
|
||||
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (successful) {
|
||||
setStatus(true);
|
||||
setTimeout(() => setStatus(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
function setBotFormValues() {
|
||||
isInitializing.current = true;
|
||||
initBotFormComponent().then(() => {
|
||||
@@ -241,6 +194,7 @@ export default function BotForm({
|
||||
return {
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
categories: item.spec.categories,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -384,12 +338,6 @@ export default function BotForm({
|
||||
}
|
||||
}
|
||||
|
||||
// --- Webhook URL display helper ---
|
||||
const showWebhook =
|
||||
initBotId &&
|
||||
webhookUrl &&
|
||||
(currentAdapter !== 'lark' || enableWebhook !== false);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -547,20 +495,31 @@ export default function BotForm({
|
||||
)}
|
||||
</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>
|
||||
{groupedAdapters.map((group) => (
|
||||
<SelectGroup
|
||||
key={group.categoryId ?? 'uncategorized'}
|
||||
>
|
||||
{group.categoryId && (
|
||||
<SelectLabel>
|
||||
{getCategoryLabel(t, group.categoryId)}
|
||||
</SelectLabel>
|
||||
)}
|
||||
{group.items.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>
|
||||
@@ -574,75 +533,19 @@ export default function BotForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
{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 && (
|
||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
itemConfigList={filteredDynamicFormConfigList}
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface IChooseAdapterEntity {
|
||||
label: string;
|
||||
value: string;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export interface IPipelineEntity {
|
||||
|
||||
@@ -11,10 +11,14 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Globe } from 'lucide-react';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
|
||||
/**
|
||||
* Resolve the value referenced by a `show_if.field` string.
|
||||
@@ -40,6 +44,106 @@ function resolveShowIfValue(
|
||||
return externalDependentValues?.[field];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only component for webhook URL fields.
|
||||
* Rendered outside of react-hook-form binding since the value is
|
||||
* read-only and comes from systemContext, not user input.
|
||||
*/
|
||||
function WebhookUrlField({
|
||||
label,
|
||||
description,
|
||||
url,
|
||||
extraUrl,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
extraUrl?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [extraCopied, setExtraCopied] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopy = (text: string, setter: (v: boolean) => void) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setter(true);
|
||||
setTimeout(() => setter(false), 2000);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={url}
|
||||
readOnly
|
||||
className="flex-1 bg-muted"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(url, setCopied)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{extraUrl && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={extraUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-muted"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(extraUrl, setExtraCopied)}
|
||||
>
|
||||
{extraCopied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{systemInfo.edition === 'community' && (
|
||||
<div className="flex items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 mt-1 max-w-2xl">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('bots.webhookSaasHint')}{' '}
|
||||
<a
|
||||
href="https://space.langbot.app/cloud?utm_source=local_webui&utm_medium=webhook_alert&utm_campaign=saas_conversion"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||
>
|
||||
{t('bots.webhookSaasLink')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DynamicFormComponent({
|
||||
itemConfigList,
|
||||
onSubmit,
|
||||
@@ -99,9 +203,16 @@ export default function DynamicFormComponent({
|
||||
return value;
|
||||
};
|
||||
|
||||
// Filter out display-only field types (e.g. webhook-url) that should not
|
||||
// participate in form state, validation, or value emission.
|
||||
const editableItems = useMemo(
|
||||
() => itemConfigList.filter((item) => item.type !== 'webhook-url'),
|
||||
[itemConfigList],
|
||||
);
|
||||
|
||||
// 根据 itemConfigList 动态生成 zod schema
|
||||
const formSchema = z.object(
|
||||
itemConfigList.reduce(
|
||||
editableItems.reduce(
|
||||
(acc, item) => {
|
||||
let fieldSchema;
|
||||
switch (item.type) {
|
||||
@@ -179,7 +290,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: itemConfigList.reduce((acc, item) => {
|
||||
defaultValues: editableItems.reduce((acc, item) => {
|
||||
// 优先使用 initialValues,如果没有则使用默认值
|
||||
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||
return {
|
||||
@@ -207,7 +318,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
if (initialValues && hasRealChange) {
|
||||
// 合并默认值和初始值
|
||||
const mergedValues = itemConfigList.reduce(
|
||||
const mergedValues = editableItems.reduce(
|
||||
(acc, item) => {
|
||||
const rawValue = initialValues[item.name] ?? item.default;
|
||||
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
|
||||
@@ -222,7 +333,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
previousInitialValues.current = initialValues;
|
||||
}
|
||||
}, [initialValues, form, itemConfigList]);
|
||||
}, [initialValues, form, editableItems]);
|
||||
|
||||
// Get reactive form values for conditional rendering
|
||||
const watchedValues = form.watch();
|
||||
@@ -238,7 +349,7 @@ export default function DynamicFormComponent({
|
||||
// even if the user saves without modifying any field.
|
||||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||||
const formValues = form.getValues();
|
||||
const initialFinalValues = itemConfigList.reduce(
|
||||
const initialFinalValues = editableItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
@@ -258,7 +369,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
const subscription = form.watch(() => {
|
||||
const formValues = form.getValues();
|
||||
const finalValues = itemConfigList.reduce(
|
||||
const finalValues = editableItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
@@ -269,7 +380,7 @@ export default function DynamicFormComponent({
|
||||
previousInitialValues.current = finalValues as Record<string, object>;
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, itemConfigList]);
|
||||
}, [form, editableItems]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -307,6 +418,29 @@ export default function DynamicFormComponent({
|
||||
// All fields are disabled when editing (creation_settings are immutable)
|
||||
const isFieldDisabled = !!isEditing;
|
||||
|
||||
// Webhook URL fields are display-only; render outside of form binding
|
||||
if (config.type === 'webhook-url') {
|
||||
const webhookUrl = (systemContext?.webhook_url as string) || '';
|
||||
const extraWebhookUrl =
|
||||
(systemContext?.extra_webhook_url as string) || '';
|
||||
|
||||
if (!webhookUrl) return null;
|
||||
|
||||
return (
|
||||
<WebhookUrlField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={webhookUrl}
|
||||
extraUrl={extraWebhookUrl || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (config.type === 'boolean') {
|
||||
return (
|
||||
|
||||
@@ -37,7 +37,29 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Plus, X, Eye, Wrench, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
Eye,
|
||||
Wrench,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
Info,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
|
||||
export default function DynamicFormItemComponent({
|
||||
config,
|
||||
@@ -57,6 +79,25 @@ export default function DynamicFormItemComponent({
|
||||
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
||||
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
|
||||
const fetchLlmModels = () => {
|
||||
httpClient
|
||||
.getProviderLLMModels()
|
||||
.then((resp) => {
|
||||
setLlmModels(resp.models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('models.getModelListError') + err.msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelsDialogChange = (open: boolean) => {
|
||||
setModelsDialogOpen(open);
|
||||
if (!open) {
|
||||
fetchLlmModels();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -88,26 +129,35 @@ export default function DynamicFormItemComponent({
|
||||
}
|
||||
};
|
||||
|
||||
// Whether to show Space login CTA in model selectors
|
||||
const showSpaceLoginCTA =
|
||||
!systemInfo.disable_models_service && userInfo?.account_type !== 'space';
|
||||
|
||||
const handleSpaceLogin = () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
httpClient
|
||||
.getSpaceAuthorizeUrl(redirectUri, token)
|
||||
.then((response) => {
|
||||
window.location.href = response.authorize_url;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
});
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
|
||||
httpClient
|
||||
.getProviderLLMModels()
|
||||
.then((resp) => {
|
||||
let models = resp.models;
|
||||
// Filter out space-chat-completions models when not logged in with space account or when models service is disabled
|
||||
if (
|
||||
systemInfo.disable_models_service ||
|
||||
userInfo?.account_type !== 'space'
|
||||
) {
|
||||
models = models.filter(
|
||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||
);
|
||||
}
|
||||
setLlmModels(models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('models.getModelListError') + err.msg);
|
||||
});
|
||||
fetchLlmModels();
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
@@ -126,23 +176,7 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
|
||||
httpClient
|
||||
.getProviderLLMModels()
|
||||
.then((resp) => {
|
||||
let models = resp.models;
|
||||
if (
|
||||
systemInfo.disable_models_service ||
|
||||
userInfo?.account_type !== 'space'
|
||||
) {
|
||||
models = models.filter(
|
||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||
);
|
||||
}
|
||||
setLlmModels(models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to get LLM model list: ' + err.msg);
|
||||
});
|
||||
fetchLlmModels();
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
@@ -188,6 +222,40 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.STRING:
|
||||
if (config.options && config.options.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 max-w-md">
|
||||
<Input className="flex-1" {...field} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="h-9 w-9 shrink-0 text-muted-foreground"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{config.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.name}
|
||||
onClick={() => field.onChange(option.name)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{extractI18nObject(option.label)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.name}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Input className="max-w-md" {...field} />;
|
||||
|
||||
case DynamicFormItemType.TEXT:
|
||||
@@ -249,7 +317,11 @@ export default function DynamicFormItemComponent({
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{config.options?.map((option) => (
|
||||
<SelectItem key={option.name} value={option.name}>
|
||||
<SelectItem
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
description={option.name}
|
||||
>
|
||||
{extractI18nObject(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -259,8 +331,16 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
||||
// Group models by provider
|
||||
const groupedModels = llmModels.reduce(
|
||||
// Separate space models from regular models
|
||||
const spaceModels = llmModels.filter(
|
||||
(m) => m.provider?.requester === 'space-chat-completions',
|
||||
);
|
||||
const regularModels = llmModels.filter(
|
||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||
);
|
||||
|
||||
// Group regular models by provider
|
||||
const groupedModels = regularModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName =
|
||||
model.provider?.name || model.provider?.requester || 'Unknown';
|
||||
@@ -271,33 +351,180 @@ export default function DynamicFormItemComponent({
|
||||
{} as Record<string, LLMModel[]>,
|
||||
);
|
||||
|
||||
// Group space models by provider (for logged-in users)
|
||||
const groupedSpaceModels = spaceModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName =
|
||||
model.provider?.name || model.provider?.requester || 'Unknown';
|
||||
if (!acc[providerName]) acc[providerName] = [];
|
||||
acc[providerName].push(model);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, LLMModel[]>,
|
||||
);
|
||||
|
||||
// Hardcoded preview model names for CTA when no space models are synced
|
||||
const previewModelNames = [
|
||||
'gpt-4o',
|
||||
'claude-sonnet-4-20250514',
|
||||
'deepseek-chat',
|
||||
'gemini-2.5-flash',
|
||||
'qwen-plus',
|
||||
];
|
||||
|
||||
return (
|
||||
<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" />
|
||||
)}
|
||||
<div className="max-w-md flex items-center gap-1.5">
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
))}
|
||||
{/* Space models section */}
|
||||
{showSpaceLoginCTA ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
{t('models.langbotModels')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
{t('models.spaceTrialTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectLabel>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Preview models (first 3 visible, rest blurred) */}
|
||||
{(spaceModels.length > 0
|
||||
? spaceModels.map((m) => m.name)
|
||||
: previewModelNames
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
{/* Blurred remaining models with login overlay */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="select-none overflow-hidden"
|
||||
style={{ maxHeight: '3rem' }}
|
||||
>
|
||||
{(spaceModels.length > 0
|
||||
? spaceModels.map((m) => m.name)
|
||||
: previewModelNames
|
||||
)
|
||||
.slice(3)
|
||||
.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Login overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSpaceLogin();
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('models.unlockModels')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectGroup>
|
||||
) : !systemInfo.disable_models_service ? (
|
||||
// User is logged into Space — show space models normally
|
||||
Object.entries(groupedSpaceModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
{providerName}
|
||||
</span>
|
||||
</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>
|
||||
),
|
||||
)
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => setModelsDialogOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t('models.title')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ModelsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -338,8 +565,16 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
||||
// Group models by provider
|
||||
const groupedModelsForFallback = llmModels.reduce(
|
||||
// Separate space models from regular models
|
||||
const fbSpaceModels = llmModels.filter(
|
||||
(m) => m.provider?.requester === 'space-chat-completions',
|
||||
);
|
||||
const fbRegularModels = llmModels.filter(
|
||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||
);
|
||||
|
||||
// Group regular models by provider
|
||||
const groupedModelsForFallback = fbRegularModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName =
|
||||
model.provider?.name || model.provider?.requester || 'Unknown';
|
||||
@@ -350,6 +585,27 @@ export default function DynamicFormItemComponent({
|
||||
{} as Record<string, LLMModel[]>,
|
||||
);
|
||||
|
||||
// Group space models by provider (for logged-in users)
|
||||
const fbGroupedSpaceModels = fbSpaceModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName =
|
||||
model.provider?.name || model.provider?.requester || 'Unknown';
|
||||
if (!acc[providerName]) acc[providerName] = [];
|
||||
acc[providerName].push(model);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, LLMModel[]>,
|
||||
);
|
||||
|
||||
// Hardcoded preview model names for CTA
|
||||
const fbPreviewModelNames = [
|
||||
'gpt-4o',
|
||||
'claude-sonnet-4-20250514',
|
||||
'deepseek-chat',
|
||||
'gemini-2.5-flash',
|
||||
'qwen-plus',
|
||||
];
|
||||
|
||||
const rawModelValue = field.value;
|
||||
const modelValue: { primary: string; fallbacks: string[] } =
|
||||
rawModelValue != null &&
|
||||
@@ -406,6 +662,112 @@ export default function DynamicFormItemComponent({
|
||||
</SelectGroup>
|
||||
),
|
||||
)}
|
||||
{/* Space models section */}
|
||||
{showSpaceLoginCTA ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
{t('models.langbotModels')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
{t('models.spaceTrialTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</SelectLabel>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Preview models (first 3 visible, rest blurred) */}
|
||||
{(fbSpaceModels.length > 0
|
||||
? fbSpaceModels.map((m) => m.name)
|
||||
: fbPreviewModelNames
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
{/* Blurred remaining models with login overlay */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="select-none overflow-hidden"
|
||||
style={{ maxHeight: '3rem' }}
|
||||
>
|
||||
{(fbSpaceModels.length > 0
|
||||
? fbSpaceModels.map((m) => m.name)
|
||||
: fbPreviewModelNames
|
||||
)
|
||||
.slice(3)
|
||||
.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Login overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSpaceLogin();
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('models.unlockModels')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectGroup>
|
||||
) : !systemInfo.disable_models_service ? (
|
||||
// User is logged into Space — show space models normally
|
||||
Object.entries(fbGroupedSpaceModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
{providerName}
|
||||
</span>
|
||||
</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>
|
||||
),
|
||||
)
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
@@ -448,11 +810,35 @@ export default function DynamicFormItemComponent({
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{t('models.fallback.primary')}
|
||||
</p>
|
||||
{renderModelSelect(
|
||||
modelValue.primary,
|
||||
(val) => updateValue({ primary: val }),
|
||||
t('models.selectModel'),
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1">
|
||||
{renderModelSelect(
|
||||
modelValue.primary,
|
||||
(val) => updateValue({ primary: val }),
|
||||
t('models.selectModel'),
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => setModelsDialogOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t('models.title')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ModelsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallback models */}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Upload,
|
||||
Store,
|
||||
Github,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
@@ -1252,28 +1253,6 @@ export default function HomeSidebar({
|
||||
|
||||
{/* Navigation items grouped by section */}
|
||||
<SidebarContent>
|
||||
{/* Standalone items (e.g. Quick Start) — rendered before section groups */}
|
||||
{sidebarConfigList
|
||||
.filter((c) => c.section === 'standalone')
|
||||
.map((config) => (
|
||||
<SidebarGroup key={config.id}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedChild?.id === config.id}
|
||||
onClick={() => handleChildClick(config)}
|
||||
tooltip={config.name}
|
||||
>
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t('sidebar.home')}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
@@ -1313,7 +1292,7 @@ export default function HomeSidebar({
|
||||
onClick={() => setApiKeyDialogOpen(true)}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4" />
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
<span>{t('common.apiIntegration')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -1331,6 +1310,7 @@ export default function HomeSidebar({
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM4.53956 9.82234C6.8254 10.837 8.68402 12.5048 9.74238 14.7996 10.8008 12.5048 12.6594 10.837 14.9452 9.82234 12.6321 8.79557 10.7676 7.04647 9.74239 4.71088 8.71719 7.04648 6.85267 8.79557 4.53956 9.82234ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899ZM18.3745 19.0469 18.937 18.4883 19.4878 19.0469 18.937 19.5898 18.3745 19.0469Z" />
|
||||
</svg>
|
||||
@@ -1423,6 +1403,15 @@ export default function HomeSidebar({
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
router.push('/wizard');
|
||||
}}
|
||||
>
|
||||
<Zap className="text-blue-500" />
|
||||
{t('sidebar.quickStart')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export const sidebarConfigList = [
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M13 9H21L11 24V15H4L13 0V9ZM11 11V7.22063L7.53238 13H13V17.3944L17.263 11H11Z"></path>
|
||||
</svg>
|
||||
@@ -37,6 +38,7 @@ export const sidebarConfigList = [
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<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>
|
||||
@@ -57,6 +59,7 @@ export const sidebarConfigList = [
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z"></path>
|
||||
</svg>
|
||||
@@ -78,6 +81,7 @@ export const sidebarConfigList = [
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path>
|
||||
</svg>
|
||||
@@ -99,6 +103,7 @@ export const sidebarConfigList = [
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z"></path>
|
||||
</svg>
|
||||
@@ -122,6 +127,7 @@ export const sidebarConfigList = [
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<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>
|
||||
@@ -143,6 +149,7 @@ export const sidebarConfigList = [
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<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>
|
||||
@@ -164,6 +171,7 @@ export const sidebarConfigList = [
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -304,7 +304,7 @@ export default function KBForm({
|
||||
{t('knowledge.noEnginesAvailable')}
|
||||
</p>
|
||||
<Link
|
||||
href="/home/plugins"
|
||||
href="/home/market?category=KnowledgeEngine"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t('knowledge.installEngineHint')}
|
||||
|
||||
@@ -15,8 +15,13 @@ import {
|
||||
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 {
|
||||
userInfo,
|
||||
systemInfo,
|
||||
initializeUserInfo,
|
||||
initializeSystemInfo,
|
||||
} from '@/app/infra/http';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
@@ -35,6 +40,10 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import {
|
||||
PluginInstallTaskProvider,
|
||||
PluginInstallProgressDialog,
|
||||
} from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
// Routes that belong to the "Extensions" section
|
||||
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
|
||||
@@ -50,6 +59,8 @@ export default function HomeLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize user info if not already initialized
|
||||
useEffect(() => {
|
||||
if (!userInfo) {
|
||||
@@ -57,9 +68,28 @@ export default function HomeLayout({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-redirect to wizard on first visit (wizard not yet completed on this instance)
|
||||
useEffect(() => {
|
||||
const checkWizard = async () => {
|
||||
try {
|
||||
// Always re-fetch to ensure we have the latest wizard_status from backend
|
||||
await initializeSystemInfo();
|
||||
if (systemInfo.wizard_status === 'none') {
|
||||
router.replace('/wizard');
|
||||
}
|
||||
} catch {
|
||||
// If fetching system info fails, don't redirect
|
||||
}
|
||||
};
|
||||
checkWizard();
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<SidebarDataProvider>
|
||||
<HomeLayoutInner>{children}</HomeLayoutInner>
|
||||
<PluginInstallTaskProvider>
|
||||
<HomeLayoutInner>{children}</HomeLayoutInner>
|
||||
<PluginInstallProgressDialog />
|
||||
</PluginInstallTaskProvider>
|
||||
</SidebarDataProvider>
|
||||
);
|
||||
}
|
||||
@@ -143,7 +173,9 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-4 pt-0">{mainContent}</div>
|
||||
<div className="flex-1 overflow-hidden p-4 pt-0 min-w-0">
|
||||
{mainContent}
|
||||
</div>
|
||||
|
||||
<SurveyWidget />
|
||||
</SidebarInset>
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download } from 'lucide-react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } 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';
|
||||
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
enum PluginInstallStatus {
|
||||
ASK_CONFIRM = 'ask_confirm',
|
||||
@@ -41,6 +42,12 @@ export default function MarketplacePage() {
|
||||
function MarketplaceContent() {
|
||||
const { t } = useTranslation();
|
||||
const { refreshPlugins } = useSidebarData();
|
||||
const {
|
||||
addTask,
|
||||
setSelectedTaskId,
|
||||
registerOnTaskComplete,
|
||||
unregisterOnTaskComplete,
|
||||
} = usePluginInstallTasks();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
@@ -69,28 +76,19 @@ function MarketplaceContent() {
|
||||
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);
|
||||
}
|
||||
// Register task completion callback for toast and plugin list refresh
|
||||
useEffect(() => {
|
||||
const onComplete = (_taskId: number, success: boolean) => {
|
||||
if (success) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
refreshPlugins();
|
||||
}
|
||||
};
|
||||
registerOnTaskComplete(onComplete);
|
||||
return () => {
|
||||
unregisterOnTaskComplete(onComplete);
|
||||
};
|
||||
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
|
||||
|
||||
const handleInstallPlugin = useCallback(
|
||||
async (plugin: PluginV4) => {
|
||||
@@ -109,6 +107,7 @@ function MarketplaceContent() {
|
||||
|
||||
function handleModalConfirm() {
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
||||
httpClient
|
||||
.installPluginFromMarketplace(
|
||||
installInfo.plugin_author,
|
||||
@@ -117,7 +116,14 @@ function MarketplaceContent() {
|
||||
)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
watchTask(taskId);
|
||||
const taskKey = `marketplace-${taskId}`;
|
||||
addTask({
|
||||
taskId,
|
||||
pluginName: pluginDisplayName,
|
||||
source: 'marketplace',
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
setModalOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInstallError(err.msg);
|
||||
|
||||
160
web/src/app/home/monitoring/components/FeedbackCard.tsx
Normal file
160
web/src/app/home/monitoring/components/FeedbackCard.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ThumbsUp, ThumbsDown, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
|
||||
interface FeedbackCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
subtitle?: string;
|
||||
icon: React.ReactNode;
|
||||
trend?: {
|
||||
value: number;
|
||||
direction: 'up' | 'down' | 'neutral';
|
||||
};
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon,
|
||||
trend,
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
}: FeedbackCardProps) {
|
||||
const variantStyles = {
|
||||
default: 'bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700',
|
||||
success: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',
|
||||
danger: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
|
||||
};
|
||||
|
||||
const iconStyles = {
|
||||
default: 'text-gray-500 dark:text-gray-400',
|
||||
success: 'text-green-500 dark:text-green-400',
|
||||
warning: 'text-yellow-500 dark:text-yellow-400',
|
||||
danger: 'text-red-500 dark:text-red-400',
|
||||
};
|
||||
|
||||
const trendStyles = {
|
||||
up: 'text-green-500',
|
||||
down: 'text-red-500',
|
||||
neutral: 'text-gray-500',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`p-6 rounded-xl border shadow-sm ${variantStyles.default} animate-pulse`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2" />
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-1" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-6 rounded-xl border shadow-sm ${variantStyles[variant]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{trend && (
|
||||
<div className={`flex items-center mt-2 text-sm ${trendStyles[trend.direction]}`}>
|
||||
{trend.direction === 'up' && <TrendingUp className="w-4 h-4 mr-1" />}
|
||||
{trend.direction === 'down' && <TrendingDown className="w-4 h-4 mr-1" />}
|
||||
{trend.direction === 'neutral' && <Minus className="w-4 h-4 mr-1" />}
|
||||
<span>{trend.value > 0 ? '+' : ''}{trend.value}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg bg-gray-100 dark:bg-gray-800 ${iconStyles[variant]}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FeedbackStatsProps {
|
||||
stats: {
|
||||
totalFeedback: number;
|
||||
totalLikes: number;
|
||||
totalDislikes: number;
|
||||
satisfactionRate: number;
|
||||
} | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('monitoring.feedback.totalFeedback'),
|
||||
value: stats?.totalFeedback ?? 0,
|
||||
icon: (
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
),
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.totalLikes'),
|
||||
value: stats?.totalLikes ?? 0,
|
||||
icon: <ThumbsUp className="w-6 h-6" />,
|
||||
variant: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.totalDislikes'),
|
||||
value: stats?.totalDislikes ?? 0,
|
||||
icon: <ThumbsDown className="w-6 h-6" />,
|
||||
variant: 'danger' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.satisfactionRate'),
|
||||
value: stats ? `${stats.satisfactionRate}%` : '0%',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||
</svg>
|
||||
),
|
||||
variant: (stats && stats.satisfactionRate >= 80 ? 'success' : stats && stats.satisfactionRate >= 50 ? 'warning' : 'danger') as 'default' | 'success' | 'warning' | 'danger',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
{cards.map((card, index) => (
|
||||
<FeedbackCard
|
||||
key={index}
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
icon={card.icon}
|
||||
variant={card.variant}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
web/src/app/home/monitoring/components/FeedbackList.tsx
Normal file
252
web/src/app/home/monitoring/components/FeedbackList.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ThumbsUp, ThumbsDown, ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
|
||||
import { FeedbackRecord } from '../types/monitoring';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
|
||||
interface FeedbackListProps {
|
||||
feedback: FeedbackRecord[];
|
||||
loading?: boolean;
|
||||
onViewMessage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function FeedbackList({ feedback, loading, onViewMessage }: FeedbackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(null);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedId(expandedId === id ? null : id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!feedback || feedback.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-base font-medium mb-2">
|
||||
{t('monitoring.feedback.noFeedback')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t('monitoring.feedback.noFeedbackDescription')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{feedback.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border rounded-xl overflow-hidden hover:shadow-md transition-all duration-200 ${
|
||||
item.feedbackType === 'like'
|
||||
? 'border-green-200 dark:border-green-900'
|
||||
: 'border-red-200 dark:border-red-900'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`p-5 cursor-pointer transition-colors ${
|
||||
item.feedbackType === 'like'
|
||||
? 'hover:bg-green-50 dark:hover:bg-green-950/50 bg-green-50/50 dark:bg-green-950/30'
|
||||
: 'hover:bg-red-50 dark:hover:bg-red-950/50 bg-red-50/50 dark:bg-red-950/30'
|
||||
}`}
|
||||
onClick={() => toggleExpand(item.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedId === item.id ? (
|
||||
<ChevronDown className={`w-5 h-5 ${item.feedbackType === 'like' ? 'text-green-500' : 'text-red-500'}`} />
|
||||
) : (
|
||||
<ChevronRight className={`w-5 h-5 ${item.feedbackType === 'like' ? 'text-green-500' : 'text-red-500'}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* Feedback Type Icon */}
|
||||
{item.feedbackType === 'like' ? (
|
||||
<ThumbsUp className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<ThumbsDown className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ${item.feedbackType === 'like' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{item.feedbackType === 'like' ? t('monitoring.feedback.like') : t('monitoring.feedback.dislike')}
|
||||
</span>
|
||||
{item.botName && (
|
||||
<>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.botName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.platform && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
||||
{item.platform}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.feedbackContent && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{item.feedbackContent}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.inaccurateReasons && item.inaccurateReasons.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{item.inaccurateReasons.map((reason, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400"
|
||||
>
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{item.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedId === item.id && (
|
||||
<div className={`border-t p-5 bg-white dark:bg-gray-900 ${
|
||||
item.feedbackType === 'like' ? 'border-green-200 dark:border-green-900' : 'border-red-200 dark:border-red-900'
|
||||
}`}>
|
||||
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4">
|
||||
{/* 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">
|
||||
{t('monitoring.feedback.contextInfo')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
{item.botName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.botName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.pipelineName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.pipelineName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.sessionId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.sessionId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.userId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.feedback.userId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.userId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.messageId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.feedback.messageId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate flex items-center gap-1">
|
||||
<span className="truncate">{item.messageId}</span>
|
||||
{onViewMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewMessage(item.messageId!);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.streamId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.feedback.streamId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.streamId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Content */}
|
||||
{item.feedbackContent && (
|
||||
<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">
|
||||
{t('monitoring.feedback.feedbackContent')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
|
||||
{item.feedbackContent}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -147,23 +147,16 @@ export default function TrafficChart({
|
||||
<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-muted-foreground">
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<svg
|
||||
className="w-16 h-16 mb-4 text-muted-foreground/30"
|
||||
fill="none"
|
||||
className="h-[3rem] w-[3rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
<path d="M2 13H8V21H2V13ZM16 8H22V21H16V8ZM9 3H15V21H9V3ZM4 15V19H6V15H4ZM11 5V19H13V5H11ZM18 10V19H20V10H18Z"></path>
|
||||
</svg>
|
||||
<p className="text-sm font-medium">
|
||||
{t('monitoring.trafficChart.noData')}
|
||||
</p>
|
||||
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
185
web/src/app/home/monitoring/hooks/useFeedbackData.ts
Normal file
185
web/src/app/home/monitoring/hooks/useFeedbackData.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { httpClient } from '@/app/infra/http';
|
||||
import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
|
||||
|
||||
interface UseFeedbackDataParams {
|
||||
botIds?: string[];
|
||||
pipelineIds?: string[];
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
feedbackType?: 'like' | 'dislike';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
interface RawFeedbackRecord {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
feedback_id: string;
|
||||
feedback_type: number;
|
||||
feedback_content?: string;
|
||||
inaccurate_reasons?: string;
|
||||
bot_id?: string;
|
||||
bot_name?: string;
|
||||
pipeline_id?: string;
|
||||
pipeline_name?: string;
|
||||
session_id?: string;
|
||||
message_id?: string;
|
||||
stream_id?: string;
|
||||
user_id?: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
interface RawFeedbackStats {
|
||||
total_feedback: number;
|
||||
total_likes: number;
|
||||
total_dislikes: number;
|
||||
satisfaction_rate: number;
|
||||
by_bot?: Array<{
|
||||
bot_id: string;
|
||||
bot_name: string;
|
||||
total: number;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for fetching and managing feedback data
|
||||
*/
|
||||
export function useFeedbackData(params: UseFeedbackDataParams = {}) {
|
||||
const [feedback, setFeedback] = useState<FeedbackRecord[]>([]);
|
||||
const [stats, setStats] = useState<FeedbackStats | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const paramsStr = useMemo(
|
||||
() => JSON.stringify(params),
|
||||
[params],
|
||||
);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.botIds) {
|
||||
params.botIds.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
if (params.pipelineIds) {
|
||||
params.pipelineIds.forEach((id) => queryParams.append('pipelineId', id));
|
||||
}
|
||||
if (params.startTime) {
|
||||
queryParams.append('startTime', params.startTime);
|
||||
}
|
||||
if (params.endTime) {
|
||||
queryParams.append('endTime', params.endTime);
|
||||
}
|
||||
|
||||
const result = await httpClient.get<RawFeedbackStats>(
|
||||
`/api/v1/monitoring/feedback/stats?${queryParams.toString()}`,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
setStats({
|
||||
totalFeedback: result.total_feedback,
|
||||
totalLikes: result.total_likes,
|
||||
totalDislikes: result.total_dislikes,
|
||||
satisfactionRate: result.satisfaction_rate,
|
||||
byBot: result.by_bot?.map((bot) => ({
|
||||
botId: bot.bot_id,
|
||||
botName: bot.bot_name,
|
||||
totalFeedback: bot.total,
|
||||
totalLikes: bot.likes,
|
||||
totalDislikes: bot.dislikes,
|
||||
satisfactionRate: bot.total > 0 ? Math.round((bot.likes / bot.total) * 100) : 0,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch feedback stats:', err);
|
||||
}
|
||||
}, [params.botIds, params.pipelineIds, params.startTime, params.endTime]);
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.botIds) {
|
||||
params.botIds.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
if (params.pipelineIds) {
|
||||
params.pipelineIds.forEach((id) => queryParams.append('pipelineId', id));
|
||||
}
|
||||
if (params.startTime) {
|
||||
queryParams.append('startTime', params.startTime);
|
||||
}
|
||||
if (params.endTime) {
|
||||
queryParams.append('endTime', params.endTime);
|
||||
}
|
||||
if (params.feedbackType) {
|
||||
queryParams.append('feedbackType', params.feedbackType === 'like' ? '1' : '2');
|
||||
}
|
||||
if (params.limit) {
|
||||
queryParams.append('limit', params.limit.toString());
|
||||
}
|
||||
if (params.offset) {
|
||||
queryParams.append('offset', params.offset.toString());
|
||||
}
|
||||
|
||||
const result = await httpClient.get<{
|
||||
feedback: RawFeedbackRecord[];
|
||||
total: number;
|
||||
}>(`/api/v1/monitoring/feedback?${queryParams.toString()}`);
|
||||
|
||||
if (result) {
|
||||
const transformedFeedback: FeedbackRecord[] = result.feedback.map((item) => ({
|
||||
id: item.id,
|
||||
timestamp: new Date(item.timestamp),
|
||||
feedbackId: item.feedback_id,
|
||||
feedbackType: item.feedback_type === 1 ? 'like' : 'dislike',
|
||||
feedbackContent: item.feedback_content,
|
||||
inaccurateReasons: item.inaccurate_reasons
|
||||
? JSON.parse(item.inaccurate_reasons)
|
||||
: undefined,
|
||||
botId: item.bot_id,
|
||||
botName: item.bot_name,
|
||||
pipelineId: item.pipeline_id,
|
||||
pipelineName: item.pipeline_name,
|
||||
sessionId: item.session_id,
|
||||
messageId: item.message_id,
|
||||
streamId: item.stream_id,
|
||||
userId: item.user_id,
|
||||
platform: item.platform,
|
||||
}));
|
||||
|
||||
setFeedback(transformedFeedback);
|
||||
setTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch feedback:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetchStats();
|
||||
fetchFeedback();
|
||||
}, [fetchStats, fetchFeedback]);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [paramsStr]);
|
||||
|
||||
return {
|
||||
feedback,
|
||||
stats,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
ModelCall,
|
||||
LLMCall,
|
||||
EmbeddingCall,
|
||||
FeedbackRecord,
|
||||
FeedbackStats,
|
||||
} from '../types/monitoring';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { Suspense, useState } from 'react';
|
||||
import React, { Suspense, useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,8 +10,11 @@ import MonitoringFilters from './components/filters/MonitoringFilters';
|
||||
import { ExportDropdown } from './components/ExportDropdown';
|
||||
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
|
||||
import { useMonitoringData } from './hooks/useMonitoringData';
|
||||
import { useFeedbackData } from './hooks/useFeedbackData';
|
||||
import { MessageDetailsCard } from './components/MessageDetailsCard';
|
||||
import { MessageContentRenderer } from './components/MessageContentRenderer';
|
||||
import { FeedbackStatsCards } from './components/FeedbackCard';
|
||||
import { FeedbackList } from './components/FeedbackList';
|
||||
import { MessageDetails } from './types/monitoring';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
||||
@@ -68,6 +71,60 @@ function MonitoringPageContent() {
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
|
||||
// Get time range for feedback data
|
||||
const feedbackTimeRange = useMemo(() => {
|
||||
const now = new Date();
|
||||
let startTime: Date | null = null;
|
||||
|
||||
switch (filterState.timeRange) {
|
||||
case 'lastHour':
|
||||
startTime = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last6Hours':
|
||||
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last24Hours':
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last7Days':
|
||||
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last30Days':
|
||||
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'custom':
|
||||
if (filterState.customDateRange) {
|
||||
startTime = filterState.customDateRange.from;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const endTime =
|
||||
filterState.timeRange === 'custom' && filterState.customDateRange
|
||||
? filterState.customDateRange.to
|
||||
: now;
|
||||
|
||||
return {
|
||||
startTime: startTime?.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
}, [filterState.timeRange, filterState.customDateRange]);
|
||||
|
||||
// Feedback data hook
|
||||
const {
|
||||
feedback: feedbackList,
|
||||
stats: feedbackStats,
|
||||
total: feedbackTotal,
|
||||
loading: feedbackLoading,
|
||||
refetch: refetchFeedback,
|
||||
} = useFeedbackData({
|
||||
botIds: filterState.selectedBots.length > 0 ? filterState.selectedBots : undefined,
|
||||
pipelineIds: filterState.selectedPipelines.length > 0 ? filterState.selectedPipelines : undefined,
|
||||
startTime: feedbackTimeRange.startTime,
|
||||
endTime: feedbackTimeRange.endTime,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -188,7 +245,7 @@ function MonitoringPageContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-y-auto">
|
||||
<div className="w-full h-full overflow-y-auto overflow-x-hidden">
|
||||
{/* 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-background">
|
||||
<div className="ml-[2rem] mr-[1.5rem] px-[0.8rem]">
|
||||
@@ -249,6 +306,9 @@ function MonitoringPageContent() {
|
||||
<TabsTrigger value="modelCalls" className="px-6 py-2">
|
||||
{t('monitoring.tabs.modelCalls')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="px-6 py-2">
|
||||
{t('monitoring.tabs.feedback')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="px-6 py-2">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</TabsTrigger>
|
||||
@@ -379,26 +439,18 @@ function MonitoringPageContent() {
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.messages || data.messages.length === 0) && (
|
||||
<div className="text-center text-muted-foreground py-16">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-muted-foreground/30"
|
||||
fill="none"
|
||||
className="h-[3rem] w-[3rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM4 18.3851L5.76282 17H20V5H4V18.3851Z"></path>
|
||||
</svg>
|
||||
<p className="text-base font-medium mb-2">
|
||||
<div className="text-sm">
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t('monitoring.messageList.noMessagesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -600,28 +652,55 @@ function MonitoringPageContent() {
|
||||
(!data ||
|
||||
!data.modelCalls ||
|
||||
data.modelCalls.length === 0) && (
|
||||
<div className="text-center text-muted-foreground py-16">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-muted-foreground/30"
|
||||
fill="none"
|
||||
className="h-[3rem] w-[3rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899Z"></path>
|
||||
</svg>
|
||||
<p className="text-base font-medium">
|
||||
<div className="text-sm">
|
||||
{t('monitoring.modelCalls.noData')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-6 m-0">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Feedback Stats Cards */}
|
||||
<div className="mb-6">
|
||||
<FeedbackStatsCards
|
||||
stats={feedbackStats}
|
||||
loading={feedbackLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feedback List */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('monitoring.feedback.feedbackList')}
|
||||
</h3>
|
||||
<FeedbackList
|
||||
feedback={feedbackList}
|
||||
loading={feedbackLoading}
|
||||
onViewMessage={jumpToMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="p-6 m-0">
|
||||
<div>
|
||||
{loading && (
|
||||
@@ -775,23 +854,18 @@ function MonitoringPageContent() {
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.errors || data.errors.length === 0) && (
|
||||
<div className="text-center text-muted-foreground py-16">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
|
||||
fill="none"
|
||||
className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
<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.0026 16L6.75999 11.7574L8.17421 10.3431L11.0026 13.1716L16.6595 7.51472L18.0737 8.92893L11.0026 16Z"></path>
|
||||
</svg>
|
||||
<p className="text-base font-medium text-green-600 dark:text-green-400">
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('monitoring.errors.noErrors')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -162,6 +162,39 @@ export interface DateRange {
|
||||
to: Date;
|
||||
}
|
||||
|
||||
export interface FeedbackRecord {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
feedbackId: string;
|
||||
feedbackType: 'like' | 'dislike';
|
||||
feedbackContent?: string;
|
||||
inaccurateReasons?: string[];
|
||||
botId?: string;
|
||||
botName?: string;
|
||||
pipelineId?: string;
|
||||
pipelineName?: string;
|
||||
sessionId?: string;
|
||||
messageId?: string;
|
||||
streamId?: string;
|
||||
userId?: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export interface FeedbackStats {
|
||||
totalFeedback: number;
|
||||
totalLikes: number;
|
||||
totalDislikes: number;
|
||||
satisfactionRate: number;
|
||||
byBot?: Array<{
|
||||
botId: string;
|
||||
botName: string;
|
||||
totalFeedback: number;
|
||||
totalLikes: number;
|
||||
totalDislikes: number;
|
||||
satisfactionRate: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
overview: OverviewMetrics;
|
||||
messages: MonitoringMessage[];
|
||||
@@ -170,11 +203,14 @@ export interface MonitoringData {
|
||||
modelCalls: ModelCall[];
|
||||
sessions: SessionInfo[];
|
||||
errors: ErrorLog[];
|
||||
feedback?: FeedbackRecord[];
|
||||
feedbackStats?: FeedbackStats;
|
||||
totalCount: {
|
||||
messages: number;
|
||||
llmCalls: number;
|
||||
embeddingCalls: number;
|
||||
sessions: number;
|
||||
errors: number;
|
||||
feedback?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ export default function PluginDetailContent({ id }: { id: string }) {
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="hidden md:block w-px bg-border shrink-0" />
|
||||
{/* Right side - Readme */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-w-0">
|
||||
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Download,
|
||||
Package,
|
||||
Settings,
|
||||
Rocket,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
usePluginInstallTasks,
|
||||
InstallStage,
|
||||
PluginInstallTask,
|
||||
} from './PluginInstallTaskContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STAGES: {
|
||||
key: InstallStage;
|
||||
icon: React.ElementType;
|
||||
i18nKey: string;
|
||||
}[] = [
|
||||
{
|
||||
key: InstallStage.DOWNLOADING,
|
||||
icon: Download,
|
||||
i18nKey: 'plugins.installProgress.downloading',
|
||||
},
|
||||
{
|
||||
key: InstallStage.INSTALLING_DEPS,
|
||||
icon: Package,
|
||||
i18nKey: 'plugins.installProgress.installingDeps',
|
||||
},
|
||||
{
|
||||
key: InstallStage.INITIALIZING,
|
||||
icon: Settings,
|
||||
i18nKey: 'plugins.installProgress.initializing',
|
||||
},
|
||||
{
|
||||
key: InstallStage.LAUNCHING,
|
||||
icon: Rocket,
|
||||
i18nKey: 'plugins.installProgress.launching',
|
||||
},
|
||||
];
|
||||
|
||||
function getStageIndex(stage: InstallStage): number {
|
||||
const idx = STAGES.findIndex((s) => s.key === stage);
|
||||
return idx >= 0 ? idx : -1;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single stage row — used in both active (single) and completed (all) views.
|
||||
*/
|
||||
function StageRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
isActive,
|
||||
isCompleted,
|
||||
isError,
|
||||
detail,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
isCompleted: boolean;
|
||||
isError: boolean;
|
||||
detail?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300',
|
||||
isActive &&
|
||||
!isError &&
|
||||
'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
|
||||
isCompleted &&
|
||||
'bg-green-50/50 dark:bg-green-950/15 border border-green-100 dark:border-green-900/50',
|
||||
isError &&
|
||||
isActive &&
|
||||
'bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900',
|
||||
)}
|
||||
>
|
||||
{/* Left: status indicator */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-7 h-7 rounded-full shrink-0',
|
||||
isCompleted &&
|
||||
'bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400',
|
||||
isActive &&
|
||||
!isError &&
|
||||
!isCompleted &&
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400',
|
||||
isError &&
|
||||
isActive &&
|
||||
'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : isError && isActive ? (
|
||||
<XCircle className="w-4 h-4" />
|
||||
) : isActive ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle: label + detail */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isActive &&
|
||||
!isError &&
|
||||
!isCompleted &&
|
||||
'text-blue-700 dark:text-blue-300',
|
||||
isCompleted && 'text-green-600 dark:text-green-400',
|
||||
isError && isActive && 'text-red-600 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* Small icon after text */}
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 shrink-0',
|
||||
isActive &&
|
||||
!isError &&
|
||||
!isCompleted &&
|
||||
'text-blue-400 dark:text-blue-500',
|
||||
isCompleted && 'text-green-400 dark:text-green-500',
|
||||
isError && isActive && 'text-red-400 dark:text-red-500',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{detail && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-0.5',
|
||||
isCompleted
|
||||
? 'text-green-600/70 dark:text-green-400/70'
|
||||
: 'text-blue-600/70 dark:text-blue-400/70',
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number): string {
|
||||
if (bytesPerSec === 0) return '0 B/s';
|
||||
const k = 1024;
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
const i = Math.floor(Math.log(bytesPerSec) / Math.log(k));
|
||||
return (bytesPerSec / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentStageIndex = getStageIndex(task.stage);
|
||||
const isDone = task.stage === InstallStage.DONE;
|
||||
const isError = task.stage === InstallStage.ERROR;
|
||||
|
||||
/** Build detail node for a stage */
|
||||
const getStageDetail = (
|
||||
stageKey: InstallStage,
|
||||
isCompletedView: boolean,
|
||||
): React.ReactNode | undefined => {
|
||||
if (stageKey === InstallStage.DOWNLOADING) {
|
||||
// Show download progress: current / total + speed
|
||||
const dlTotal = task.downloadTotal || task.fileSize;
|
||||
const dlCurrent = task.downloadCurrent;
|
||||
const dlSpeed = task.downloadSpeed;
|
||||
|
||||
if (isCompletedView && dlTotal) {
|
||||
// Done view: just show total size
|
||||
return t('plugins.installProgress.downloadSize', {
|
||||
size: formatFileSize(dlTotal),
|
||||
});
|
||||
}
|
||||
|
||||
if (dlTotal && dlCurrent != null) {
|
||||
const parts: string[] = [];
|
||||
parts.push(`${formatFileSize(dlCurrent)} / ${formatFileSize(dlTotal)}`);
|
||||
if (dlSpeed && dlSpeed > 0) {
|
||||
parts.push(formatSpeed(dlSpeed));
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
if (dlTotal) {
|
||||
return t('plugins.installProgress.downloadSize', {
|
||||
size: formatFileSize(dlTotal),
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (stageKey === InstallStage.INSTALLING_DEPS) {
|
||||
const total = task.depsTotal;
|
||||
const installed = task.depsInstalled;
|
||||
const remaining = task.depsRemaining;
|
||||
const currentDep = task.currentDep;
|
||||
const dlSize = task.depsDownloadedSize;
|
||||
const speed = task.depsSpeed;
|
||||
|
||||
if (isCompletedView && total != null) {
|
||||
const parts: string[] = [];
|
||||
parts.push(t('plugins.installProgress.depsInfo', { count: total }));
|
||||
if (dlSize && dlSize > 0) {
|
||||
parts.push(formatFileSize(dlSize));
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
if (total != null && installed != null) {
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
t('plugins.installProgress.depsProgress', {
|
||||
installed,
|
||||
total,
|
||||
remaining: remaining ?? total - installed,
|
||||
}),
|
||||
);
|
||||
if (dlSize && dlSize > 0) {
|
||||
parts.push(formatFileSize(dlSize));
|
||||
}
|
||||
if (speed && speed > 0) {
|
||||
parts.push(formatSpeed(speed));
|
||||
}
|
||||
if (currentDep) {
|
||||
return (
|
||||
<>
|
||||
<span>{parts.join(' · ')}</span>
|
||||
<br />
|
||||
<span className="opacity-70">{currentDep}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
if (total != null) {
|
||||
return t('plugins.installProgress.depsInfo', { count: total });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Overall progress bar — always blue */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isDone
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-blue-700 dark:text-blue-300',
|
||||
)}
|
||||
>
|
||||
{isDone
|
||||
? t('plugins.installProgress.completed')
|
||||
: isError
|
||||
? t('plugins.installProgress.failed')
|
||||
: t('plugins.installProgress.overallProgress')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isDone
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-blue-600 dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{isDone ? '100%' : `${task.overallProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={isDone ? 100 : task.overallProgress}
|
||||
className={cn(
|
||||
'h-2.5',
|
||||
'[&>div]:bg-blue-500 dark:[&>div]:bg-blue-400',
|
||||
'bg-blue-100 dark:bg-blue-900/30',
|
||||
isDone &&
|
||||
'[&>div]:bg-green-500 dark:[&>div]:bg-green-400 bg-green-100 dark:bg-green-900/30',
|
||||
isError &&
|
||||
'[&>div]:bg-red-500 dark:[&>div]:bg-red-400 bg-red-100 dark:bg-red-900/30',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stage display */}
|
||||
<div className="space-y-1.5">
|
||||
{isDone
|
||||
? /* When done: show all stages with completed style */
|
||||
STAGES.map((stageConfig) => (
|
||||
<StageRow
|
||||
key={stageConfig.key}
|
||||
icon={stageConfig.icon}
|
||||
label={t(stageConfig.i18nKey)}
|
||||
isActive={false}
|
||||
isCompleted={true}
|
||||
isError={false}
|
||||
detail={getStageDetail(stageConfig.key, true)}
|
||||
/>
|
||||
))
|
||||
: isError
|
||||
? /* Error: show the failed stage */
|
||||
currentStageIndex >= 0 && (
|
||||
<StageRow
|
||||
icon={STAGES[currentStageIndex].icon}
|
||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||
isActive={true}
|
||||
isCompleted={false}
|
||||
isError={true}
|
||||
detail={task.error}
|
||||
/>
|
||||
)
|
||||
: /* In progress: only show the current active stage */
|
||||
currentStageIndex >= 0 && (
|
||||
<StageRow
|
||||
icon={STAGES[currentStageIndex].icon}
|
||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||
isActive={true}
|
||||
isCompleted={false}
|
||||
isError={false}
|
||||
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Done banner */}
|
||||
{isDone && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300 font-medium">
|
||||
{t('plugins.installProgress.installComplete')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error detail */}
|
||||
{isError && task.error && (
|
||||
<div className="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
|
||||
<p className="text-xs text-red-600 dark:text-red-400 break-all line-clamp-4">
|
||||
{task.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginInstallProgressDialog() {
|
||||
const { t } = useTranslation();
|
||||
const { tasks, selectedTaskId, setSelectedTaskId, removeTask } =
|
||||
usePluginInstallTasks();
|
||||
|
||||
const selectedTask = tasks.find((t) => t.id === selectedTaskId) || null;
|
||||
const open = !!selectedTask;
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedTaskId(null);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (selectedTask) {
|
||||
if (
|
||||
selectedTask.stage === InstallStage.DONE ||
|
||||
selectedTask.stage === InstallStage.ERROR
|
||||
) {
|
||||
removeTask(selectedTask.id);
|
||||
}
|
||||
}
|
||||
setSelectedTaskId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<Download className="size-5" />
|
||||
<span className="truncate">
|
||||
{selectedTask
|
||||
? t('plugins.installProgress.title', {
|
||||
name: selectedTask.pluginName,
|
||||
})
|
||||
: t('plugins.installProgress.titleGeneric')}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedTask && <TaskProgressContent task={selectedTask} />}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
{selectedTask &&
|
||||
(selectedTask.stage === InstallStage.DONE ||
|
||||
selectedTask.stage === InstallStage.ERROR) && (
|
||||
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
||||
{t('plugins.installProgress.dismiss')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
{selectedTask?.stage === InstallStage.DONE ||
|
||||
selectedTask?.stage === InstallStage.ERROR
|
||||
? t('common.close')
|
||||
: t('plugins.installProgress.background')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { AsyncTask } from '@/app/infra/entities/api';
|
||||
|
||||
/**
|
||||
* Installation stages mapped from backend current_action strings.
|
||||
*/
|
||||
export enum InstallStage {
|
||||
DOWNLOADING = 'downloading',
|
||||
INSTALLING_DEPS = 'installing_deps',
|
||||
INITIALIZING = 'initializing',
|
||||
LAUNCHING = 'launching',
|
||||
DONE = 'done',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export interface PluginInstallTask {
|
||||
id: string; // unique key: `${source}-${taskId}`
|
||||
taskId: number; // backend async task id
|
||||
pluginName: string; // display name
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
stage: InstallStage;
|
||||
overallProgress: number; // 0-100
|
||||
fileSize?: number; // bytes, if known
|
||||
// Download progress
|
||||
downloadCurrent?: number; // bytes downloaded so far
|
||||
downloadTotal?: number; // total bytes to download
|
||||
downloadSpeed?: number; // bytes per second
|
||||
// Dependency progress
|
||||
depsTotal?: number; // total dependency count
|
||||
depsInstalled?: number; // deps installed so far
|
||||
depsRemaining?: number; // remaining
|
||||
currentDep?: string; // currently installing dep name
|
||||
depsDownloadedSize?: number; // total bytes of downloaded deps
|
||||
depsSpeed?: number; // deps download speed bytes/s
|
||||
error?: string;
|
||||
startedAt: number; // timestamp
|
||||
currentAction: string; // raw backend action string
|
||||
}
|
||||
|
||||
type OnTaskCompleteCallback = (taskId: number, success: boolean) => void;
|
||||
|
||||
interface PluginInstallTaskContextValue {
|
||||
tasks: PluginInstallTask[];
|
||||
addTask: (params: {
|
||||
taskId: number;
|
||||
pluginName: string;
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
fileSize?: number;
|
||||
}) => void;
|
||||
removeTask: (id: string) => void;
|
||||
clearCompletedTasks: () => void;
|
||||
selectedTaskId: string | null;
|
||||
setSelectedTaskId: (id: string | null) => void;
|
||||
/** Register a callback for when a task completes (for toast/refresh). Cleared on unmount. */
|
||||
registerOnTaskComplete: (cb: OnTaskCompleteCallback) => void;
|
||||
unregisterOnTaskComplete: (cb: OnTaskCompleteCallback) => void;
|
||||
}
|
||||
|
||||
const PluginInstallTaskContext =
|
||||
createContext<PluginInstallTaskContextValue | null>(null);
|
||||
|
||||
export function usePluginInstallTasks() {
|
||||
const ctx = useContext(PluginInstallTaskContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'usePluginInstallTasks must be used within PluginInstallTaskProvider',
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map backend `current_action` to our InstallStage.
|
||||
*/
|
||||
function mapActionToStage(action: string): InstallStage {
|
||||
if (!action) return InstallStage.DOWNLOADING;
|
||||
const lower = action.toLowerCase();
|
||||
if (lower.includes('download')) return InstallStage.DOWNLOADING;
|
||||
if (lower.includes('dependencies') || lower.includes('requirements'))
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('initializ') || lower.includes('setting'))
|
||||
return InstallStage.INITIALIZING;
|
||||
if (lower.includes('launch')) return InstallStage.LAUNCHING;
|
||||
if (lower.includes('installed') || lower.includes('complete'))
|
||||
return InstallStage.DONE;
|
||||
return InstallStage.DOWNLOADING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall progress percentage from a stage.
|
||||
*/
|
||||
function stageToProgress(stage: InstallStage): number {
|
||||
switch (stage) {
|
||||
case InstallStage.DOWNLOADING:
|
||||
return 10;
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return 40;
|
||||
case InstallStage.INITIALIZING:
|
||||
return 70;
|
||||
case InstallStage.LAUNCHING:
|
||||
return 85;
|
||||
case InstallStage.DONE:
|
||||
return 100;
|
||||
case InstallStage.ERROR:
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract install source from backend task name.
|
||||
*/
|
||||
function extractSourceFromName(
|
||||
name: string,
|
||||
): 'github' | 'marketplace' | 'local' {
|
||||
if (name.includes('github')) return 'github';
|
||||
if (name.includes('marketplace')) return 'marketplace';
|
||||
return 'local';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a backend task name is a plugin install task.
|
||||
*/
|
||||
function isPluginInstallTask(name: string): boolean {
|
||||
return name.startsWith('plugin-install-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a backend AsyncTask to our PluginInstallTask.
|
||||
*/
|
||||
function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
const source = extractSourceFromName(task.name);
|
||||
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
|
||||
const action = task.task_context?.current_action || '';
|
||||
const done = task.runtime.done;
|
||||
const exception = task.runtime.exception;
|
||||
|
||||
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
||||
|
||||
let stage: InstallStage;
|
||||
let overallProgress: number;
|
||||
let error: string | undefined;
|
||||
|
||||
if (done) {
|
||||
if (exception) {
|
||||
stage = InstallStage.ERROR;
|
||||
overallProgress = 0;
|
||||
error = exception;
|
||||
} else {
|
||||
stage = InstallStage.DONE;
|
||||
overallProgress = 100;
|
||||
}
|
||||
} else {
|
||||
stage = mapActionToStage(action);
|
||||
overallProgress = Math.min(95, stageToProgress(stage));
|
||||
}
|
||||
|
||||
const pluginName = str(md.plugin_name) || task.label || `${source} plugin`;
|
||||
|
||||
return {
|
||||
id: `${source}-${task.id}`,
|
||||
taskId: task.id,
|
||||
pluginName,
|
||||
source,
|
||||
stage,
|
||||
overallProgress,
|
||||
downloadCurrent: num(md.download_current),
|
||||
downloadTotal: num(md.download_total),
|
||||
downloadSpeed: num(md.download_speed),
|
||||
depsTotal: num(md.deps_total),
|
||||
depsInstalled: num(md.deps_installed),
|
||||
depsRemaining: num(md.deps_remaining),
|
||||
currentDep: str(md.current_dep),
|
||||
depsDownloadedSize: num(md.deps_downloaded_size),
|
||||
depsSpeed: num(md.deps_speed),
|
||||
error,
|
||||
startedAt: Date.now(),
|
||||
currentAction: action,
|
||||
};
|
||||
}
|
||||
|
||||
export function PluginInstallTaskProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<PluginInstallTask[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const intervalRefs = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const syncIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const onTaskCompleteCallbacks = useRef<Set<OnTaskCompleteCallback>>(
|
||||
new Set(),
|
||||
);
|
||||
// Track tasks that have already been marked as completed/failed (to avoid duplicate callbacks)
|
||||
const notifiedTaskIds = useRef<Set<number>>(new Set());
|
||||
// Track task IDs that the user has explicitly dismissed
|
||||
const dismissedTaskIds = useRef<Set<number>>(new Set());
|
||||
|
||||
// Cleanup all intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
intervalRefs.current.forEach((interval) => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const registerOnTaskComplete = useCallback((cb: OnTaskCompleteCallback) => {
|
||||
onTaskCompleteCallbacks.current.add(cb);
|
||||
}, []);
|
||||
|
||||
const unregisterOnTaskComplete = useCallback((cb: OnTaskCompleteCallback) => {
|
||||
onTaskCompleteCallbacks.current.delete(cb);
|
||||
}, []);
|
||||
|
||||
const notifyTaskComplete = useCallback((taskId: number, success: boolean) => {
|
||||
if (notifiedTaskIds.current.has(taskId)) return;
|
||||
notifiedTaskIds.current.add(taskId);
|
||||
onTaskCompleteCallbacks.current.forEach((cb) => {
|
||||
cb(taskId, success);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pollTask = useCallback(
|
||||
(taskKey: string, taskId: number) => {
|
||||
// Don't start duplicate polling for the same task
|
||||
if (intervalRefs.current.has(taskKey)) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
httpClient
|
||||
.getAsyncTask(taskId)
|
||||
.then((res: AsyncTask) => {
|
||||
const action = res.task_context?.current_action || '';
|
||||
const done = res.runtime.done;
|
||||
const exception = res.runtime.exception;
|
||||
const md = (res.task_context?.metadata ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Extract progress fields from metadata
|
||||
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
||||
|
||||
const downloadCurrent = num(md.download_current);
|
||||
const downloadTotal = num(md.download_total);
|
||||
const downloadSpeed = num(md.download_speed);
|
||||
const depsTotal = num(md.deps_total);
|
||||
const depsInstalled = num(md.deps_installed);
|
||||
const depsRemaining = num(md.deps_remaining);
|
||||
const currentDep = str(md.current_dep);
|
||||
const depsDownloadedSize = num(md.deps_downloaded_size);
|
||||
const depsSpeed = num(md.deps_speed);
|
||||
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== taskKey) return t;
|
||||
|
||||
const progressFields = {
|
||||
downloadCurrent: downloadCurrent ?? t.downloadCurrent,
|
||||
downloadTotal: downloadTotal ?? t.downloadTotal,
|
||||
downloadSpeed: downloadSpeed ?? t.downloadSpeed,
|
||||
depsTotal: depsTotal ?? t.depsTotal,
|
||||
depsInstalled: depsInstalled ?? t.depsInstalled,
|
||||
depsRemaining: depsRemaining ?? t.depsRemaining,
|
||||
currentDep: currentDep ?? t.currentDep,
|
||||
depsDownloadedSize:
|
||||
depsDownloadedSize ?? t.depsDownloadedSize,
|
||||
depsSpeed: depsSpeed ?? t.depsSpeed,
|
||||
};
|
||||
|
||||
if (done) {
|
||||
// Stop polling
|
||||
const iv = intervalRefs.current.get(taskKey);
|
||||
if (iv) {
|
||||
clearInterval(iv);
|
||||
intervalRefs.current.delete(taskKey);
|
||||
}
|
||||
|
||||
if (exception) {
|
||||
notifyTaskComplete(taskId, false);
|
||||
return {
|
||||
...t,
|
||||
stage: InstallStage.ERROR,
|
||||
error: exception,
|
||||
overallProgress: 0,
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
}
|
||||
|
||||
notifyTaskComplete(taskId, true);
|
||||
return {
|
||||
...t,
|
||||
stage: InstallStage.DONE,
|
||||
overallProgress: 100,
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
}
|
||||
|
||||
const stage = mapActionToStage(action);
|
||||
const baseProgress = stageToProgress(stage);
|
||||
// Add small time-based increment within stage
|
||||
const elapsed = (Date.now() - t.startedAt) / 1000;
|
||||
const withinStageIncrement = Math.min(
|
||||
15,
|
||||
Math.floor(elapsed / 2),
|
||||
);
|
||||
const progress = Math.min(
|
||||
95,
|
||||
baseProgress + withinStageIncrement,
|
||||
);
|
||||
|
||||
return {
|
||||
...t,
|
||||
stage,
|
||||
overallProgress: progress,
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore polling errors
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
intervalRefs.current.set(taskKey, interval);
|
||||
},
|
||||
[notifyTaskComplete],
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch all plugin-operation tasks from backend and sync state.
|
||||
* This is called on mount and periodically to recover tasks after refresh.
|
||||
*/
|
||||
const syncTasksFromBackend = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getAsyncTasks({ kind: 'plugin-operation' });
|
||||
const backendTasks = (resp.tasks || []).filter((t: AsyncTask) =>
|
||||
isPluginInstallTask(t.name),
|
||||
);
|
||||
|
||||
setTasks((prevTasks) => {
|
||||
const existingTaskIds = new Set(prevTasks.map((t) => t.taskId));
|
||||
const updatedTasks = [...prevTasks];
|
||||
|
||||
for (const bt of backendTasks) {
|
||||
// Skip tasks that the user has dismissed
|
||||
if (dismissedTaskIds.current.has(bt.id)) continue;
|
||||
|
||||
if (!existingTaskIds.has(bt.id)) {
|
||||
// New task from backend (e.g. after page refresh) — add it
|
||||
const newTask = asyncTaskToPluginInstallTask(bt);
|
||||
updatedTasks.push(newTask);
|
||||
|
||||
// If not done, start polling for progress
|
||||
if (!bt.runtime.done) {
|
||||
pollTask(newTask.id, bt.id);
|
||||
} else {
|
||||
// Mark as already notified so we don't re-trigger toasts for old completed tasks
|
||||
notifiedTaskIds.current.add(bt.id);
|
||||
}
|
||||
} else {
|
||||
// Already tracking — if it's done in backend but still active locally, update it
|
||||
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
|
||||
if (idx !== -1) {
|
||||
const existing = updatedTasks[idx];
|
||||
if (
|
||||
bt.runtime.done &&
|
||||
existing.stage !== InstallStage.DONE &&
|
||||
existing.stage !== InstallStage.ERROR
|
||||
) {
|
||||
const converted = asyncTaskToPluginInstallTask(bt);
|
||||
converted.startedAt = existing.startedAt;
|
||||
converted.pluginName = existing.pluginName;
|
||||
converted.fileSize = existing.fileSize;
|
||||
updatedTasks[idx] = converted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedTasks;
|
||||
});
|
||||
} catch {
|
||||
// Silently ignore sync errors
|
||||
}
|
||||
}, [pollTask]);
|
||||
|
||||
// Initial sync on mount + periodic sync every 3s
|
||||
useEffect(() => {
|
||||
syncTasksFromBackend();
|
||||
syncIntervalRef.current = setInterval(syncTasksFromBackend, 3000);
|
||||
return () => {
|
||||
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
|
||||
};
|
||||
}, [syncTasksFromBackend]);
|
||||
|
||||
const addTask = useCallback(
|
||||
(params: {
|
||||
taskId: number;
|
||||
pluginName: string;
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
fileSize?: number;
|
||||
}) => {
|
||||
const taskKey = `${params.source}-${params.taskId}`;
|
||||
|
||||
// Remove from dismissed set if re-added
|
||||
dismissedTaskIds.current.delete(params.taskId);
|
||||
|
||||
const newTask: PluginInstallTask = {
|
||||
id: taskKey,
|
||||
taskId: params.taskId,
|
||||
pluginName: params.pluginName,
|
||||
source: params.source,
|
||||
stage: InstallStage.DOWNLOADING,
|
||||
overallProgress: 5,
|
||||
fileSize: params.fileSize,
|
||||
startedAt: Date.now(),
|
||||
currentAction: '',
|
||||
};
|
||||
|
||||
setTasks((prev) => {
|
||||
// Avoid duplicate
|
||||
if (prev.some((t) => t.taskId === params.taskId)) return prev;
|
||||
return [...prev, newTask];
|
||||
});
|
||||
pollTask(taskKey, params.taskId);
|
||||
},
|
||||
[pollTask],
|
||||
);
|
||||
|
||||
const removeTask = useCallback((id: string) => {
|
||||
const iv = intervalRefs.current.get(id);
|
||||
if (iv) {
|
||||
clearInterval(iv);
|
||||
intervalRefs.current.delete(id);
|
||||
}
|
||||
|
||||
setTasks((prev) => {
|
||||
const task = prev.find((t) => t.id === id);
|
||||
if (task) {
|
||||
dismissedTaskIds.current.add(task.taskId);
|
||||
}
|
||||
return prev.filter((t) => t.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearCompletedTasks = useCallback(() => {
|
||||
setTasks((prev) => {
|
||||
const completed = prev.filter(
|
||||
(t) => t.stage === InstallStage.DONE || t.stage === InstallStage.ERROR,
|
||||
);
|
||||
completed.forEach((t) => {
|
||||
dismissedTaskIds.current.add(t.taskId);
|
||||
});
|
||||
return prev.filter(
|
||||
(t) => t.stage !== InstallStage.DONE && t.stage !== InstallStage.ERROR,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PluginInstallTaskContext.Provider
|
||||
value={{
|
||||
tasks,
|
||||
addTask,
|
||||
removeTask,
|
||||
clearCompletedTasks,
|
||||
selectedTaskId,
|
||||
setSelectedTaskId,
|
||||
registerOnTaskComplete,
|
||||
unregisterOnTaskComplete,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PluginInstallTaskContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Download,
|
||||
Package,
|
||||
Settings,
|
||||
Rocket,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
X,
|
||||
ListTodo,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
usePluginInstallTasks,
|
||||
InstallStage,
|
||||
PluginInstallTask,
|
||||
} from './PluginInstallTaskContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STAGE_ICONS: Record<string, React.ElementType> = {
|
||||
[InstallStage.DOWNLOADING]: Download,
|
||||
[InstallStage.INSTALLING_DEPS]: Package,
|
||||
[InstallStage.INITIALIZING]: Settings,
|
||||
[InstallStage.LAUNCHING]: Rocket,
|
||||
[InstallStage.DONE]: CheckCircle2,
|
||||
[InstallStage.ERROR]: XCircle,
|
||||
};
|
||||
|
||||
function TaskQueueItem({
|
||||
task,
|
||||
onClick,
|
||||
onRemove,
|
||||
}: {
|
||||
task: PluginInstallTask;
|
||||
onClick: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isDone = task.stage === InstallStage.DONE;
|
||||
const isError = task.stage === InstallStage.ERROR;
|
||||
const isRunning = !isDone && !isError;
|
||||
const StageIcon = STAGE_ICONS[task.stage] || Download;
|
||||
|
||||
const stageLabel = (() => {
|
||||
switch (task.stage) {
|
||||
case InstallStage.DOWNLOADING:
|
||||
return t('plugins.installProgress.downloading');
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return t('plugins.installProgress.installingDeps');
|
||||
case InstallStage.INITIALIZING:
|
||||
return t('plugins.installProgress.initializing');
|
||||
case InstallStage.LAUNCHING:
|
||||
return t('plugins.installProgress.launching');
|
||||
case InstallStage.DONE:
|
||||
return t('plugins.installProgress.completed');
|
||||
case InstallStage.ERROR:
|
||||
return t('plugins.installProgress.failed');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg hover:bg-muted/60 cursor-pointer transition-colors group"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-7 h-7 rounded-full shrink-0',
|
||||
isDone &&
|
||||
'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
|
||||
isError &&
|
||||
'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
isRunning &&
|
||||
'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<StageIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{task.pluginName}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{stageLabel}</span>
|
||||
{isRunning && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{task.overallProgress}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isRunning && (
|
||||
<Progress value={task.overallProgress} className="h-1 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isDone || isError) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginInstallTaskQueue() {
|
||||
const { t } = useTranslation();
|
||||
const { tasks, setSelectedTaskId, removeTask, clearCompletedTasks } =
|
||||
usePluginInstallTasks();
|
||||
|
||||
const runningCount = tasks.filter(
|
||||
(t) => t.stage !== InstallStage.DONE && t.stage !== InstallStage.ERROR,
|
||||
).length;
|
||||
const hasCompleted = tasks.some(
|
||||
(t) => t.stage === InstallStage.DONE || t.stage === InstallStage.ERROR,
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="relative px-4 py-5 cursor-pointer">
|
||||
<ListTodo className="w-4 h-4 mr-2" />
|
||||
{t('plugins.installProgress.taskQueue')}
|
||||
{runningCount > 0 && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="ml-2 h-5 min-w-5 px-1.5 text-xs"
|
||||
>
|
||||
{runningCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[340px] p-2" align="end">
|
||||
<div className="flex items-center justify-between px-2 py-1.5 mb-1">
|
||||
<span className="text-sm font-semibold">
|
||||
{t('plugins.installProgress.taskQueue')}
|
||||
</span>
|
||||
{hasCompleted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={clearCompletedTasks}
|
||||
>
|
||||
{t('plugins.installProgress.clearCompleted')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-0.5">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t('plugins.installProgress.noTasks')}
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskQueueItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
onRemove={() => removeTask(task.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
PluginInstallTaskProvider,
|
||||
usePluginInstallTasks,
|
||||
InstallStage,
|
||||
} from './PluginInstallTaskContext';
|
||||
export type { PluginInstallTask } from './PluginInstallTaskContext';
|
||||
export { default as PluginInstallProgressDialog } from './PluginInstallProgressDialog';
|
||||
export { default as PluginInstallTaskQueue } from './PluginInstallTaskQueue';
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
@@ -46,9 +47,24 @@ function MarketPageContent({
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const validCategories = [
|
||||
'Tool',
|
||||
'Command',
|
||||
'EventListener',
|
||||
'KnowledgeEngine',
|
||||
'Parser',
|
||||
];
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [componentFilter, setComponentFilter] = useState<string>('all');
|
||||
const [componentFilter, setComponentFilter] = useState<string>(() => {
|
||||
const category = searchParams.get('category');
|
||||
if (category && validCategories.includes(category)) {
|
||||
return category;
|
||||
}
|
||||
return 'all';
|
||||
});
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
|
||||
const [tagNames, setTagNames] = useState<Record<string, string>>({});
|
||||
@@ -284,6 +300,18 @@ function MarketPageContent({
|
||||
setComponentFilter(value);
|
||||
setCurrentPage(1);
|
||||
setPlugins([]);
|
||||
|
||||
// Update URL query param to keep it in sync
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (value === 'all') {
|
||||
params.delete('category');
|
||||
} else {
|
||||
params.set('category', value);
|
||||
}
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
// fetchPlugins will be called by useEffect when componentFilter changes
|
||||
}, []);
|
||||
|
||||
@@ -561,10 +589,17 @@ function MarketPageContent({
|
||||
<LoadingSpinner text={t('market.loading')} />
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
{searchQuery ? t('market.noResults') : t('market.noPlugins')}
|
||||
</div>
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
{searchQuery ? t('market.noResults') : t('market.noPlugins')}
|
||||
{' · '}
|
||||
<a
|
||||
href="https://github.com/langbot-app/langbot-plugin-demo/issues/new?template=plugin-request.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t('market.requestPlugin')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -52,6 +52,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import {
|
||||
PluginInstallTaskQueue,
|
||||
usePluginInstallTasks,
|
||||
} from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
enum PluginInstallStatus {
|
||||
WAIT_INPUT = 'wait_input',
|
||||
@@ -99,6 +103,12 @@ function PluginListView() {
|
||||
pendingPluginInstallAction,
|
||||
setPendingPluginInstallAction,
|
||||
} = useSidebarData();
|
||||
const {
|
||||
addTask,
|
||||
setSelectedTaskId,
|
||||
registerOnTaskComplete,
|
||||
unregisterOnTaskComplete,
|
||||
} = usePluginInstallTasks();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [installSource, setInstallSource] = useState<string>('local');
|
||||
const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
@@ -155,30 +165,20 @@ function PluginListView() {
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
resetGithubState();
|
||||
setModalOpen(false);
|
||||
pluginInstalledRef.current?.refreshPluginList();
|
||||
refreshPlugins();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
// Register task completion callback for toast and plugin list refresh
|
||||
useEffect(() => {
|
||||
const onComplete = (_taskId: number, success: boolean) => {
|
||||
if (success) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
pluginInstalledRef.current?.refreshPluginList();
|
||||
refreshPlugins();
|
||||
}
|
||||
};
|
||||
registerOnTaskComplete(onComplete);
|
||||
return () => {
|
||||
unregisterOnTaskComplete(onComplete);
|
||||
};
|
||||
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
|
||||
|
||||
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
||||
|
||||
@@ -300,6 +300,8 @@ function PluginListView() {
|
||||
) {
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
if (installSource === 'github') {
|
||||
const pluginDisplayName = `${installInfo.owner}/${installInfo.repo}`;
|
||||
const assetSize = selectedAsset?.size;
|
||||
httpClient
|
||||
.installPluginFromGithub(
|
||||
installInfo.asset_url,
|
||||
@@ -309,18 +311,37 @@ function PluginListView() {
|
||||
)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
watchTask(taskId);
|
||||
const taskKey = `github-${taskId}`;
|
||||
addTask({
|
||||
taskId,
|
||||
pluginName: pluginDisplayName,
|
||||
source: 'github',
|
||||
fileSize: assetSize,
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
resetGithubState();
|
||||
setModalOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInstallError(err.msg);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
});
|
||||
} else if (installSource === 'local') {
|
||||
const fileName = installInfo.file?.name || 'local plugin';
|
||||
const fileSize = installInfo.file?.size;
|
||||
httpClient
|
||||
.installPluginFromLocal(installInfo.file)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
watchTask(taskId);
|
||||
const taskKey = `local-${taskId}`;
|
||||
addTask({
|
||||
taskId,
|
||||
pluginName: fileName,
|
||||
source: 'local',
|
||||
fileSize: fileSize,
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
setModalOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInstallError(err.msg);
|
||||
@@ -546,8 +567,10 @@ function PluginListView() {
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Header bar with debug info and install button */}
|
||||
{/* Header bar with debug info, task queue, and install button */}
|
||||
<div className="flex flex-row justify-end items-center px-[0.8rem] pb-4 flex-shrink-0 gap-2">
|
||||
<PluginInstallTaskQueue />
|
||||
|
||||
<Popover open={debugPopoverOpen} onOpenChange={setDebugPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
88
web/src/app/infra/entities/adapter-categories.ts
Normal file
88
web/src/app/infra/entities/adapter-categories.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
/**
|
||||
* All known adapter category IDs.
|
||||
*/
|
||||
export const ADAPTER_CATEGORIES = [
|
||||
'popular',
|
||||
'china',
|
||||
'global',
|
||||
'protocol',
|
||||
] as const;
|
||||
|
||||
export type AdapterCategoryId = (typeof ADAPTER_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Returns the ordered list of category IDs based on the current locale.
|
||||
*
|
||||
* - zh-Hans: popular -> china -> global -> protocol
|
||||
* - All other locales: popular -> global -> china -> protocol
|
||||
*
|
||||
* `popular` is always first.
|
||||
*/
|
||||
export function getOrderedCategories(): AdapterCategoryId[] {
|
||||
const lang = i18n.language;
|
||||
if (lang === 'zh-Hans') {
|
||||
return ['popular', 'china', 'global', 'protocol'];
|
||||
}
|
||||
return ['popular', 'global', 'china', 'protocol'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups items that have a `categories` string array into ordered category
|
||||
* buckets. An item can appear in multiple groups if it belongs to multiple
|
||||
* categories. Items without any recognised category are collected into a
|
||||
* trailing "uncategorized" group (null key).
|
||||
*/
|
||||
export function groupByCategory<T extends { categories?: string[] }>(
|
||||
items: T[],
|
||||
): { categoryId: AdapterCategoryId | null; items: T[] }[] {
|
||||
const ordered = getOrderedCategories();
|
||||
const buckets = new Map<AdapterCategoryId | null, T[]>();
|
||||
|
||||
// Initialise buckets in display order
|
||||
for (const cat of ordered) {
|
||||
buckets.set(cat, []);
|
||||
}
|
||||
buckets.set(null, []);
|
||||
|
||||
for (const item of items) {
|
||||
const cats = item.categories;
|
||||
if (!cats || cats.length === 0) {
|
||||
buckets.get(null)!.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
let placed = false;
|
||||
for (const cat of cats) {
|
||||
if (ordered.includes(cat as AdapterCategoryId)) {
|
||||
buckets.get(cat as AdapterCategoryId)!.push(item);
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
buckets.get(null)!.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Build result, skipping empty buckets
|
||||
const result: { categoryId: AdapterCategoryId | null; items: T[] }[] = [];
|
||||
for (const [categoryId, groupItems] of buckets) {
|
||||
if (groupItems.length > 0) {
|
||||
result.push({ categoryId, items: groupItems });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the i18n display name for a category ID using the
|
||||
* `bots.adapterCategory.*` translation keys.
|
||||
*/
|
||||
export function getCategoryLabel(
|
||||
t: (key: string) => string,
|
||||
categoryId: AdapterCategoryId | null,
|
||||
): string {
|
||||
if (categoryId === null) return '';
|
||||
return t(`bots.adapterCategory.${categoryId}`);
|
||||
}
|
||||
@@ -117,6 +117,7 @@ export interface Adapter {
|
||||
description: I18nObject;
|
||||
icon?: string;
|
||||
spec: {
|
||||
categories?: string[];
|
||||
config: IDynamicFormItemSchema[];
|
||||
};
|
||||
}
|
||||
@@ -251,6 +252,14 @@ export interface SystemLimitation {
|
||||
max_extensions: number;
|
||||
}
|
||||
|
||||
export interface WizardProgress {
|
||||
step: number;
|
||||
selected_adapter: string | null;
|
||||
created_bot_uuid: string | null;
|
||||
bot_saved: boolean;
|
||||
selected_runner: string | null;
|
||||
}
|
||||
|
||||
export interface ApiRespSystemInfo {
|
||||
debug: boolean;
|
||||
version: string;
|
||||
@@ -260,6 +269,8 @@ export interface ApiRespSystemInfo {
|
||||
allow_modify_login_info: boolean;
|
||||
disable_models_service: boolean;
|
||||
limitation: SystemLimitation;
|
||||
wizard_status: string; // 'none' | 'skipped' | 'completed'
|
||||
wizard_progress: WizardProgress | null;
|
||||
}
|
||||
|
||||
export interface RagMigrationStatusResp {
|
||||
@@ -288,12 +299,14 @@ export interface AsyncTaskRuntimeInfo {
|
||||
export interface AsyncTaskTaskContext {
|
||||
current_action: string;
|
||||
log: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AsyncTask {
|
||||
id: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
label: string;
|
||||
task_type: string; // system or user
|
||||
runtime: AsyncTaskRuntimeInfo;
|
||||
task_context: AsyncTaskTaskContext;
|
||||
|
||||
@@ -3,6 +3,9 @@ export interface I18nObject {
|
||||
zh_Hans: string;
|
||||
zh_Hant?: string;
|
||||
ja_JP?: string;
|
||||
th_TH?: string;
|
||||
vi_VN?: string;
|
||||
es_ES?: string;
|
||||
}
|
||||
|
||||
export interface ComponentManifest {
|
||||
|
||||
@@ -42,6 +42,7 @@ export enum DynamicFormItemType {
|
||||
KNOWLEDGE_BASE_MULTI_SELECTOR = 'knowledge-base-multi-selector',
|
||||
PLUGIN_SELECTOR = 'plugin-selector',
|
||||
BOT_SELECTOR = 'bot-selector',
|
||||
WEBHOOK_URL = 'webhook-url',
|
||||
}
|
||||
|
||||
export interface IFileConfig {
|
||||
|
||||
@@ -701,8 +701,29 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get('/api/v1/system/info');
|
||||
}
|
||||
|
||||
public getAsyncTasks(): Promise<ApiRespAsyncTasks> {
|
||||
return this.get('/api/v1/system/tasks');
|
||||
public updateWizardStatus(status: 'skipped' | 'completed'): Promise<void> {
|
||||
return this.post('/api/v1/system/wizard/completed', { status });
|
||||
}
|
||||
|
||||
public saveWizardProgress(progress: {
|
||||
step: number;
|
||||
selected_adapter: string | null;
|
||||
created_bot_uuid: string | null;
|
||||
bot_saved: boolean;
|
||||
selected_runner: string | null;
|
||||
}): Promise<void> {
|
||||
return this.put('/api/v1/system/wizard/progress', progress);
|
||||
}
|
||||
|
||||
public getAsyncTasks(params?: {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
}): Promise<ApiRespAsyncTasks> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.type) query.set('type', params.type);
|
||||
if (params?.kind) query.set('kind', params.kind);
|
||||
const qs = query.toString();
|
||||
return this.get(`/api/v1/system/tasks${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
public getAsyncTask(id: number): Promise<AsyncTask> {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CloudServiceClient } from './CloudServiceClient';
|
||||
import { ApiRespSystemInfo } from '@/app/infra/entities/api';
|
||||
|
||||
// 系统信息
|
||||
export let systemInfo: ApiRespSystemInfo = {
|
||||
export const systemInfo: ApiRespSystemInfo = {
|
||||
debug: false,
|
||||
version: '',
|
||||
edition: 'community',
|
||||
@@ -16,6 +16,8 @@ export let systemInfo: ApiRespSystemInfo = {
|
||||
max_pipelines: -1,
|
||||
max_extensions: -1,
|
||||
},
|
||||
wizard_status: 'none',
|
||||
wizard_progress: null,
|
||||
};
|
||||
|
||||
// 用户信息
|
||||
@@ -50,7 +52,7 @@ if (typeof window !== 'undefined' && systemInfo.cloud_service_url === '') {
|
||||
backendClient
|
||||
.getSystemInfo()
|
||||
.then((info) => {
|
||||
systemInfo = info;
|
||||
Object.assign(systemInfo, info);
|
||||
cloudServiceClient.updateBaseURL(info.cloud_service_url);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -65,7 +67,7 @@ if (typeof window !== 'undefined' && systemInfo.cloud_service_url === '') {
|
||||
export const getCloudServiceClient = async (): Promise<CloudServiceClient> => {
|
||||
if (systemInfo.cloud_service_url === '') {
|
||||
try {
|
||||
systemInfo = await backendClient.getSystemInfo();
|
||||
Object.assign(systemInfo, await backendClient.getSystemInfo());
|
||||
// 更新 cloud service client 的 baseURL
|
||||
cloudServiceClient.updateBaseURL(systemInfo.cloud_service_url);
|
||||
} catch (error) {
|
||||
@@ -90,7 +92,7 @@ export const getCloudServiceClientSync = (): CloudServiceClient => {
|
||||
*/
|
||||
export const initializeSystemInfo = async (): Promise<void> => {
|
||||
try {
|
||||
systemInfo = await backendClient.getSystemInfo();
|
||||
Object.assign(systemInfo, await backendClient.getSystemInfo());
|
||||
cloudServiceClient.updateBaseURL(systemInfo.cloud_service_url);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize system info:', error);
|
||||
|
||||
@@ -18,10 +18,16 @@ import {
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
userInfo,
|
||||
systemInfo,
|
||||
initializeUserInfo,
|
||||
initializeSystemInfo,
|
||||
} from '@/app/infra/http';
|
||||
import { Adapter, Bot, Pipeline } from '@/app/infra/entities/api';
|
||||
import {
|
||||
Adapter,
|
||||
Bot,
|
||||
Pipeline,
|
||||
WizardProgress,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import {
|
||||
PipelineConfigTab,
|
||||
@@ -29,11 +35,16 @@ import {
|
||||
} from '@/app/infra/entities/pipeline';
|
||||
import {
|
||||
DynamicFormItemConfig,
|
||||
getDefaultValues,
|
||||
parseDynamicFormItemType,
|
||||
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import {
|
||||
groupByCategory,
|
||||
getCategoryLabel,
|
||||
} from '@/app/infra/entities/adapter-categories';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -47,63 +58,20 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WizardState {
|
||||
currentStep: number;
|
||||
selectedAdapter: string | null;
|
||||
selectedRunner: string | null;
|
||||
botName: string;
|
||||
botDescription: string;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
runnerConfig: Record<string, unknown>;
|
||||
createdBotUuid: string | null;
|
||||
}
|
||||
|
||||
const WIZARD_STORAGE_KEY = 'langbot_wizard_state';
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadWizardState(): WizardState | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(WIZARD_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as WizardState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveWizardState(state: WizardState): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(WIZARD_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// localStorage may be full - silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
function clearWizardState(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(WIZARD_STORAGE_KEY);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Wizard Page (full-screen, no sidebar)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,30 +81,19 @@ export default function WizardPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// ---- Wizard state ----
|
||||
const restoredState = useRef(loadWizardState());
|
||||
const [currentStep, setCurrentStep] = useState(
|
||||
restoredState.current?.currentStep ?? 0,
|
||||
);
|
||||
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(
|
||||
restoredState.current?.selectedAdapter ?? null,
|
||||
);
|
||||
const [selectedRunner, setSelectedRunner] = useState<string | null>(
|
||||
restoredState.current?.selectedRunner ?? null,
|
||||
);
|
||||
const [botName, setBotName] = useState(restoredState.current?.botName ?? '');
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
|
||||
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
|
||||
const [botName, setBotName] = useState('');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [botDescription, _setBotDescription] = useState(
|
||||
restoredState.current?.botDescription ?? '',
|
||||
);
|
||||
const [botDescription, _setBotDescription] = useState('');
|
||||
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
|
||||
restoredState.current?.adapterConfig ?? {},
|
||||
);
|
||||
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>(
|
||||
restoredState.current?.runnerConfig ?? {},
|
||||
);
|
||||
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(
|
||||
restoredState.current?.createdBotUuid ?? null,
|
||||
{},
|
||||
);
|
||||
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>({});
|
||||
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(null);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||
|
||||
// ---- Remote data ----
|
||||
const [adapters, setAdapters] = useState<Adapter[]>([]);
|
||||
@@ -149,30 +106,24 @@ export default function WizardPage() {
|
||||
const [isSavingBot, setIsSavingBot] = useState(false);
|
||||
const [botSaved, setBotSaved] = useState(false);
|
||||
|
||||
// ---- Persist state on every change ----
|
||||
useEffect(() => {
|
||||
saveWizardState({
|
||||
currentStep,
|
||||
selectedAdapter,
|
||||
selectedRunner,
|
||||
botName,
|
||||
botDescription,
|
||||
adapterConfig,
|
||||
runnerConfig,
|
||||
createdBotUuid,
|
||||
});
|
||||
}, [
|
||||
currentStep,
|
||||
selectedAdapter,
|
||||
selectedRunner,
|
||||
botName,
|
||||
botDescription,
|
||||
adapterConfig,
|
||||
runnerConfig,
|
||||
createdBotUuid,
|
||||
]);
|
||||
// ---- Helper: persist wizard progress to backend (fire-and-forget) ----
|
||||
const saveProgress = useCallback(
|
||||
(overrides: Partial<WizardProgress> = {}) => {
|
||||
const progress: WizardProgress = {
|
||||
step: overrides.step ?? currentStep,
|
||||
selected_adapter: overrides.selected_adapter ?? selectedAdapter,
|
||||
created_bot_uuid: overrides.created_bot_uuid ?? createdBotUuid,
|
||||
bot_saved: overrides.bot_saved ?? botSaved,
|
||||
selected_runner: overrides.selected_runner ?? selectedRunner,
|
||||
};
|
||||
httpClient.saveWizardProgress(progress).catch((err) => {
|
||||
console.error('Failed to save wizard progress', err);
|
||||
});
|
||||
},
|
||||
[currentStep, selectedAdapter, createdBotUuid, botSaved, selectedRunner],
|
||||
);
|
||||
|
||||
// ---- Fetch remote data ----
|
||||
// ---- Fetch remote data & restore progress ----
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@@ -188,6 +139,47 @@ export default function WizardPage() {
|
||||
setAdapters(adaptersResp.adapters);
|
||||
const aiTab = metadataResp.configs.find((c) => c.name === 'ai');
|
||||
if (aiTab) setAiConfigTab(aiTab);
|
||||
|
||||
// Restore wizard progress if available
|
||||
const progress = systemInfo.wizard_progress;
|
||||
if (progress && progress.created_bot_uuid) {
|
||||
// Verify the bot still exists before restoring
|
||||
try {
|
||||
const botData = await httpClient.getBot(progress.created_bot_uuid);
|
||||
if (cancelled) return;
|
||||
|
||||
setSelectedAdapter(progress.selected_adapter);
|
||||
setCreatedBotUuid(progress.created_bot_uuid);
|
||||
setBotSaved(progress.bot_saved ?? false);
|
||||
setSelectedRunner(progress.selected_runner);
|
||||
|
||||
// Restore bot name from fetched bot data
|
||||
setBotName(botData.bot.name);
|
||||
|
||||
// Restore webhook URLs
|
||||
const runtimeValues = botData.bot.adapter_runtime_values as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
setWebhookUrl((runtimeValues?.webhook_full_url as string) || '');
|
||||
setExtraWebhookUrl(
|
||||
(runtimeValues?.extra_webhook_full_url as string) || '',
|
||||
);
|
||||
|
||||
// Restore step (cap at step 2 — step 3 means done)
|
||||
setCurrentStep(Math.min(progress.step, 2));
|
||||
} catch {
|
||||
// Bot no longer exists — clear stale progress and start fresh
|
||||
httpClient
|
||||
.saveWizardProgress({
|
||||
step: 0,
|
||||
selected_adapter: null,
|
||||
created_bot_uuid: null,
|
||||
bot_saved: false,
|
||||
selected_runner: null,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load wizard data', err);
|
||||
toast.error(t('wizard.loadError'));
|
||||
@@ -258,6 +250,15 @@ export default function WizardPage() {
|
||||
);
|
||||
}, [selectedRunnerConfigStage]);
|
||||
|
||||
// ---- Runner selection with progress saving ----
|
||||
const handleSelectRunner = useCallback(
|
||||
(runner: string) => {
|
||||
setSelectedRunner(runner);
|
||||
saveProgress({ step: 2, selected_runner: runner });
|
||||
},
|
||||
[saveProgress],
|
||||
);
|
||||
|
||||
// ---- Navigation helpers ----
|
||||
|
||||
const canProceed = useCallback((): boolean => {
|
||||
@@ -275,15 +276,19 @@ export default function WizardPage() {
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (currentStep < TOTAL_STEPS - 1 && canProceed()) {
|
||||
setCurrentStep((s) => s + 1);
|
||||
const nextStep = currentStep + 1;
|
||||
setCurrentStep(nextStep);
|
||||
saveProgress({ step: nextStep });
|
||||
}
|
||||
}, [currentStep, canProceed]);
|
||||
}, [currentStep, canProceed, saveProgress]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((s) => s - 1);
|
||||
const prevStep = currentStep - 1;
|
||||
setCurrentStep(prevStep);
|
||||
saveProgress({ step: prevStep });
|
||||
}
|
||||
}, [currentStep]);
|
||||
}, [currentStep, saveProgress]);
|
||||
|
||||
// ---- Create Bot (Step 0) ----
|
||||
// Creates a disabled bot using the adapter label as name.
|
||||
@@ -300,18 +305,45 @@ export default function WizardPage() {
|
||||
: selectedAdapter;
|
||||
setBotName(defaultName);
|
||||
|
||||
const defaultConfig = adapter
|
||||
? getDefaultValues(adapter.spec.config)
|
||||
: {};
|
||||
|
||||
const bot: Bot = {
|
||||
name: defaultName,
|
||||
description: '',
|
||||
adapter: selectedAdapter,
|
||||
adapter_config: {},
|
||||
adapter_config: defaultConfig,
|
||||
enable: false,
|
||||
};
|
||||
const resp = await httpClient.createBot(bot);
|
||||
setCreatedBotUuid(resp.uuid);
|
||||
toast.success(t('wizard.botCreateSuccess'));
|
||||
|
||||
// Fetch runtime info to get webhook URL(s)
|
||||
try {
|
||||
const botData = await httpClient.getBot(resp.uuid);
|
||||
const runtimeValues = botData.bot.adapter_runtime_values as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
setWebhookUrl((runtimeValues?.webhook_full_url as string) || '');
|
||||
setExtraWebhookUrl(
|
||||
(runtimeValues?.extra_webhook_full_url as string) || '',
|
||||
);
|
||||
} catch {
|
||||
// Non-critical — webhook URL display is optional
|
||||
}
|
||||
|
||||
// Advance to Step 1
|
||||
setCurrentStep(1);
|
||||
|
||||
// Persist progress
|
||||
saveProgress({
|
||||
step: 1,
|
||||
selected_adapter: selectedAdapter,
|
||||
created_bot_uuid: resp.uuid,
|
||||
bot_saved: false,
|
||||
selected_runner: null,
|
||||
});
|
||||
} catch (err) {
|
||||
const apiErr = err as { msg?: string };
|
||||
toast.error(
|
||||
@@ -320,7 +352,7 @@ export default function WizardPage() {
|
||||
} finally {
|
||||
setIsCreatingBot(false);
|
||||
}
|
||||
}, [selectedAdapter, adapters, t]);
|
||||
}, [selectedAdapter, adapters, t, saveProgress]);
|
||||
|
||||
// ---- Save Bot Config & Enable (Step 1) ----
|
||||
// Updates the bot's adapter config and enables it.
|
||||
@@ -338,6 +370,23 @@ export default function WizardPage() {
|
||||
enable: true,
|
||||
});
|
||||
setBotSaved(true);
|
||||
|
||||
// Re-fetch runtime info to get updated webhook URL(s)
|
||||
try {
|
||||
const botData = await httpClient.getBot(createdBotUuid);
|
||||
const runtimeValues = botData.bot.adapter_runtime_values as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
setWebhookUrl((runtimeValues?.webhook_full_url as string) || '');
|
||||
setExtraWebhookUrl(
|
||||
(runtimeValues?.extra_webhook_full_url as string) || '',
|
||||
);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
// Persist progress
|
||||
saveProgress({ step: 1, bot_saved: true });
|
||||
} catch (err) {
|
||||
const apiErr = err as { msg?: string };
|
||||
toast.error(
|
||||
@@ -353,6 +402,7 @@ export default function WizardPage() {
|
||||
botDescription,
|
||||
adapterConfig,
|
||||
t,
|
||||
saveProgress,
|
||||
]);
|
||||
|
||||
// ---- Create Pipeline & Link (Step 2 finish) ----
|
||||
@@ -403,7 +453,6 @@ export default function WizardPage() {
|
||||
use_pipeline_uuid: pipelineResp.uuid,
|
||||
});
|
||||
|
||||
toast.success(t('wizard.createSuccess'));
|
||||
setCurrentStep(3);
|
||||
} catch (err) {
|
||||
const apiErr = err as { msg?: string };
|
||||
@@ -442,11 +491,33 @@ export default function WizardPage() {
|
||||
|
||||
// ---- Skip handler ----
|
||||
const [showSkipConfirm, setShowSkipConfirm] = useState(false);
|
||||
const [isSkipping, setIsSkipping] = useState(false);
|
||||
|
||||
const handleSkipConfirm = useCallback(() => {
|
||||
clearWizardState();
|
||||
const handleSkipConfirm = useCallback(async () => {
|
||||
setIsSkipping(true);
|
||||
try {
|
||||
if (systemInfo.wizard_status === 'none') {
|
||||
await httpClient.updateWizardStatus('skipped');
|
||||
systemInfo.wizard_status = 'skipped';
|
||||
}
|
||||
// Always clear persisted progress so re-entering starts fresh
|
||||
await httpClient.saveWizardProgress({
|
||||
step: 0,
|
||||
selected_adapter: null,
|
||||
created_bot_uuid: null,
|
||||
bot_saved: false,
|
||||
selected_runner: null,
|
||||
});
|
||||
systemInfo.wizard_progress = null;
|
||||
} catch {
|
||||
toast.error(t('wizard.skipSaveError'));
|
||||
setIsSkipping(false);
|
||||
return;
|
||||
}
|
||||
setIsSkipping(false);
|
||||
setShowSkipConfirm(false);
|
||||
router.push('/home');
|
||||
}, [router]);
|
||||
}, [router, t]);
|
||||
|
||||
// ---- Render ----
|
||||
|
||||
@@ -563,13 +634,15 @@ export default function WizardPage() {
|
||||
isSavingBot={isSavingBot}
|
||||
botSaved={botSaved}
|
||||
onSaveBot={handleSaveBot}
|
||||
webhookUrl={webhookUrl}
|
||||
extraWebhookUrl={extraWebhookUrl}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<StepAIEngine
|
||||
runnerOptions={runnerOptions}
|
||||
selected={selectedRunner}
|
||||
onSelect={setSelectedRunner}
|
||||
onSelect={handleSelectRunner}
|
||||
isLocalAccount={isLocalAccount}
|
||||
onSpaceAuth={handleSpaceAuth}
|
||||
runnerConfigItems={selectedRunnerConfigItems}
|
||||
@@ -623,21 +696,31 @@ export default function WizardPage() {
|
||||
)}
|
||||
|
||||
{/* Skip confirmation dialog */}
|
||||
<AlertDialog open={showSkipConfirm} onOpenChange={setShowSkipConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('wizard.skip')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Dialog open={showSkipConfirm} onOpenChange={setShowSkipConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('wizard.skip')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('wizard.skipConfirmMessage')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={handleSkipConfirm}>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowSkipConfirm(false)}
|
||||
disabled={isSkipping}
|
||||
>
|
||||
{t('wizard.prev')}
|
||||
</Button>
|
||||
<Button onClick={handleSkipConfirm} disabled={isSkipping}>
|
||||
{isSkipping && (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
)}
|
||||
{t('wizard.skipConfirmOk')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -657,6 +740,14 @@ function StepPlatform({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const groupedAdapters = useMemo(() => {
|
||||
const withCategories = adapters.map((a) => ({
|
||||
...a,
|
||||
categories: a.spec.categories,
|
||||
}));
|
||||
return groupByCategory(withCategories);
|
||||
}, [adapters]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div className="text-center">
|
||||
@@ -665,45 +756,54 @@ function StepPlatform({
|
||||
{t('wizard.platform.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{adapters.map((adapter) => (
|
||||
<Card
|
||||
key={adapter.name}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-md',
|
||||
selected === adapter.name
|
||||
? 'ring-2 ring-primary shadow-md'
|
||||
: 'hover:border-primary/50',
|
||||
)}
|
||||
onClick={() => onSelect(adapter.name)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center gap-3 pb-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(adapter.name)}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base truncate">
|
||||
{extractI18nObject(adapter.label)}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{selected === adapter.name && (
|
||||
<div className="ml-auto shrink-0">
|
||||
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-primary-foreground" />
|
||||
{groupedAdapters.map((group) => (
|
||||
<div key={group.categoryId ?? 'uncategorized'} className="space-y-3">
|
||||
{group.categoryId && (
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{getCategoryLabel(t, group.categoryId)}
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{group.items.map((adapter) => (
|
||||
<Card
|
||||
key={adapter.name}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-md',
|
||||
selected === adapter.name
|
||||
? 'ring-2 ring-primary shadow-md'
|
||||
: 'hover:border-primary/50',
|
||||
)}
|
||||
onClick={() => onSelect(adapter.name)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center gap-3 pb-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(adapter.name)}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base truncate">
|
||||
{extractI18nObject(adapter.label)}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{extractI18nObject(adapter.description)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{selected === adapter.name && (
|
||||
<div className="ml-auto shrink-0">
|
||||
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{extractI18nObject(adapter.description)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -722,6 +822,8 @@ function StepBotConfig({
|
||||
isSavingBot,
|
||||
botSaved,
|
||||
onSaveBot,
|
||||
webhookUrl,
|
||||
extraWebhookUrl,
|
||||
}: {
|
||||
adapterConfigItems: IDynamicFormItemSchema[];
|
||||
adapterConfigValues: Record<string, unknown>;
|
||||
@@ -732,6 +834,8 @@ function StepBotConfig({
|
||||
isSavingBot: boolean;
|
||||
botSaved: boolean;
|
||||
onSaveBot: () => void;
|
||||
webhookUrl: string;
|
||||
extraWebhookUrl: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -787,7 +891,11 @@ function StepBotConfig({
|
||||
itemConfigList={adapterConfigItems}
|
||||
initialValues={adapterConfigValues as Record<string, object>}
|
||||
onSubmit={stableAdapterConfigCb}
|
||||
systemContext={{ is_wizard: true }}
|
||||
systemContext={{
|
||||
is_wizard: true,
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1037,10 +1145,32 @@ function StepDone() {
|
||||
})),
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
clearWizardState();
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
|
||||
const handleBack = useCallback(async () => {
|
||||
setIsCompleting(true);
|
||||
try {
|
||||
if (systemInfo.wizard_status === 'none') {
|
||||
await httpClient.updateWizardStatus('completed');
|
||||
systemInfo.wizard_status = 'completed';
|
||||
}
|
||||
// Always clear persisted progress so re-entering starts fresh
|
||||
await httpClient.saveWizardProgress({
|
||||
step: 0,
|
||||
selected_adapter: null,
|
||||
created_bot_uuid: null,
|
||||
bot_saved: false,
|
||||
selected_runner: null,
|
||||
});
|
||||
systemInfo.wizard_progress = null;
|
||||
} catch {
|
||||
toast.error(t('wizard.completeSaveError'));
|
||||
setIsCompleting(false);
|
||||
return;
|
||||
}
|
||||
setIsCompleting(false);
|
||||
router.push('/home/bots');
|
||||
}, [router]);
|
||||
}, [router, t]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center h-full min-h-[400px]">
|
||||
@@ -1065,7 +1195,8 @@ function StepDone() {
|
||||
<p className="text-muted-foreground mt-2 text-center max-w-md">
|
||||
{t('wizard.done.description')}
|
||||
</p>
|
||||
<Button className="mt-6" onClick={handleBack}>
|
||||
<Button className="mt-6" onClick={handleBack} disabled={isCompleting}>
|
||||
{isCompleting && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />}
|
||||
{t('wizard.done.backToWorkbench')}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -39,6 +39,15 @@ export function LanguageSelector({
|
||||
} else if (i18n.language === 'ja' || i18n.language === 'ja-JP') {
|
||||
setCurrentLanguage('ja-JP');
|
||||
localStorage.setItem('langbot_language', 'ja-JP');
|
||||
} else if (i18n.language === 'th' || i18n.language === 'th-TH') {
|
||||
setCurrentLanguage('th-TH');
|
||||
localStorage.setItem('langbot_language', 'th-TH');
|
||||
} else if (i18n.language === 'vi' || i18n.language === 'vi-VN') {
|
||||
setCurrentLanguage('vi-VN');
|
||||
localStorage.setItem('langbot_language', 'vi-VN');
|
||||
} else if (i18n.language === 'es' || i18n.language === 'es-ES') {
|
||||
setCurrentLanguage('es-ES');
|
||||
localStorage.setItem('langbot_language', 'es-ES');
|
||||
} else {
|
||||
setCurrentLanguage('en-US');
|
||||
localStorage.setItem('langbot_language', 'en-US');
|
||||
@@ -58,6 +67,15 @@ export function LanguageSelector({
|
||||
detectedLanguage = 'zh-Hant';
|
||||
} else if (browserLanguage === 'ja' || browserLanguage === 'ja-JP') {
|
||||
detectedLanguage = 'ja-JP';
|
||||
} else if (browserLanguage === 'th' || browserLanguage === 'th-TH') {
|
||||
detectedLanguage = 'th-TH';
|
||||
} else if (browserLanguage === 'vi' || browserLanguage === 'vi-VN') {
|
||||
detectedLanguage = 'vi-VN';
|
||||
} else if (
|
||||
browserLanguage === 'es' ||
|
||||
browserLanguage.startsWith('es-')
|
||||
) {
|
||||
detectedLanguage = 'es-ES';
|
||||
} else {
|
||||
detectedLanguage = 'en-US';
|
||||
}
|
||||
@@ -92,6 +110,9 @@ export function LanguageSelector({
|
||||
<SelectItem value="zh-Hant">繁體中文</SelectItem>
|
||||
<SelectItem value="en-US">English</SelectItem>
|
||||
<SelectItem value="ja-JP">日本語</SelectItem>
|
||||
<SelectItem value="th-TH">ภาษาไทย</SelectItem>
|
||||
<SelectItem value="vi-VN">Tiếng Việt</SelectItem>
|
||||
<SelectItem value="es-ES">Español</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
28
web/src/components/ui/progress.tsx
Normal file
28
web/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ComponentRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all duration-300"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -101,8 +101,11 @@ function SelectLabel({
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
description,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
description?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
@@ -117,7 +120,16 @@ function SelectItem({
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description != null ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
<span className="text-xs text-muted-foreground leading-tight">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
)}
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'bg-background relative flex w-full flex-1 flex-col min-w-0',
|
||||
'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,
|
||||
|
||||
@@ -27,6 +27,9 @@ export const extractI18nObject = (i18nObject: I18nObject): string => {
|
||||
if (language === 'zh_Hans' && i18nObject.zh_Hans) return i18nObject.zh_Hans;
|
||||
if (language === 'zh_Hant' && i18nObject.zh_Hant) return i18nObject.zh_Hant;
|
||||
if (language === 'ja_JP' && i18nObject.ja_JP) return i18nObject.ja_JP;
|
||||
if (language === 'th_TH' && i18nObject.th_TH) return i18nObject.th_TH;
|
||||
if (language === 'vi_VN' && i18nObject.vi_VN) return i18nObject.vi_VN;
|
||||
if (language === 'es_ES' && i18nObject.es_ES) return i18nObject.es_ES;
|
||||
return (
|
||||
i18nObject.en_US ||
|
||||
i18nObject.zh_Hans ||
|
||||
@@ -49,6 +52,12 @@ export const getAPILanguageCode = (): string => {
|
||||
if (language === 'en-US') return 'en';
|
||||
// ja-JP -> ja_JP
|
||||
if (language === 'ja-JP') return 'ja_JP';
|
||||
// th-TH -> th_TH
|
||||
if (language === 'th-TH') return 'th_TH';
|
||||
// vi-VN -> vi_VN
|
||||
if (language === 'vi-VN') return 'vi_VN';
|
||||
// es-ES -> es_ES
|
||||
if (language === 'es-ES') return 'es_ES';
|
||||
// 默认返回 en
|
||||
return 'en';
|
||||
};
|
||||
|
||||
@@ -8,6 +8,9 @@ import enUS from './locales/en-US';
|
||||
import zhHans from './locales/zh-Hans';
|
||||
import zhHant from './locales/zh-Hant';
|
||||
import jaJP from './locales/ja-JP';
|
||||
import thTH from './locales/th-TH';
|
||||
import viVN from './locales/vi-VN';
|
||||
import esES from './locales/es-ES';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
@@ -26,6 +29,15 @@ i18n
|
||||
'ja-JP': {
|
||||
translation: jaJP,
|
||||
},
|
||||
'th-TH': {
|
||||
translation: thTH,
|
||||
},
|
||||
'vi-VN': {
|
||||
translation: viVN,
|
||||
},
|
||||
'es-ES': {
|
||||
translation: esES,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'zh-Hans',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
@@ -46,6 +46,7 @@ const enUS = {
|
||||
confirmDelete: 'Confirm Delete',
|
||||
deleteConfirmation: 'Are you sure you want to delete this?',
|
||||
selectOption: 'Select an option',
|
||||
selectPreset: 'Select Preset',
|
||||
required: 'Required',
|
||||
enable: 'Enable',
|
||||
name: 'Name',
|
||||
@@ -231,6 +232,10 @@ const enUS = {
|
||||
loginWithSpace: 'Login with Space',
|
||||
loginToUseModels: 'Login with Space to use cloud models',
|
||||
noModels: 'No models configured',
|
||||
langbotModels: 'LangBot Models',
|
||||
spaceTrialTooltip:
|
||||
'Free trial credits available! Login with Space to access cloud models with zero configuration.',
|
||||
unlockModels: 'Login to use',
|
||||
editProvider: 'Edit Provider',
|
||||
addProvider: 'Add Provider',
|
||||
addProviderHint: 'Add providers to use models from other sources',
|
||||
@@ -314,6 +319,15 @@ const enUS = {
|
||||
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
|
||||
webhookUrlHintEither:
|
||||
'Use either of the two URLs above in your platform configuration',
|
||||
webhookSaasHint:
|
||||
'Webhook requires a publicly accessible domain. LangBot Cloud provides a ready-to-use public endpoint for your bot.',
|
||||
webhookSaasLink: 'Learn more about LangBot Cloud',
|
||||
adapterCategory: {
|
||||
popular: 'Popular',
|
||||
china: 'China',
|
||||
global: 'Global',
|
||||
protocol: 'Protocol',
|
||||
},
|
||||
logLevel: 'Log Level',
|
||||
allLevels: 'All Levels',
|
||||
selectLevel: 'Select Level',
|
||||
@@ -479,6 +493,27 @@ const enUS = {
|
||||
confirmInstall: 'Confirm Install',
|
||||
installFromGithubDesc: 'Install plugin from GitHub Release',
|
||||
goToMarketplace: 'Go to Marketplace',
|
||||
installProgress: {
|
||||
title: 'Installing {{name}}',
|
||||
titleGeneric: 'Plugin Installation',
|
||||
overallProgress: 'Overall Progress',
|
||||
downloading: 'Downloading Plugin',
|
||||
installingDeps: 'Installing Dependencies',
|
||||
initializing: 'Initializing Settings',
|
||||
launching: 'Launching Plugin',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
downloadSize: 'Package size: {{size}}',
|
||||
depsInfo: '{{count}} dependencies to install',
|
||||
depsProgress:
|
||||
'{{installed}}/{{total}} installed · {{remaining}} remaining',
|
||||
installComplete: 'Plugin installed successfully',
|
||||
dismiss: 'Dismiss',
|
||||
background: 'Run in Background',
|
||||
taskQueue: 'Install Tasks',
|
||||
clearCompleted: 'Clear Completed',
|
||||
noTasks: 'No install tasks',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
@@ -996,6 +1031,7 @@ const enUS = {
|
||||
llmCalls: 'LLM Calls',
|
||||
embeddingCalls: 'Embedding Calls',
|
||||
modelCalls: 'Model Calls',
|
||||
feedback: 'User Feedback',
|
||||
sessions: 'Session Analysis',
|
||||
errors: 'Error Logs',
|
||||
},
|
||||
@@ -1075,6 +1111,26 @@ const enUS = {
|
||||
noErrors: 'No errors found',
|
||||
stackTrace: 'Stack Trace',
|
||||
},
|
||||
feedback: {
|
||||
title: 'User Feedback',
|
||||
totalFeedback: 'Total Feedback',
|
||||
totalLikes: 'Likes',
|
||||
totalDislikes: 'Dislikes',
|
||||
satisfactionRate: 'Satisfaction Rate',
|
||||
like: 'Like',
|
||||
dislike: 'Dislike',
|
||||
noFeedback: 'No feedback yet',
|
||||
noFeedbackDescription: 'User feedback will appear here',
|
||||
feedbackList: 'Feedback List',
|
||||
feedbackContent: 'Feedback Content',
|
||||
contextInfo: 'Context Info',
|
||||
userId: 'User ID',
|
||||
messageId: 'Message ID',
|
||||
streamId: 'Stream ID',
|
||||
inaccurateReasons: 'Inaccurate Reasons',
|
||||
platform: 'Platform',
|
||||
exportFeedback: 'Export Feedback',
|
||||
},
|
||||
queries: {
|
||||
title: 'Queries',
|
||||
},
|
||||
@@ -1117,7 +1173,7 @@ const enUS = {
|
||||
loadError: 'Failed to load wizard data',
|
||||
skip: 'Skip',
|
||||
skipConfirmMessage:
|
||||
'You can re-enter the Quick Start wizard from the sidebar later, or create a bot manually.',
|
||||
'You can re-enter the Quick Start wizard from the account menu later, or create a bot manually.',
|
||||
skipConfirmOk: 'OK',
|
||||
prev: 'Previous',
|
||||
next: 'Next',
|
||||
@@ -1128,6 +1184,8 @@ const enUS = {
|
||||
botSaveSuccess: 'Bot configuration saved and enabled!',
|
||||
createError: 'Failed to create resources',
|
||||
spaceAuthError: 'Failed to initiate Space authorization',
|
||||
skipSaveError: 'Failed to save skip status. Please try again.',
|
||||
completeSaveError: 'Failed to save completion status. Please try again.',
|
||||
step: {
|
||||
platform: 'Platform',
|
||||
botConfig: 'Bot Setup',
|
||||
|
||||
1250
web/src/i18n/locales/es-ES.ts
Normal file
1250
web/src/i18n/locales/es-ES.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@
|
||||
confirmDelete: '削除の確認',
|
||||
deleteConfirmation: '本当に削除しますか?',
|
||||
selectOption: 'オプションを選択',
|
||||
selectPreset: 'プリセットを選択',
|
||||
required: '必須',
|
||||
enable: '有効にする',
|
||||
name: '名前',
|
||||
@@ -236,6 +237,10 @@
|
||||
loginWithSpace: 'Space でログイン',
|
||||
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
|
||||
noModels: 'モデルがありません',
|
||||
langbotModels: 'LangBot モデル',
|
||||
spaceTrialTooltip:
|
||||
'無料トライアルクレジットが利用可能!Space でログインして、設定不要でクラウドモデルを使用できます。',
|
||||
unlockModels: 'ログインして使用',
|
||||
editProvider: 'プロバイダーを編集',
|
||||
addProvider: 'プロバイダーを追加',
|
||||
addProviderHint:
|
||||
@@ -319,6 +324,15 @@
|
||||
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
|
||||
webhookUrlHintEither:
|
||||
'上記の2つのURLのいずれかをプラットフォーム設定に使用してください',
|
||||
webhookSaasHint:
|
||||
'Webhook には公開アクセス可能なドメインが必要です。LangBot Cloud では、ボット用のパブリックエンドポイントをすぐにご利用いただけます。',
|
||||
webhookSaasLink: 'LangBot Cloud の詳細はこちら',
|
||||
adapterCategory: {
|
||||
popular: '人気',
|
||||
china: '中国',
|
||||
global: 'グローバル',
|
||||
protocol: 'プロトコル',
|
||||
},
|
||||
logLevel: 'ログレベル',
|
||||
allLevels: 'すべてのレベル',
|
||||
selectLevel: 'レベルを選択',
|
||||
@@ -479,6 +493,27 @@
|
||||
confirmInstall: 'インストールを確認',
|
||||
installFromGithubDesc: 'GitHubリリースからプラグインをインストール',
|
||||
goToMarketplace: 'マーケットプレイスへ',
|
||||
installProgress: {
|
||||
title: '{{name}} をインストール中',
|
||||
titleGeneric: 'プラグインのインストール',
|
||||
overallProgress: '全体の進捗',
|
||||
downloading: 'プラグインをダウンロード中',
|
||||
installingDeps: '依存関係をインストール中',
|
||||
initializing: '設定を初期化中',
|
||||
launching: 'プラグインを起動中',
|
||||
completed: '完了',
|
||||
failed: '失敗',
|
||||
downloadSize: 'パッケージサイズ: {{size}}',
|
||||
depsInfo: '{{count}} 個の依存関係をインストール',
|
||||
depsProgress:
|
||||
'{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個',
|
||||
installComplete: 'プラグインのインストール完了',
|
||||
dismiss: '閉じる',
|
||||
background: 'バックグラウンドで実行',
|
||||
taskQueue: 'インストールタスク',
|
||||
clearCompleted: '完了を消去',
|
||||
noTasks: 'インストールタスクはありません',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'プラグインを検索...',
|
||||
@@ -1091,7 +1126,7 @@
|
||||
loadError: 'ウィザードデータの読み込みに失敗しました',
|
||||
skip: 'スキップ',
|
||||
skipConfirmMessage:
|
||||
'サイドバーからクイックスタートウィザードに再度アクセスするか、手動でボットを作成できます。',
|
||||
'アカウントメニューからクイックスタートウィザードに再度アクセスするか、手動でボットを作成できます。',
|
||||
skipConfirmOk: 'OK',
|
||||
prev: '前へ',
|
||||
next: '次へ',
|
||||
@@ -1102,6 +1137,8 @@
|
||||
botSaveSuccess: 'ボット設定が保存され、有効になりました!',
|
||||
createError: 'リソースの作成に失敗しました',
|
||||
spaceAuthError: 'Space 認証の開始に失敗しました',
|
||||
skipSaveError: 'スキップ状態の保存に失敗しました。もう一度お試しください。',
|
||||
completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。',
|
||||
step: {
|
||||
platform: 'プラットフォーム',
|
||||
botConfig: 'ボット設定',
|
||||
|
||||
1193
web/src/i18n/locales/th-TH.ts
Normal file
1193
web/src/i18n/locales/th-TH.ts
Normal file
File diff suppressed because it is too large
Load Diff
1214
web/src/i18n/locales/vi-VN.ts
Normal file
1214
web/src/i18n/locales/vi-VN.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ const zhHans = {
|
||||
installedPlugins: '已安装插件',
|
||||
pluginMarket: '插件市场',
|
||||
mcpServers: 'MCP 服务器',
|
||||
quickStart: '快速开始',
|
||||
quickStart: '快速开始向导',
|
||||
},
|
||||
common: {
|
||||
login: '登录',
|
||||
@@ -45,6 +45,7 @@ const zhHans = {
|
||||
confirmDelete: '确认删除',
|
||||
deleteConfirmation: '你确定要删除这个吗?',
|
||||
selectOption: '选择一个选项',
|
||||
selectPreset: '选择预设',
|
||||
required: '必填',
|
||||
enable: '是否启用',
|
||||
name: '名称',
|
||||
@@ -222,6 +223,10 @@ const zhHans = {
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
loginToUseModels: '通过 Space 登录以使用云端模型',
|
||||
noModels: '暂无模型',
|
||||
langbotModels: 'LangBot 模型',
|
||||
spaceTrialTooltip:
|
||||
'免费试用积分已就绪!通过 Space 登录即可零配置使用云端模型。',
|
||||
unlockModels: '登录以使用',
|
||||
editProvider: '编辑供应商',
|
||||
addProvider: '添加供应商',
|
||||
addProviderHint: '添加自定义供应商以使用其他来源的模型',
|
||||
@@ -299,6 +304,15 @@ const zhHans = {
|
||||
webhookUrlHint:
|
||||
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
|
||||
webhookUrlHintEither: '以上两个地址任选其一填入平台配置即可',
|
||||
webhookSaasHint:
|
||||
'Webhook 需要公网可访问的域名。LangBot Cloud 为你的机器人提供开箱即用的公网地址。',
|
||||
webhookSaasLink: '了解 LangBot Cloud',
|
||||
adapterCategory: {
|
||||
popular: '热门',
|
||||
china: '中国',
|
||||
global: '全球',
|
||||
protocol: '协议',
|
||||
},
|
||||
logLevel: '日志级别',
|
||||
allLevels: '全部级别',
|
||||
selectLevel: '选择级别',
|
||||
@@ -455,6 +469,26 @@ const zhHans = {
|
||||
confirmInstall: '确认安装',
|
||||
installFromGithubDesc: '从 GitHub Release 安装插件',
|
||||
goToMarketplace: '前往插件市场',
|
||||
installProgress: {
|
||||
title: '正在安装 {{name}}',
|
||||
titleGeneric: '插件安装',
|
||||
overallProgress: '总体进度',
|
||||
downloading: '下载插件',
|
||||
installingDeps: '安装依赖',
|
||||
initializing: '初始化配置',
|
||||
launching: '启动插件',
|
||||
completed: '已完成',
|
||||
failed: '安装失败',
|
||||
downloadSize: '包大小: {{size}}',
|
||||
depsInfo: '共 {{count}} 个依赖需要安装',
|
||||
depsProgress: '已安装 {{installed}}/{{total}} · 剩余 {{remaining}} 个',
|
||||
installComplete: '插件安装成功',
|
||||
dismiss: '关闭',
|
||||
background: '后台运行',
|
||||
taskQueue: '安装任务',
|
||||
clearCompleted: '清除已完成',
|
||||
noTasks: '暂无安装任务',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: '搜索插件...',
|
||||
@@ -943,6 +977,7 @@ const zhHans = {
|
||||
llmCalls: 'LLM调用',
|
||||
embeddingCalls: 'Embedding调用',
|
||||
modelCalls: '模型调用',
|
||||
feedback: '用户反馈',
|
||||
sessions: '会话分析',
|
||||
errors: '错误日志',
|
||||
},
|
||||
@@ -1022,6 +1057,26 @@ const zhHans = {
|
||||
noErrors: '未找到错误',
|
||||
stackTrace: '堆栈追踪',
|
||||
},
|
||||
feedback: {
|
||||
title: '用户反馈',
|
||||
totalFeedback: '总反馈数',
|
||||
totalLikes: '点赞数',
|
||||
totalDislikes: '点踩数',
|
||||
satisfactionRate: '满意度',
|
||||
like: '点赞',
|
||||
dislike: '点踩',
|
||||
noFeedback: '暂无反馈',
|
||||
noFeedbackDescription: '用户反馈将在此显示',
|
||||
feedbackList: '反馈列表',
|
||||
feedbackContent: '反馈内容',
|
||||
contextInfo: '上下文信息',
|
||||
userId: '用户ID',
|
||||
messageId: '消息ID',
|
||||
streamId: '流ID',
|
||||
inaccurateReasons: '不准确原因',
|
||||
platform: '平台',
|
||||
exportFeedback: '导出反馈',
|
||||
},
|
||||
queries: {
|
||||
title: '查询记录',
|
||||
},
|
||||
@@ -1064,7 +1119,7 @@ const zhHans = {
|
||||
loadError: '加载向导数据失败',
|
||||
skip: '跳过',
|
||||
skipConfirmMessage:
|
||||
'您之后可以在侧边栏重新进入快速开始向导,或手动创建机器人。',
|
||||
'您之后可以在账户菜单重新进入快速开始向导,或手动创建机器人。',
|
||||
skipConfirmOk: '确定',
|
||||
prev: '上一步',
|
||||
next: '下一步',
|
||||
@@ -1075,6 +1130,8 @@ const zhHans = {
|
||||
botSaveSuccess: '机器人配置已保存并启用!',
|
||||
createError: '创建资源失败',
|
||||
spaceAuthError: '无法发起 Space 授权',
|
||||
skipSaveError: '保存跳过状态失败,请重试。',
|
||||
completeSaveError: '保存完成状态失败,请重试。',
|
||||
step: {
|
||||
platform: '平台接入',
|
||||
botConfig: '机器人配置',
|
||||
|
||||
@@ -45,6 +45,7 @@ const zhHant = {
|
||||
confirmDelete: '確認刪除',
|
||||
deleteConfirmation: '您確定要刪除這個嗎?',
|
||||
selectOption: '選擇一個選項',
|
||||
selectPreset: '選擇預設',
|
||||
required: '必填',
|
||||
enable: '是否啟用',
|
||||
name: '名稱',
|
||||
@@ -221,6 +222,10 @@ const zhHant = {
|
||||
loginWithSpace: '使用 Space 登入',
|
||||
loginToUseModels: '使用 Space 登入以使用雲端模型',
|
||||
noModels: '暫無模型',
|
||||
langbotModels: 'LangBot 模型',
|
||||
spaceTrialTooltip:
|
||||
'免費試用積分已就緒!使用 Space 登入即可零設定使用雲端模型。',
|
||||
unlockModels: '登入以使用',
|
||||
editProvider: '編輯供應商',
|
||||
addProvider: '新增供應商',
|
||||
addProviderHint: '新增供應商以使用其他來源的模型',
|
||||
@@ -298,6 +303,15 @@ const zhHant = {
|
||||
webhookUrlHint:
|
||||
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
|
||||
webhookUrlHintEither: '以上兩個地址任選其一填入平台配置即可',
|
||||
webhookSaasHint:
|
||||
'Webhook 需要公網可存取的網域。LangBot Cloud 為你的機器人提供即開即用的公網位址。',
|
||||
webhookSaasLink: '了解 LangBot Cloud',
|
||||
adapterCategory: {
|
||||
popular: '熱門',
|
||||
china: '中國',
|
||||
global: '全球',
|
||||
protocol: '協定',
|
||||
},
|
||||
logLevel: '日誌級別',
|
||||
allLevels: '全部級別',
|
||||
selectLevel: '選擇級別',
|
||||
@@ -448,6 +462,26 @@ const zhHant = {
|
||||
confirmInstall: '確認安裝',
|
||||
installFromGithubDesc: '從 GitHub Release 安裝插件',
|
||||
goToMarketplace: '前往外掛市場',
|
||||
installProgress: {
|
||||
title: '正在安裝 {{name}}',
|
||||
titleGeneric: '外掛安裝',
|
||||
overallProgress: '整體進度',
|
||||
downloading: '下載外掛',
|
||||
installingDeps: '安裝依賴',
|
||||
initializing: '初始化設定',
|
||||
launching: '啟動外掛',
|
||||
completed: '已完成',
|
||||
failed: '安裝失敗',
|
||||
downloadSize: '檔案大小: {{size}}',
|
||||
depsInfo: '共 {{count}} 個依賴需要安裝',
|
||||
depsProgress: '已安裝 {{installed}}/{{total}} · 剩餘 {{remaining}} 個',
|
||||
installComplete: '外掛安裝成功',
|
||||
dismiss: '關閉',
|
||||
background: '背景執行',
|
||||
taskQueue: '安裝任務',
|
||||
clearCompleted: '清除已完成',
|
||||
noTasks: '暫無安裝任務',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: '搜尋插件...',
|
||||
@@ -1031,7 +1065,7 @@ const zhHant = {
|
||||
loadError: '載入嚮導資料失敗',
|
||||
skip: '跳過',
|
||||
skipConfirmMessage:
|
||||
'您之後可以在側邊欄重新進入快速開始嚮導,或手動建立機器人。',
|
||||
'您之後可以在帳戶選單重新進入快速開始嚮導,或手動建立機器人。',
|
||||
skipConfirmOk: '確定',
|
||||
prev: '上一步',
|
||||
next: '下一步',
|
||||
@@ -1042,6 +1076,8 @@ const zhHant = {
|
||||
botSaveSuccess: '機器人配置已儲存並啟用!',
|
||||
createError: '建立資源失敗',
|
||||
spaceAuthError: '無法發起 Space 授權',
|
||||
skipSaveError: '儲存跳過狀態失敗,請重試。',
|
||||
completeSaveError: '儲存完成狀態失敗,請重試。',
|
||||
step: {
|
||||
platform: '平台接入',
|
||||
botConfig: '機器人配置',
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
-webkit-text-size-adjust: 100%;
|
||||
color: var(--color-fg-default);
|
||||
background-color: transparent;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans',
|
||||
Helvetica, Arial, sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica,
|
||||
Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
@@ -139,8 +140,9 @@
|
||||
font-size: 85%;
|
||||
background-color: var(--color-neutral-muted);
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
'Liberation Mono', monospace;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
|
||||
Reference in New Issue
Block a user