diff --git a/README.md b/README.md index 7d7f5cf3..ba6bbf90 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ docker compose up -d | [xAI](https://x.ai/) | ✅ | | | [智谱AI](https://open.bigmodel.cn/) | ✅ | | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 资源平台 | +| [302 AI](https://share.302.ai/SuTG99) | ✅ | 大模型聚合平台 | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | | | [Dify](https://dify.ai) | ✅ | LLMOps 平台 | | [Ollama](https://ollama.com/) | ✅ | 本地大模型运行平台 | diff --git a/README_EN.md b/README_EN.md index 2f898ccd..0542f16d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -118,6 +118,7 @@ Directly use the released version to run, see the [Manual Deployment](https://do | [Zhipu AI](https://open.bigmodel.cn/) | ✅ | | | [Dify](https://dify.ai) | ✅ | LLMOps platform | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM and GPU resource platform | +| [302 AI](https://share.302.ai/SuTG99) | ✅ | LLM gateway(MaaS) | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | | | [Ollama](https://ollama.com/) | ✅ | Local LLM running platform | | [LMStudio](https://lmstudio.ai/) | ✅ | Local LLM running platform | diff --git a/README_JP.md b/README_JP.md index e971cf00..7a3a16dd 100644 --- a/README_JP.md +++ b/README_JP.md @@ -116,6 +116,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール | [xAI](https://x.ai/) | ✅ | | | [Zhipu AI](https://open.bigmodel.cn/) | ✅ | | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型とGPUリソースプラットフォーム | +| [302 AI](https://share.302.ai/SuTG99) | ✅ | LLMゲートウェイ(MaaS) | | [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | | | [Dify](https://dify.ai) | ✅ | LLMOpsプラットフォーム | | [Ollama](https://ollama.com/) | ✅ | ローカルLLM実行プラットフォーム | diff --git a/pkg/core/boot.py b/pkg/core/boot.py index b8c5a974..aff117e6 100644 --- a/pkg/core/boot.py +++ b/pkg/core/boot.py @@ -1,4 +1,4 @@ -from __future__ import print_function +from __future__ import annotations import traceback import asyncio diff --git a/pkg/core/stages/show_notes.py b/pkg/core/stages/show_notes.py index e7c98b42..5fa7ff08 100644 --- a/pkg/core/stages/show_notes.py +++ b/pkg/core/stages/show_notes.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio + from .. import stage, app, note from ...utils import importutil @@ -20,11 +22,15 @@ class ShowNotesStage(stage.BootingStage): try: note_inst = note_cls(ap) if await note_inst.need_show(): - async for ret in note_inst.yield_note(): - if not ret: - continue - msg, level = ret - if msg: - ap.logger.log(level, msg) + + async def ayield_note(note_inst: note.LaunchNote): + async for ret in note_inst.yield_note(): + if not ret: + continue + msg, level = ret + if msg: + ap.logger.log(level, msg) + + asyncio.create_task(ayield_note(note_inst)) except Exception: continue diff --git a/web/src/app/home/bots/ICreateBotField.ts b/pkg/entity/errors/__init__.py similarity index 100% rename from web/src/app/home/bots/ICreateBotField.ts rename to pkg/entity/errors/__init__.py diff --git a/pkg/entity/errors/platform.py b/pkg/entity/errors/platform.py new file mode 100644 index 00000000..75fc2299 --- /dev/null +++ b/pkg/entity/errors/platform.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class AdapterNotFoundError(Exception): + def __init__(self, adapter_name: str): + self.adapter_name = adapter_name + + def __str__(self): + return f'Adapter {self.adapter_name} not found' diff --git a/pkg/entity/errors/provider.py b/pkg/entity/errors/provider.py new file mode 100644 index 00000000..b495a6b0 --- /dev/null +++ b/pkg/entity/errors/provider.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class RequesterNotFoundError(Exception): + def __init__(self, requester_name: str): + self.requester_name = requester_name + + def __str__(self): + return f'Requester {self.requester_name} not found' diff --git a/pkg/platform/botmgr.py b/pkg/platform/botmgr.py index 12803916..1da5eec8 100644 --- a/pkg/platform/botmgr.py +++ b/pkg/platform/botmgr.py @@ -15,6 +15,8 @@ from ..discover import engine from ..entity.persistence import bot as persistence_bot +from ..entity.errors import platform as platform_errors + from .logger import EventLogger # 处理 3.4 移除了 YiriMirai 之后,插件的兼容性问题 @@ -118,8 +120,10 @@ class RuntimeBot: if isinstance(e, asyncio.CancelledError): self.task_context.set_current_action('Exited.') return + + traceback_str = traceback.format_exc() self.task_context.set_current_action('Exited with error.') - await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback.format_exc()}') + await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback_str}') self.task_wrapper = self.ap.task_mgr.create_task( exception_wrapper(), @@ -205,7 +209,12 @@ class PlatformManager: for bot in bots: # load all bots here, enable or disable will be handled in runtime - await self.load_bot(bot) + try: + await self.load_bot(bot) + except platform_errors.AdapterNotFoundError as e: + self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}') + except Exception as e: + self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\n{traceback.format_exc()}') async def load_bot( self, @@ -219,6 +228,9 @@ class PlatformManager: logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap) + if bot_entity.adapter not in self.adapter_dict: + raise platform_errors.AdapterNotFoundError(bot_entity.adapter) + adapter_inst = self.adapter_dict[bot_entity.adapter]( bot_entity.adapter_config, self.ap, diff --git a/pkg/platform/sources/dingtalk.py b/pkg/platform/sources/dingtalk.py index 675911a5..3147c984 100644 --- a/pkg/platform/sources/dingtalk.py +++ b/pkg/platform/sources/dingtalk.py @@ -22,7 +22,7 @@ class DingTalkMessageConverter(adapter.MessageConverter): at = True if type(msg) is platform_message.Plain: content += msg.text - return content,at + return content, at @staticmethod async def target2yiri(event: DingTalkEvent, bot_name: str): @@ -116,15 +116,6 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter): self.bot_account_id = self.config['robot_name'] - self.bot = DingTalkClient( - client_id=config['client_id'], - client_secret=config['client_secret'], - robot_name=config['robot_name'], - robot_code=config['robot_code'], - markdown_card=config['markdown_card'], - logger=self.logger, - ) - async def reply_message( self, message_source: platform_events.MessageEvent, @@ -136,8 +127,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter): ) incoming_message = event.incoming_message - content,at = await DingTalkMessageConverter.yiri2target(message) - await self.bot.send_message(content, incoming_message,at) + content, at = await DingTalkMessageConverter.yiri2target(message) + await self.bot.send_message(content, incoming_message, at) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): content = await DingTalkMessageConverter.yiri2target(message) @@ -157,8 +148,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter): await self.event_converter.target2yiri(event, self.config['robot_name']), self, ) - except Exception as e: - await self.logger.error(f"Error in dingtalk callback: {traceback.format_exc()}") + except Exception: + await self.logger.error(f'Error in dingtalk callback: {traceback.format_exc()}') if event_type == platform_events.FriendMessage: self.bot.on_message('FriendMessage')(on_message) @@ -166,6 +157,15 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter): self.bot.on_message('GroupMessage')(on_message) async def run_async(self): + config = self.config + self.bot = DingTalkClient( + client_id=config['client_id'], + client_secret=config['client_secret'], + robot_name=config['robot_name'], + robot_code=config['robot_code'], + markdown_card=config['markdown_card'], + logger=self.logger, + ) await self.bot.start() async def kill(self) -> bool: diff --git a/pkg/platform/sources/discord.py b/pkg/platform/sources/discord.py index f159c628..4f5cac28 100644 --- a/pkg/platform/sources/discord.py +++ b/pkg/platform/sources/discord.py @@ -8,6 +8,7 @@ import base64 import uuid import os import datetime +import io import aiohttp @@ -35,28 +36,88 @@ class DiscordMessageConverter(adapter.MessageConverter): for ele in message_chain: if isinstance(ele, platform_message.Image): image_bytes = None + filename = f'{uuid.uuid4()}.png' # 默认文件名 if ele.base64: - image_bytes = base64.b64decode(ele.base64) + # 处理base64编码的图片 + if ele.base64.startswith('data:'): + # 从data URL中提取文件类型 + data_header = ele.base64.split(',')[0] + if 'jpeg' in data_header or 'jpg' in data_header: + filename = f'{uuid.uuid4()}.jpg' + elif 'gif' in data_header: + filename = f'{uuid.uuid4()}.gif' + elif 'webp' in data_header: + filename = f'{uuid.uuid4()}.webp' + # 去掉data:image/xxx;base64,前缀 + base64_data = ele.base64.split(',')[1] + else: + base64_data = ele.base64 + image_bytes = base64.b64decode(base64_data) elif ele.url: + # 从URL下载图片 async with aiohttp.ClientSession() as session: async with session.get(ele.url) as response: image_bytes = await response.read() + # 从URL或Content-Type推断文件类型 + content_type = response.headers.get('Content-Type', '') + if 'jpeg' in content_type or 'jpg' in content_type: + filename = f'{uuid.uuid4()}.jpg' + elif 'gif' in content_type: + filename = f'{uuid.uuid4()}.gif' + elif 'webp' in content_type: + filename = f'{uuid.uuid4()}.webp' + elif ele.url.lower().endswith(('.jpg', '.jpeg')): + filename = f'{uuid.uuid4()}.jpg' + elif ele.url.lower().endswith('.gif'): + filename = f'{uuid.uuid4()}.gif' + elif ele.url.lower().endswith('.webp'): + filename = f'{uuid.uuid4()}.webp' elif ele.path: - with open(ele.path, 'rb') as f: - image_bytes = f.read() + # 从文件路径读取图片 + # 确保路径没有空字节 + clean_path = ele.path.replace('\x00', '') + clean_path = os.path.abspath(clean_path) + + if not os.path.exists(clean_path): + continue # 跳过不存在的文件 + + try: + with open(clean_path, 'rb') as f: + image_bytes = f.read() + # 从文件路径获取文件名,保持原始扩展名 + original_filename = os.path.basename(clean_path) + if original_filename and '.' in original_filename: + # 保持原始文件名的扩展名 + ext = original_filename.split('.')[-1].lower() + filename = f'{uuid.uuid4()}.{ext}' + else: + # 如果没有扩展名,尝试从文件内容检测 + if image_bytes.startswith(b'\xff\xd8\xff'): + filename = f'{uuid.uuid4()}.jpg' + elif image_bytes.startswith(b'GIF'): + filename = f'{uuid.uuid4()}.gif' + elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]: + filename = f'{uuid.uuid4()}.webp' + # 默认保持PNG + except Exception as e: + print(f"Error reading image file {clean_path}: {e}") + continue # 跳过读取失败的文件 - image_files.append(discord.File(fp=image_bytes, filename=f'{uuid.uuid4()}.png')) + if image_bytes: + # 使用BytesIO创建文件对象,避免路径问题 + import io + image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename)) elif isinstance(ele, platform_message.Plain): text_string += ele.text elif isinstance(ele, platform_message.Forward): for node in ele.node_list: ( - text_string, - image_files, + node_text, + node_images, ) = await DiscordMessageConverter.yiri2target(node.message_chain) - text_string += text_string - image_files.extend(image_files) + text_string += node_text + image_files.extend(node_images) return text_string, image_files @@ -199,7 +260,27 @@ class DiscordAdapter(adapter.MessagePlatformAdapter): self.bot = MyClient(intents=intents, **args) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): - pass + msg_to_send, image_files = await self.message_converter.yiri2target(message) + + try: + # 获取频道对象 + channel = self.bot.get_channel(int(target_id)) + if channel is None: + # 如果本地缓存中没有,尝试从API获取 + channel = await self.bot.fetch_channel(int(target_id)) + + args = { + 'content': msg_to_send, + } + + if len(image_files) > 0: + args['files'] = image_files + + await channel.send(**args) + + except Exception as e: + await self.logger.error(f"Discord send_message failed: {e}") + raise e async def reply_message( self, diff --git a/pkg/platform/sources/lark.py b/pkg/platform/sources/lark.py index d1116362..49ff53be 100644 --- a/pkg/platform/sources/lark.py +++ b/pkg/platform/sources/lark.py @@ -9,6 +9,7 @@ import re import base64 import uuid import json +import time import datetime import hashlib from Crypto.Cipher import AES @@ -320,6 +321,10 @@ class LarkEventConverter(adapter.EventConverter): ) +CARD_ID_CACHE_SIZE = 500 +CARD_ID_CACHE_MAX_LIFETIME = 20 * 60 # 20分钟 + + class LarkAdapter(adapter.MessagePlatformAdapter): bot: lark_oapi.ws.Client api_client: lark_oapi.Client @@ -338,6 +343,8 @@ class LarkAdapter(adapter.MessagePlatformAdapter): config: dict quart_app: quart.Quart ap: app.Application + + message_id_to_card_id: typing.Dict[str, typing.Tuple[str, int]] def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config @@ -345,6 +352,7 @@ class LarkAdapter(adapter.MessagePlatformAdapter): self.logger = logger self.quart_app = quart.Quart(__name__) self.listeners = {} + self.message_id_to_card_id = {} @self.quart_app.route('/lark/callback', methods=['POST']) async def lark_callback(): @@ -390,6 +398,19 @@ class LarkAdapter(adapter.MessagePlatformAdapter): return {'code': 500, 'message': 'error'} async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): + if self.config['enable-card-reply'] and event.event.message.message_id not in self.message_id_to_card_id: + self.ap.logger.debug('卡片回复模式开启') + # 开启卡片回复模式. 这里可以实现飞书一发消息,马上创建卡片进行回复"思考中..." + reply_message_id = await self.create_message_card(event.event.message.message_id) + self.message_id_to_card_id[event.event.message.message_id] = (reply_message_id, time.time()) + + if len(self.message_id_to_card_id) > CARD_ID_CACHE_SIZE: + self.message_id_to_card_id = { + k: v + for k, v in self.message_id_to_card_id.items() + if v[1] > time.time() - CARD_ID_CACHE_MAX_LIFETIME + } + lb_event = await self.event_converter.target2yiri(event, self.api_client) await self.listeners[type(lb_event)](lb_event, self) @@ -409,11 +430,93 @@ class LarkAdapter(adapter.MessagePlatformAdapter): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass + async def create_message_card(self, message_id: str) -> str: + """ + 创建卡片消息。 + 使用卡片消息是因为普通消息更新次数有限制,而大模型流式返回结果可能很多而超过限制,而飞书卡片没有这个限制 + """ + + # TODO 目前只支持卡片模板方式,且卡片变量一定是content,未来这块要做成可配置 + # 发消息马上就会回复显示初始化的content信息,即思考中 + content = { + 'type': 'template', + 'data': {'template_id': self.config['card_template_id'], 'template_variable': {'content': 'Thinking...'}}, + } + request: ReplyMessageRequest = ( + ReplyMessageRequest.builder() + .message_id(message_id) + .request_body( + ReplyMessageRequestBody.builder().content(json.dumps(content)).msg_type('interactive').build() + ) + .build() + ) + + # 发起请求 + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request) + + # 处理失败返回 + if not response.success(): + raise Exception( + f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + return response.data.message_id + async def reply_message( self, message_source: platform_events.MessageEvent, message: platform_message.MessageChain, quote_origin: bool = False, + ): + if self.config['enable-card-reply']: + await self.reply_card_message(message_source, message, quote_origin) + else: + await self.reply_normal_message(message_source, message, quote_origin) + + async def reply_card_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + """ + 回复消息变成更新卡片消息 + """ + lark_message = await self.message_converter.yiri2target(message, self.api_client) + + text_message = '' + for ele in lark_message[0]: + if ele['tag'] == 'text': + text_message += ele['text'] + elif ele['tag'] == 'md': + text_message += ele['text'] + + content = { + 'type': 'template', + 'data': {'template_id': self.config['card_template_id'], 'template_variable': {'content': text_message}}, + } + + request: PatchMessageRequest = ( + PatchMessageRequest.builder() + .message_id(self.message_id_to_card_id[message_source.message_chain.message_id][0]) + .request_body(PatchMessageRequestBody.builder().content(json.dumps(content)).build()) + .build() + ) + + # 发起请求 + response: PatchMessageResponse = self.api_client.im.v1.message.patch(request) + + # 处理失败返回 + if not response.success(): + raise Exception( + f'client.im.v1.message.patch failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + return + + async def reply_normal_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, ): # 不再需要了,因为message_id已经被包含到message_chain中 # lark_event = await self.event_converter.yiri2target(message_source) @@ -492,4 +595,9 @@ class LarkAdapter(adapter.MessagePlatformAdapter): ) async def kill(self) -> bool: + # 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接 + # 断开时lark.ws.Client的_receive_message_loop会打印error日志: receive message loop exit。然后进行重连, + # 所以要设置_auto_reconnect=False,让其不重连。 + self.bot._auto_reconnect = False + await self.bot._disconnect() return False diff --git a/pkg/platform/sources/lark.yaml b/pkg/platform/sources/lark.yaml index f51bab76..bafaba81 100644 --- a/pkg/platform/sources/lark.yaml +++ b/pkg/platform/sources/lark.yaml @@ -65,6 +65,23 @@ spec: type: string required: true default: "" + - name: enable-card-reply + label: + en_US: Enable Card Reply Mode + zh_Hans: 启用飞书卡片回复模式 + description: + en_US: If enabled, the bot will use the card of lark reply mode + zh_Hans: 如果启用,将使用飞书卡片方式来回复内容 + type: boolean + required: true + default: false + - name: card_template_id + label: + en_US: card template id + zh_Hans: 卡片模板ID + type: string + required: true + default: "填写你的卡片template_id" execution: python: path: ./lark.py diff --git a/pkg/platform/sources/wechatpad.png b/pkg/platform/sources/wechatpad.png new file mode 100644 index 00000000..603d0d9a Binary files /dev/null and b/pkg/platform/sources/wechatpad.png differ diff --git a/pkg/platform/sources/wechatpad.yaml b/pkg/platform/sources/wechatpad.yaml index df2970a2..b936dcae 100644 --- a/pkg/platform/sources/wechatpad.yaml +++ b/pkg/platform/sources/wechatpad.yaml @@ -8,6 +8,7 @@ metadata: description: en_US: WeChatPad Adapter zh_CN: WeChatPad 适配器 + icon: wechatpad.png spec: config: - name: wechatpad_url diff --git a/pkg/provider/modelmgr/modelmgr.py b/pkg/provider/modelmgr/modelmgr.py index 6bc80fe3..b15e53a9 100644 --- a/pkg/provider/modelmgr/modelmgr.py +++ b/pkg/provider/modelmgr/modelmgr.py @@ -1,12 +1,14 @@ from __future__ import annotations import sqlalchemy +import traceback from . import entities, requester from ...core import app from ...discover import engine from . import token from ...entity.persistence import model as persistence_model +from ...entity.errors import provider as provider_errors FETCH_MODEL_LIST_URL = 'https://api.qchatgpt.rockchin.top/api/v2/fetch/model_list' @@ -64,7 +66,12 @@ class ModelManager: # load models for llm_model in llm_models: - await self.load_llm_model(llm_model) + try: + await self.load_llm_model(llm_model) + except provider_errors.RequesterNotFoundError as e: + self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping model {llm_model.uuid}') + except Exception as e: + self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}') async def init_runtime_llm_model( self, @@ -76,6 +83,9 @@ class ModelManager: elif isinstance(model_info, dict): model_info = persistence_model.LLMModel(**model_info) + if model_info.requester not in self.requester_dict: + raise provider_errors.RequesterNotFoundError(model_info.requester) + requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config) await requester_inst.initialize() diff --git a/pkg/provider/modelmgr/requesters/302ai.png b/pkg/provider/modelmgr/requesters/302ai.png new file mode 100644 index 00000000..f1b21c9d Binary files /dev/null and b/pkg/provider/modelmgr/requesters/302ai.png differ diff --git a/pkg/provider/modelmgr/requesters/302aichatcmpl.py b/pkg/provider/modelmgr/requesters/302aichatcmpl.py new file mode 100644 index 00000000..bd9aaccd --- /dev/null +++ b/pkg/provider/modelmgr/requesters/302aichatcmpl.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import typing +import openai + +from . import chatcmpl + + +class AI302ChatCompletions(chatcmpl.OpenAIChatCompletions): + """302 AI ChatCompletion API 请求器""" + + client: openai.AsyncClient + + default_config: dict[str, typing.Any] = { + 'base_url': 'https://api.302.ai/v1', + 'timeout': 120, + } diff --git a/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml b/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml new file mode 100644 index 00000000..9d8ce9ea --- /dev/null +++ b/pkg/provider/modelmgr/requesters/302aichatcmpl.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: 302-ai-chat-completions + label: + en_US: 302 AI + zh_Hans: 302 AI + icon: 302ai.png +spec: + config: + - name: base_url + label: + en_US: Base URL + zh_Hans: 基础 URL + type: string + required: true + default: "https://api.302.ai/v1" + - name: timeout + label: + en_US: Timeout + zh_Hans: 超时时间 + type: integer + required: true + default: 120 +execution: + python: + path: ./302aichatcmpl.py + attr: AI302ChatCompletions \ No newline at end of file diff --git a/pkg/provider/runners/difysvapi.py b/pkg/provider/runners/difysvapi.py index b2542491..98b50f86 100644 --- a/pkg/provider/runners/difysvapi.py +++ b/pkg/provider/runners/difysvapi.py @@ -108,7 +108,13 @@ class DifyServiceAPIRunner(runner.RequestRunner): mode = 'basic' # 标记是基础编排还是工作流编排 - basic_mode_pending_chunk = '' + stream_output_pending_chunk = '' + + batch_pending_max_size = self.pipeline_config['ai']['dify-service-api'].get( + 'output-batch-size', 0 + ) # 积累一定量的消息更新消息一次 + + batch_pending_index = 0 inputs = {} @@ -126,6 +132,13 @@ class DifyServiceAPIRunner(runner.RequestRunner): ): self.ap.logger.debug('dify-chat-chunk: ' + str(chunk)) + # 查询异常情况 + if chunk['event'] == 'error': + yield llm_entities.Message( + role='assistant', + content=f"查询异常: [{chunk['code']}]. {chunk['message']}.\n请重试,如果还报错,请用 **!reset** 命令重置对话再尝试。", + ) + if chunk['event'] == 'workflow_started': mode = 'workflow' @@ -136,15 +149,35 @@ class DifyServiceAPIRunner(runner.RequestRunner): role='assistant', content=self._try_convert_thinking(chunk['data']['outputs']['answer']), ) + elif chunk['event'] == 'message': + stream_output_pending_chunk += chunk['answer'] + if self.pipeline_config['ai']['dify-service-api'].get('enable-streaming', False): + # 消息数超过量就输出,从而达到streaming的效果 + batch_pending_index += 1 + if batch_pending_index >= batch_pending_max_size: + yield llm_entities.Message( + role='assistant', + content=self._try_convert_thinking(stream_output_pending_chunk), + ) + batch_pending_index = 0 elif mode == 'basic': if chunk['event'] == 'message': - basic_mode_pending_chunk += chunk['answer'] + stream_output_pending_chunk += chunk['answer'] + if self.pipeline_config['ai']['dify-service-api'].get('enable-streaming', False): + # 消息数超过量就输出,从而达到streaming的效果 + batch_pending_index += 1 + if batch_pending_index >= batch_pending_max_size: + yield llm_entities.Message( + role='assistant', + content=self._try_convert_thinking(stream_output_pending_chunk), + ) + batch_pending_index = 0 elif chunk['event'] == 'message_end': yield llm_entities.Message( role='assistant', - content=self._try_convert_thinking(basic_mode_pending_chunk), + content=self._try_convert_thinking(stream_output_pending_chunk), ) - basic_mode_pending_chunk = '' + stream_output_pending_chunk = '' if chunk is None: raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') diff --git a/templates/metadata/pipeline/ai.yaml b/templates/metadata/pipeline/ai.yaml index 90732dc8..fb2672d4 100644 --- a/templates/metadata/pipeline/ai.yaml +++ b/templates/metadata/pipeline/ai.yaml @@ -128,6 +128,21 @@ stages: label: en_US: Remove zh_Hans: 移除 + - name: enable-streaming + label: + en_US: enable streaming mode + zh_Hans: 开启流式输出 + type: boolean + required: true + default: false + - name: output-batch-size + label: + en_US: output batch size + zh_Hans: 输出批次大小(积累多少条消息后一起输出) + type: integer + required: true + default: 10 + - name: dashscope-app-api label: en_US: Aliyun Dashscope App API diff --git a/web/package.json b/web/package.json index 6d1fca11..17516ac4 100644 --- a/web/package.json +++ b/web/package.json @@ -21,17 +21,19 @@ "@dnd-kit/sortable": "^10.0.0", "@hookform/resolvers": "^5.0.1", "@radix-ui/react-checkbox": "^1.3.1", - "@radix-ui/react-dialog": "^1.1.13", + "@radix-ui/react-dialog": "^1.1.14", "@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-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.4", - "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-toggle": "^1.1.8", "@radix-ui/react-toggle-group": "^1.1.9", + "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/postcss": "^4.1.5", "axios": "^1.8.4", "class-variance-authority": "^0.7.1", diff --git a/web/src/app/home/bots/BotDetailDialog.tsx b/web/src/app/home/bots/BotDetailDialog.tsx new file mode 100644 index 00000000..1c4a2403 --- /dev/null +++ b/web/src/app/home/bots/BotDetailDialog.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from '@/components/ui/sidebar'; +import { Button } from '@/components/ui/button'; +import BotForm from '@/app/home/bots/components/bot-form/BotForm'; +import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +interface BotDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + botId?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onFormSubmit: (value: z.infer) => void; + onFormCancel: () => void; + onBotDeleted: () => void; + onNewBotCreated: (botId: string) => void; +} + +export default function BotDetailDialog({ + open, + onOpenChange, + botId: propBotId, + onFormSubmit, + onFormCancel, + onBotDeleted, + onNewBotCreated, +}: BotDetailDialogProps) { + const { t } = useTranslation(); + const [botId, setBotId] = useState(propBotId); + const [activeMenu, setActiveMenu] = useState('config'); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + useEffect(() => { + setBotId(propBotId); + setActiveMenu('config'); + }, [propBotId, open]); + + const menu = [ + { + key: 'config', + label: t('bots.configuration'), + icon: ( + + + + ), + }, + { + key: 'logs', + label: t('bots.logs'), + icon: ( + + + + ), + }, + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleFormSubmit = (value: any) => { + onFormSubmit(value); + }; + + const handleFormCancel = () => { + onFormCancel(); + }; + + const handleBotDeleted = () => { + httpClient.deleteBot(botId ?? '').then(() => { + onBotDeleted(); + }); + }; + + const handleNewBotCreated = (newBotId: string) => { + setBotId(newBotId); + setActiveMenu('config'); + onNewBotCreated(newBotId); + }; + + const handleDelete = () => { + setShowDeleteConfirm(true); + }; + + const confirmDelete = () => { + handleBotDeleted(); + setShowDeleteConfirm(false); + }; + + if (!botId) { + return ( + <> + + +
+ + {t('bots.createBot')} + +
+ +
+ +
+ + +
+
+
+
+
+ + ); + } + + return ( + <> + + + + + + + + + {menu.map((item) => ( + + setActiveMenu(item.key)} + > + + {item.icon} + {item.label} + + + + ))} + + + + + +
+ + + {activeMenu === 'config' + ? t('bots.editBot') + : t('bots.botLogTitle')} + + +
+ {activeMenu === 'config' && ( + + )} + {activeMenu === 'logs' && botId && ( + + )} +
+ {activeMenu === 'config' && ( + +
+ + + +
+
+ )} +
+
+
+
+ + {/* 删除确认对话框 */} + + + + {t('common.confirmDelete')} + +
{t('bots.deleteConfirmation')}
+ + + + +
+
+ + ); +} diff --git a/web/src/app/home/bots/components/bot-card/BotCard.tsx b/web/src/app/home/bots/components/bot-card/BotCard.tsx index 5d44a8d6..3551ed66 100644 --- a/web/src/app/home/bots/components/bot-card/BotCard.tsx +++ b/web/src/app/home/bots/components/bot-card/BotCard.tsx @@ -4,21 +4,15 @@ import { httpClient } from '@/app/infra/http/HttpClient'; import { Switch } from '@/components/ui/switch'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { Button } from '@/components/ui/button'; export default function BotCard({ botCardVO, - clickLogIconCallback, setBotEnableCallback, }: { botCardVO: BotCardVO; - clickLogIconCallback: (id: string) => void; setBotEnableCallback: (id: string, enable: boolean) => void; }) { const { t } = useTranslation(); - function onClickLogIcon() { - clickLogIconCallback(botCardVO.id); - } function setBotEnable(enable: boolean) { return httpClient.updateBot(botCardVO.id, { @@ -93,25 +87,6 @@ export default function BotCard({ e.stopPropagation(); }} /> - diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index c2c79e41..40a902c2 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -67,12 +67,14 @@ export default function BotForm({ onFormCancel, onBotDeleted, onNewBotCreated, + hideButtons = false, }: { initBotId?: string; onFormSubmit: (value: z.infer>) => void; onFormCancel: () => void; onBotDeleted: () => void; onNewBotCreated: (botId: string) => void; + hideButtons?: boolean; }) { const { t } = useTranslation(); const formSchema = getFormSchema(t); @@ -282,7 +284,7 @@ export default function BotForm({ }) .finally(() => { setIsLoading(false); - form.reset(); + // form.reset(); // dynamicForm.resetFields(); }); } else { @@ -314,8 +316,6 @@ export default function BotForm({ // dynamicForm.resetFields(); }); } - setShowDynamicForm(false); - console.log('set loading', false); } function deleteBot() { @@ -365,6 +365,7 @@ export default function BotForm({
@@ -527,42 +528,44 @@ export default function BotForm({ )} -
-
- {!initBotId && ( - - )} - {initBotId && ( - <> + {!hideButtons && ( +
+
+ {!initBotId && ( - - - )} - + )} + {initBotId && ( + <> + + + + )} + +
-
+ )}
diff --git a/web/src/app/home/bots/bot-log/BotLogManager.ts b/web/src/app/home/bots/components/bot-log/BotLogManager.ts similarity index 100% rename from web/src/app/home/bots/bot-log/BotLogManager.ts rename to web/src/app/home/bots/components/bot-log/BotLogManager.ts diff --git a/web/src/app/home/bots/bot-log/view/BotLogCard.tsx b/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx similarity index 100% rename from web/src/app/home/bots/bot-log/view/BotLogCard.tsx rename to web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx diff --git a/web/src/app/home/bots/bot-log/view/BotLogListComponent.tsx b/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx similarity index 93% rename from web/src/app/home/bots/bot-log/view/BotLogListComponent.tsx rename to web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx index 5c8ea0ed..368df61c 100644 --- a/web/src/app/home/bots/bot-log/view/BotLogListComponent.tsx +++ b/web/src/app/home/bots/components/bot-log/view/BotLogListComponent.tsx @@ -1,9 +1,9 @@ 'use client'; -import { BotLogManager } from '@/app/home/bots/bot-log/BotLogManager'; +import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager'; import { useCallback, useEffect, useRef, useState } from 'react'; import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; -import { BotLogCard } from '@/app/home/bots/bot-log/view/BotLogCard'; +import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard'; import styles from './botLog.module.css'; import { Switch } from '@/components/ui/switch'; import { debounce } from 'lodash'; @@ -112,10 +112,7 @@ export function BotLogListComponent({ botId }: { botId: string }) { ); return ( -
+
{t('bots.enableAutoRefresh')}
setAutoFlush(e)} /> diff --git a/web/src/app/home/bots/bot-log/view/botLog.module.css b/web/src/app/home/bots/components/bot-log/view/botLog.module.css similarity index 100% rename from web/src/app/home/bots/bot-log/view/botLog.module.css rename to web/src/app/home/bots/components/bot-log/view/botLog.module.css diff --git a/web/src/app/home/bots/page.tsx b/web/src/app/home/bots/page.tsx index 33d55e61..d4305898 100644 --- a/web/src/app/home/bots/page.tsx +++ b/web/src/app/home/bots/page.tsx @@ -3,32 +3,21 @@ import { useEffect, useState } from 'react'; import styles from './botConfig.module.css'; import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO'; -import BotForm from '@/app/home/bots/components/bot-form/BotForm'; import BotCard from '@/app/home/bots/components/bot-card/BotCard'; import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent'; import { httpClient } from '@/app/infra/http/HttpClient'; import { Bot, Adapter } from '@/app/infra/entities/api'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { i18nObj } from '@/i18n/I18nProvider'; -import { BotLogListComponent } from '@/app/home/bots/bot-log/view/BotLogListComponent'; +import BotDetailDialog from '@/app/home/bots/BotDetailDialog'; export default function BotConfigPage() { const { t } = useTranslation(); - // 编辑机器人的modal - const [modalOpen, setModalOpen] = useState(false); - // 机器人日志的modal - const [logModalOpen, setLogModalOpen] = useState(false); + // 机器人详情dialog + const [detailDialogOpen, setDetailDialogOpen] = useState(false); const [botList, setBotList] = useState([]); - const [isEditForm, setIsEditForm] = useState(false); - const [nowSelectedBotUUID, setNowSelectedBotUUID] = useState(); - const [nowSelectedBotLog, setNowSelectedBotLog] = useState(); + const [selectedBotId, setSelectedBotId] = useState(''); useEffect(() => { getBotList(); @@ -73,61 +62,46 @@ export default function BotConfigPage() { } function handleCreateBotClick() { - setIsEditForm(false); - setNowSelectedBotUUID(''); - setModalOpen(true); + setSelectedBotId(''); + setDetailDialogOpen(true); } function selectBot(botUUID: string) { - setNowSelectedBotUUID(botUUID); - setIsEditForm(true); - setModalOpen(true); + setSelectedBotId(botUUID); + setDetailDialogOpen(true); } - function onClickLogIcon(botId: string) { - setNowSelectedBotLog(botId); - setLogModalOpen(true); + function handleFormSubmit() { + getBotList(); + // setDetailDialogOpen(false); + } + + function handleFormCancel() { + setDetailDialogOpen(false); + } + + function handleBotDeleted() { + getBotList(); + setDetailDialogOpen(false); + } + + function handleNewBotCreated(botId: string) { + console.log('new bot created', botId); + getBotList(); + setSelectedBotId(botId); } return (
- - - - - {isEditForm ? t('bots.editBot') : t('bots.createBot')} - - -
- { - getBotList(); - setModalOpen(false); - }} - onFormCancel={() => setModalOpen(false)} - onBotDeleted={() => { - getBotList(); - setModalOpen(false); - }} - onNewBotCreated={(botId) => { - console.log('new bot created', botId); - getBotList(); - selectBot(botId); - }} - /> -
-
-
- - - - - {t('bots.botLogTitle')} - - - - + {/* 注意:其余的返回内容需要保持在Spin组件外部 */}
@@ -147,9 +121,6 @@ export default function BotConfigPage() { > { - onClickLogIcon(id); - }} setBotEnableCallback={(id, enable) => { setBotList( botList.map((bot) => { diff --git a/web/src/app/home/pipelines/PipelineDetailDialog.tsx b/web/src/app/home/pipelines/PipelineDetailDialog.tsx new file mode 100644 index 00000000..72b4ac76 --- /dev/null +++ b/web/src/app/home/pipelines/PipelineDetailDialog.tsx @@ -0,0 +1,214 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from '@/components/ui/sidebar'; +import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent'; +import DebugDialog from './components/debug-dialog/DebugDialog'; +import { PipelineFormEntity } from '@/app/infra/entities/pipeline'; + +interface PipelineDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + pipelineId?: string; + isEditMode?: boolean; + isDefaultPipeline?: boolean; + initValues?: PipelineFormEntity; + onFinish: () => void; + onNewPipelineCreated?: (pipelineId: string) => void; + onDeletePipeline: () => void; + onCancel: () => void; +} + +type DialogMode = 'config' | 'debug'; + +export default function PipelineDialog({ + open, + onOpenChange, + pipelineId: propPipelineId, + isEditMode = false, + isDefaultPipeline = false, + initValues, + onFinish, + onNewPipelineCreated, + onDeletePipeline, + onCancel, +}: PipelineDialogProps) { + const { t } = useTranslation(); + const [pipelineId, setPipelineId] = useState( + propPipelineId, + ); + const [currentMode, setCurrentMode] = useState('config'); + + useEffect(() => { + setPipelineId(propPipelineId); + setCurrentMode('config'); + }, [propPipelineId, open]); + + const handleFinish = () => { + onFinish(); + }; + + const handleNewPipelineCreated = (newPipelineId: string) => { + setPipelineId(newPipelineId); + setCurrentMode('config'); + if (onNewPipelineCreated) { + onNewPipelineCreated(newPipelineId); + } + }; + + const menu = [ + { + key: 'config', + label: t('pipelines.configuration'), + icon: ( + + + + ), + }, + { + key: 'debug', + label: t('pipelines.debugChat'), + icon: ( + + + + ), + }, + ]; + + const getDialogTitle = () => { + if (currentMode === 'config') { + return isEditMode + ? t('pipelines.editPipeline') + : t('pipelines.createPipeline'); + } + return t('pipelines.debugDialog.title'); + }; + + // 创建新流水线时的对话框 + if (!isEditMode) { + return ( + + +
+ + {t('pipelines.createPipeline')} + +
+ { + onCancel(); + }} + /> +
+
+
+
+ ); + } + + // 编辑流水线时的对话框 + return ( + + + + + + + + + {menu.map((item) => ( + + setCurrentMode(item.key as DialogMode)} + > + + {item.icon} + {item.label} + + + + ))} + + + + + +
+ + {getDialogTitle()} + +
+ {currentMode === 'config' && ( + { + onCancel(); + }} + /> + )} + {currentMode === 'debug' && pipelineId && ( + + )} +
+
+
+
+
+ ); +} diff --git a/web/src/app/home/pipelines/debug-dialog/AtBadge.tsx b/web/src/app/home/pipelines/components/debug-dialog/AtBadge.tsx similarity index 100% rename from web/src/app/home/pipelines/debug-dialog/AtBadge.tsx rename to web/src/app/home/pipelines/components/debug-dialog/AtBadge.tsx diff --git a/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx b/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx new file mode 100644 index 00000000..a84389e0 --- /dev/null +++ b/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx @@ -0,0 +1,376 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { DialogContent } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; +import { Message } from '@/app/infra/entities/message'; +import { toast } from 'sonner'; +import AtBadge from './AtBadge'; + +interface MessageComponent { + type: 'At' | 'Plain'; + target?: string; + text?: string; +} + +interface DebugDialogProps { + open: boolean; + pipelineId: string; + isEmbedded?: boolean; +} + +export default function DebugDialog({ + open, + pipelineId, + isEmbedded = false, +}: DebugDialogProps) { + const { t } = useTranslation(); + const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId); + const [sessionType, setSessionType] = useState<'person' | 'group'>('person'); + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [showAtPopover, setShowAtPopover] = useState(false); + const [hasAt, setHasAt] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const popoverRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + useEffect(() => { + if (open) { + setSelectedPipelineId(pipelineId); + loadMessages(pipelineId); + } + }, [open, pipelineId]); + + useEffect(() => { + if (open) { + loadMessages(selectedPipelineId); + } + }, [sessionType, selectedPipelineId]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target as Node) && + !inputRef.current?.contains(event.target as Node) + ) { + setShowAtPopover(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + useEffect(() => { + if (showAtPopover) { + setIsHovering(true); + } + }, [showAtPopover]); + + const loadMessages = async (pipelineId: string) => { + try { + const response = await httpClient.getWebChatHistoryMessages( + pipelineId, + sessionType, + ); + setMessages(response.messages); + } catch (error) { + console.error('Failed to load messages:', error); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (sessionType === 'group') { + if (value.endsWith('@')) { + setShowAtPopover(true); + } else if (showAtPopover && (!value.includes('@') || value.length > 1)) { + setShowAtPopover(false); + } + } + setInputValue(value); + }; + + const handleAtSelect = () => { + setHasAt(true); + setShowAtPopover(false); + setInputValue(inputValue.slice(0, -1)); + }; + + const handleAtRemove = () => { + setHasAt(false); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (showAtPopover) { + handleAtSelect(); + } else { + sendMessage(); + } + } else if (e.key === 'Backspace' && hasAt && inputValue === '') { + handleAtRemove(); + } + }; + + const sendMessage = async () => { + if (!inputValue.trim() && !hasAt) return; + + try { + const messageChain = []; + + let text_content = inputValue.trim(); + if (hasAt) { + text_content = ' ' + text_content; + } + + if (hasAt) { + messageChain.push({ + type: 'At', + target: 'webchatbot', + }); + } + messageChain.push({ + type: 'Plain', + text: text_content, + }); + + if (hasAt) { + // for showing + text_content = '@webchatbot' + text_content; + } + + const userMessage: Message = { + id: -1, + role: 'user', + content: text_content, + timestamp: new Date().toISOString(), + message_chain: messageChain, + }; + + setMessages((prevMessages) => [...prevMessages, userMessage]); + setInputValue(''); + setHasAt(false); + + const response = await httpClient.sendWebChatMessage( + sessionType, + messageChain, + selectedPipelineId, + 120000, + ); + + setMessages((prevMessages) => [...prevMessages, response.message]); + } catch ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: any + ) { + console.log(error, 'type of error', typeof error); + console.error('Failed to send message:', error); + + if (!error.message.includes('timeout') && sessionType === 'person') { + toast.error(t('pipelines.debugDialog.sendFailed')); + } + } finally { + inputRef.current?.focus(); + } + }; + + const renderMessageContent = (message: Message) => { + return ( + + {(message.message_chain as MessageComponent[]).map( + (component, index) => { + if (component.type === 'At') { + return ( + + ); + } else if (component.type === 'Plain') { + return {component.text}; + } + return null; + }, + )} + + ); + }; + + const renderContent = () => ( +
+
+ + +
+
+ +
+ +
+ {messages.length === 0 ? ( +
+ {t('pipelines.debugDialog.noMessages')} +
+ ) : ( + messages.map((message) => ( +
+
+ {renderMessageContent(message)} +
+ {message.role === 'user' + ? t('pipelines.debugDialog.userMessage') + : t('pipelines.debugDialog.botMessage')} +
+
+
+ )) + )} +
+
+ + +
+
+ {hasAt && ( + + )} +
+ + {showAtPopover && ( +
+
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + + @webchatbot - {t('pipelines.debugDialog.atTips')} + +
+
+ )} +
+
+ +
+
+
+ ); + + // 如果是嵌入模式,直接返回内容 + if (isEmbedded) { + return ( +
+
{renderContent()}
+
+ ); + } + + // 原有的Dialog包装 + return ( + + {renderContent()} + + ); +} diff --git a/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx b/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx index 05bf2470..52b3aef9 100644 --- a/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx +++ b/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx @@ -1,22 +1,10 @@ import styles from './pipelineCard.module.css'; import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO'; import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -export default function PipelineCard({ - cardVO, - onDebug, -}: { - cardVO: PipelineCardVO; - onDebug: (pipelineId: string) => void; -}) { +export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) { const { t } = useTranslation(); - const handleDebugClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onDebug(cardVO.id); - }; - return (
@@ -61,22 +49,6 @@ export default function PipelineCard({
)} -
); diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 7497f64a..c92b553d 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -22,15 +22,14 @@ import { FormLabel, FormMessage, } from '@/components/ui/form'; -import { toast } from 'sonner'; import { Dialog, DialogContent, DialogHeader, DialogTitle, - DialogDescription, DialogFooter, } from '@/components/ui/dialog'; +import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { i18nObj } from '@/i18n/I18nProvider'; @@ -41,17 +40,25 @@ export default function PipelineFormComponent({ onNewPipelineCreated, isEditMode, pipelineId, + showButtons = true, + onDeletePipeline, + onCancel, }: { pipelineId?: string; isDefaultPipeline: boolean; isEditMode: boolean; disableForm: boolean; + showButtons?: boolean; // 这里的写法很不安全不规范,未来流水线需要重新整理 initValues?: PipelineFormEntity; onFinish: () => void; onNewPipelineCreated: (pipelineId: string) => void; + onDeletePipeline: () => void; + onCancel: () => void; }) { const { t } = useTranslation(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const formSchema = isEditMode ? z.object({ basic: z.object({ @@ -98,7 +105,6 @@ export default function PipelineFormComponent({ useState(); const [outputConfigTabSchema, setOutputConfigTabSchema] = useState(); - const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); const form = useForm({ resolver: zodResolver(formSchema), @@ -306,187 +312,191 @@ export default function PipelineFormComponent({ ); } - function deletePipeline() { - httpClient - .deletePipeline(pipelineId || '') - .then(() => { - onFinish(); - toast.success(t('common.deleteSuccess')); - }) - .catch((err) => { - toast.error(t('common.deleteError') + err.message); - }); - } + const handleDelete = () => { + setShowDeleteConfirm(true); + }; + + const confirmDelete = () => { + if (pipelineId) { + httpClient + .deletePipeline(pipelineId) + .then(() => { + onDeletePipeline(); + setShowDeleteConfirm(false); + toast.success(t('pipelines.deleteSuccess')); + }) + .catch((err) => { + toast.error(t('pipelines.deleteError') + err.message); + }); + } + }; return ( -
- - - - {t('common.confirmDelete')} - - - {t('pipelines.deleteConfirmation')} - - - - - - - - -
- - - - {formLabelList.map((formLabel) => ( - - {formLabel.label} - - ))} - - - {formLabelList.map((formLabel) => ( - +
+ + +
+ -

{formLabel.label}

+ + {formLabelList.map((formLabel) => ( + + {formLabel.label} + + ))} + - {formLabel.name === 'basic' && ( -
- ( - - - {t('common.name')} - * - - - - - - +
+ {formLabelList.map((formLabel) => ( + + {formLabel.name === 'basic' && ( +
+ ( + + + {t('common.name')} + * + + + + + + + )} + /> + + ( + + + {t('common.description')} + * + + + + + + + )} + /> +
)} - /> - ( - - - {t('common.description')} - * - - - - - - + {isEditMode && ( + <> + {formLabel.name === 'ai' && aiConfigTabSchema && ( +
+ {aiConfigTabSchema.stages.map((stage) => + renderDynamicForms(stage, 'ai'), + )} +
+ )} + + {formLabel.name === 'trigger' && + triggerConfigTabSchema && ( +
+ {triggerConfigTabSchema.stages.map((stage) => + renderDynamicForms(stage, 'trigger'), + )} +
+ )} + + {formLabel.name === 'safety' && + safetyConfigTabSchema && ( +
+ {safetyConfigTabSchema.stages.map((stage) => + renderDynamicForms(stage, 'safety'), + )} +
+ )} + + {formLabel.name === 'output' && + outputConfigTabSchema && ( +
+ {outputConfigTabSchema.stages.map((stage) => + renderDynamicForms(stage, 'output'), + )} +
+ )} + )} - /> -
- )} - - {isEditMode && ( - <> - {formLabel.name === 'ai' && aiConfigTabSchema && ( -
- {aiConfigTabSchema.stages.map((stage) => - renderDynamicForms(stage, 'ai'), - )} -
- )} - - {formLabel.name === 'trigger' && triggerConfigTabSchema && ( -
- {triggerConfigTabSchema.stages.map((stage) => - renderDynamicForms(stage, 'trigger'), - )} -
- )} - - {formLabel.name === 'safety' && safetyConfigTabSchema && ( -
- {safetyConfigTabSchema.stages.map((stage) => - renderDynamicForms(stage, 'safety'), - )} -
- )} - - {formLabel.name === 'output' && outputConfigTabSchema && ( -
- {outputConfigTabSchema.stages.map((stage) => - renderDynamicForms(stage, 'output'), - )} -
- )} - - )} - - ))} - - -
-
- {isEditMode && isDefaultPipeline && ( - - {t('pipelines.defaultPipelineCannotDelete')} - - )} - + + ))} +
+ +
+ + {/* 按钮栏移到 Tabs 外部,始终固定底部 */} + {showButtons && ( +
{isEditMode && !isDefaultPipeline && ( )} - -
-
- - -
+ )} + +
+ + {/* 删除确认对话框 */} + + + + {t('common.confirmDelete')} + +
{t('pipelines.deleteConfirmation')}
+ + + + +
+
+ ); } - interface FormLabel { label: string; name: string; diff --git a/web/src/app/home/pipelines/debug-dialog/DebugDialog.tsx b/web/src/app/home/pipelines/debug-dialog/DebugDialog.tsx deleted file mode 100644 index 6e428635..00000000 --- a/web/src/app/home/pipelines/debug-dialog/DebugDialog.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { httpClient } from '@/app/infra/http/HttpClient'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { cn } from '@/lib/utils'; -import { Pipeline } from '@/app/infra/entities/api'; -import { Message } from '@/app/infra/entities/message'; -import { toast } from 'sonner'; -import AtBadge from './AtBadge'; - -interface MessageComponent { - type: 'At' | 'Plain'; - target?: string; - text?: string; -} - -interface DebugDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - pipelineId: string; -} - -export default function DebugDialog({ - open, - onOpenChange, - pipelineId, -}: DebugDialogProps) { - const { t } = useTranslation(); - const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId); - const [sessionType, setSessionType] = useState<'person' | 'group'>('person'); - const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(''); - const [pipelines, setPipelines] = useState([]); - const [showAtPopover, setShowAtPopover] = useState(false); - const [hasAt, setHasAt] = useState(false); - const [isHovering, setIsHovering] = useState(false); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const popoverRef = useRef(null); - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); - - useEffect(() => { - if (open) { - setSelectedPipelineId(pipelineId); - loadPipelines(); - loadMessages(pipelineId); - } - }, [open, pipelineId]); - - useEffect(() => { - if (open) { - loadMessages(selectedPipelineId); - } - }, [sessionType, selectedPipelineId]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - popoverRef.current && - !popoverRef.current.contains(event.target as Node) && - !inputRef.current?.contains(event.target as Node) - ) { - setShowAtPopover(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - useEffect(() => { - if (showAtPopover) { - setIsHovering(true); - } - }, [showAtPopover]); - - const loadPipelines = async () => { - try { - const response = await httpClient.getPipelines(); - setPipelines(response.pipelines); - } catch (error) { - console.error('Failed to load pipelines:', error); - } - }; - - const loadMessages = async (pipelineId: string) => { - try { - const response = await httpClient.getWebChatHistoryMessages( - pipelineId, - sessionType, - ); - setMessages(response.messages); - } catch (error) { - console.error('Failed to load messages:', error); - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - if (sessionType === 'group') { - if (value.endsWith('@')) { - setShowAtPopover(true); - } else if (showAtPopover && (!value.includes('@') || value.length > 1)) { - setShowAtPopover(false); - } - } - setInputValue(value); - }; - - const handleAtSelect = () => { - setHasAt(true); - setShowAtPopover(false); - setInputValue(inputValue.slice(0, -1)); - }; - - const handleAtRemove = () => { - setHasAt(false); - }; - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - if (showAtPopover) { - handleAtSelect(); - } else { - sendMessage(); - } - } else if (e.key === 'Backspace' && hasAt && inputValue === '') { - handleAtRemove(); - } - }; - - const sendMessage = async () => { - if (!inputValue.trim() && !hasAt) return; - - try { - const messageChain = []; - - let text_content = inputValue.trim(); - if (hasAt) { - text_content = ' ' + text_content; - } - - if (hasAt) { - messageChain.push({ - type: 'At', - target: 'webchatbot', - }); - } - messageChain.push({ - type: 'Plain', - text: text_content, - }); - - if (hasAt) { - // for showing - text_content = '@webchatbot' + text_content; - } - - const userMessage: Message = { - id: -1, - role: 'user', - content: text_content, - timestamp: new Date().toISOString(), - message_chain: messageChain, - }; - - setMessages((prevMessages) => [...prevMessages, userMessage]); - setInputValue(''); - setHasAt(false); - - const response = await httpClient.sendWebChatMessage( - sessionType, - messageChain, - selectedPipelineId, - 120000, - ); - - setMessages((prevMessages) => [...prevMessages, response.message]); - } catch ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: any - ) { - console.log(error, 'type of error', typeof error); - console.error('Failed to send message:', error); - - if (!error.message.includes('timeout') && sessionType === 'person') { - toast.error(t('pipelines.debugDialog.sendFailed')); - } - } finally { - inputRef.current?.focus(); - } - }; - - // const resetSession = async () => { - // try { - // await httpClient.resetWebChatSession(selectedPipelineId, sessionType); - // setMessages([]); - // } catch (error) { - // console.error('Failed to reset session:', error); - // } - // }; - - const renderMessageContent = (message: Message) => { - return ( - - {(message.message_chain as MessageComponent[]).map( - (component, index) => { - if (component.type === 'At') { - return ( - - ); - } else if (component.type === 'Plain') { - return {component.text}; - } - return null; - }, - )} - - ); - }; - - return ( - - - -
- - {t('pipelines.debugDialog.title')} - - -
-
-
-
-
- - -
-
-
- -
- -
- {messages.length === 0 ? ( -
- {t('pipelines.debugDialog.noMessages')} -
- ) : ( - messages.map((message) => ( -
-
- {renderMessageContent(message)} -
- {message.role === 'user' - ? t('pipelines.debugDialog.userMessage') - : t('pipelines.debugDialog.botMessage')} -
-
-
- )) - )} -
-
- - -
-
- {hasAt && ( - - )} -
- - {showAtPopover && ( -
-
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - > - - @webchatbot - {t('pipelines.debugDialog.atTips')} - -
-
- )} -
-
- -
-
-
- -
- ); -} diff --git a/web/src/app/home/pipelines/page.tsx b/web/src/app/home/pipelines/page.tsx index fb17e6e6..40875f6e 100644 --- a/web/src/app/home/pipelines/page.tsx +++ b/web/src/app/home/pipelines/page.tsx @@ -1,25 +1,18 @@ 'use client'; import { useState, useEffect } from 'react'; import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent'; -import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent'; import { httpClient } from '@/app/infra/http/HttpClient'; import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO'; import PipelineCard from '@/app/home/pipelines/components/pipeline-card/PipelineCard'; import { PipelineFormEntity } from '@/app/infra/entities/pipeline'; import styles from './pipelineConfig.module.css'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import DebugDialog from './debug-dialog/DebugDialog'; +import PipelineDialog from './PipelineDetailDialog'; export default function PluginConfigPage() { const { t } = useTranslation(); - const [modalOpen, setModalOpen] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); const [isEditForm, setIsEditForm] = useState(false); const [pipelineList, setPipelineList] = useState([]); const [selectedPipelineId, setSelectedPipelineId] = useState(''); @@ -31,11 +24,8 @@ export default function PluginConfigPage() { safety: {}, output: {}, }); - const [disableForm, setDisableForm] = useState(false); const [selectedPipelineIsDefault, setSelectedPipelineIsDefault] = useState(false); - const [debugDialogOpen, setDebugDialogOpen] = useState(false); - const [debugPipelineId, setDebugPipelineId] = useState(''); useEffect(() => { getPipelines(); @@ -92,83 +82,77 @@ export default function PluginConfigPage() { trigger: value.pipeline.config.trigger, }); setSelectedPipelineIsDefault(value.pipeline.is_default ?? false); - setDisableForm(false); }); } - const handleDebug = (pipelineId: string) => { - setDebugPipelineId(pipelineId); - setDebugDialogOpen(true); + const handlePipelineClick = (pipelineId: string) => { + setSelectedPipelineId(pipelineId); + setIsEditForm(true); + setDialogOpen(true); + getSelectedPipelineForm(pipelineId); + }; + + const handleCreateNew = () => { + setIsEditForm(false); + setSelectedPipelineId(''); + setSelectedPipelineFormValue({ + basic: {}, + ai: {}, + trigger: {}, + safety: {}, + output: {}, + }); + setSelectedPipelineIsDefault(false); + setDialogOpen(true); }; return (
- - - - - {isEditForm - ? t('pipelines.editPipeline') - : t('pipelines.createPipeline')} - - -
- { - setDisableForm(true); - setIsEditForm(true); - setModalOpen(true); - setSelectedPipelineId(pipelineId); - getSelectedPipelineForm(pipelineId); - }} - onFinish={() => { - getPipelines(); - setModalOpen(false); - }} - isEditMode={isEditForm} - pipelineId={selectedPipelineId} - disableForm={disableForm} - initValues={selectedPipelineFormValue} - isDefaultPipeline={selectedPipelineIsDefault} - /> -
-
-
+ { + getPipelines(); + }} + onNewPipelineCreated={(pipelineId) => { + getPipelines(); + setSelectedPipelineId(pipelineId); + setIsEditForm(true); + setDialogOpen(true); + getSelectedPipelineForm(pipelineId); + }} + onDeletePipeline={() => { + getPipelines(); + setDialogOpen(false); + }} + onCancel={() => { + setDialogOpen(false); + }} + />
{ - setIsEditForm(false); - setModalOpen(true); - }} + onClick={handleCreateNew} /> {pipelineList.map((pipeline) => { return (
{ - setDisableForm(true); - setIsEditForm(true); - setModalOpen(true); - setSelectedPipelineId(pipeline.id); - getSelectedPipelineForm(pipeline.id); - }} + onClick={() => handlePipelineClick(pipeline.id)} > - +
); })}
- -
); } diff --git a/web/src/components/ui/breadcrumb.tsx b/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..74fa1f4d --- /dev/null +++ b/web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { ChevronRight, MoreHorizontal } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return