From b634aa48dc27a8df7e7e853914071451db00a04c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Dec 2025 13:44:01 +0800 Subject: [PATCH] feat(web): Add markdown rendering support to pipeline chat messages with toggle (#1826) * Initial plan * Add markdown rendering support to pipeline debug dialog messages with toggle button Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * Fix code review feedback: remove conflicting styles and imports Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * perf: styles * fix: websocket message broadcasting cross-contamination between person and group channels --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: Junyan Qin --- .../pkg/platform/sources/websocket_adapter.py | 15 +- .../pkg/platform/sources/websocket_manager.py | 15 +- web/package.json | 7 + .../home/pipelines/PipelineDetailDialog.tsx | 2 +- .../components/debug-dialog/DebugDialog.tsx | 246 ++++++++++++++---- .../plugin-readme/PluginReadme.tsx | 2 +- .../app/infra/websocket/WebSocketClient.ts | 16 ++ web/src/i18n/locales/en-US.ts | 2 + web/src/i18n/locales/ja-JP.ts | 2 + web/src/i18n/locales/zh-Hans.ts | 2 + web/src/i18n/locales/zh-Hant.ts | 2 + .../github-markdown.css | 0 12 files changed, 258 insertions(+), 53 deletions(-) rename web/src/{app/home/plugins/components/plugin-installed/plugin-readme => styles}/github-markdown.css (100%) diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py index 6e03a699..a9f32800 100644 --- a/src/langbot/pkg/platform/sources/websocket_adapter.py +++ b/src/langbot/pkg/platform/sources/websocket_adapter.py @@ -117,7 +117,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) # 从message_source获取pipeline_uuid和connection_id pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid - # session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person' + session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person' # 生成新的消息ID msg_id = len(session.get_message_list(pipeline_uuid)) + 1 @@ -134,13 +134,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) # 保存到历史记录 session.get_message_list(pipeline_uuid).append(message_data) - # 直接广播到所有该pipeline的连接 + # 直接广播到所有该pipeline的连接,包含session_type信息 await ws_connection_manager.broadcast_to_pipeline( pipeline_uuid, { 'type': 'response', + 'session_type': session_type, 'data': message_data.model_dump(), }, + session_type=session_type, ) return message_data.model_dump() @@ -162,6 +164,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) ) pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid + session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person' message_list = session.get_message_list(pipeline_uuid) # 检查是否是新的流式消息(通过bot_message对象判断) @@ -197,13 +200,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) if is_final and bot_message.tool_calls is None: message_list[-1] = message_data - # 直接广播到所有该pipeline的连接 + # 直接广播到所有该pipeline的连接,包含session_type信息 await ws_connection_manager.broadcast_to_pipeline( pipeline_uuid, { 'type': 'response', + 'session_type': session_type, 'data': message_data.model_dump(), }, + session_type=session_type, ) return message_data.model_dump() @@ -344,13 +349,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) ) use_session.get_message_list(pipeline_uuid).append(user_message) - # 广播用户消息到所有连接(包括发送者) + # 广播用户消息到所有连接(包括发送者),包含session_type信息 await ws_connection_manager.broadcast_to_pipeline( pipeline_uuid, { 'type': 'user_message', + 'session_type': session_type, 'data': user_message.model_dump(), }, + session_type=session_type, ) # 添加消息源 diff --git a/src/langbot/pkg/platform/sources/websocket_manager.py b/src/langbot/pkg/platform/sources/websocket_manager.py index 767c5be8..425dc9e0 100644 --- a/src/langbot/pkg/platform/sources/websocket_manager.py +++ b/src/langbot/pkg/platform/sources/websocket_manager.py @@ -134,9 +134,20 @@ class WebSocketConnectionManager: connection_ids = self.session_connections.get(session_type, set()) return [self.connections[cid] for cid in connection_ids if cid in self.connections] - async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict): - """向指定流水线的所有连接广播消息""" + async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict, session_type: str = None): + """向指定流水线的所有连接广播消息 + + Args: + pipeline_uuid: 流水线UUID + message: 要广播的消息 + session_type: 可选的会话类型过滤器,如果提供则只向匹配的session_type连接广播 + """ connections = await self.get_connections_by_pipeline(pipeline_uuid) + + # 如果指定了session_type,只向匹配的连接广播 + if session_type is not None: + connections = [conn for conn in connections if conn.session_type == session_type] + tasks = [] for conn in connections: tasks.append(self.send_to_connection(conn.connection_id, message)) diff --git a/web/package.json b/web/package.json index 992ada5b..c869944e 100644 --- a/web/package.json +++ b/web/package.json @@ -74,11 +74,18 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/debug": "^4.1.12", + "@types/estree": "^1.0.8", + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.4", "@types/lodash": "^4.17.16", + "@types/mdast": "^4.0.4", + "@types/ms": "^2.1.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", + "@types/unist": "^3.0.3", "eslint": "^9", "eslint-config-next": "15.2.4", "eslint-config-prettier": "^10.1.2", diff --git a/web/src/app/home/pipelines/PipelineDetailDialog.tsx b/web/src/app/home/pipelines/PipelineDetailDialog.tsx index c51ad961..b58afaa9 100644 --- a/web/src/app/home/pipelines/PipelineDetailDialog.tsx +++ b/web/src/app/home/pipelines/PipelineDetailDialog.tsx @@ -154,7 +154,7 @@ export default function PipelineDialog({ // 编辑流水线时的对话框 return ( - + (''); const [showImagePreview, setShowImagePreview] = useState(false); const [quotedMessage, setQuotedMessage] = useState(null); - const [hoveredMessageId, setHoveredMessageId] = useState(null); + const [rawModeMessages, setRawModeMessages] = useState>( + new Set(), + ); const messagesEndRef = useRef(null); const inputRef = useRef(null); const popoverRef = useRef(null); @@ -195,6 +204,8 @@ export default function DebugDialog({ // 监听 sessionType 和 selectedPipelineId 变化,重新加载消息和连接 useEffect(() => { if (open) { + // 清空当前消息,避免显示旧的消息 + setMessages([]); loadMessages(selectedPipelineId); initWebSocket(selectedPipelineId); } @@ -554,7 +565,111 @@ export default function DebugDialog({ return t('bots.earlier'); }; + // Generate a unique key for a message + const getMessageKey = (message: Message): string => { + return `${message.id}-${message.timestamp}`; + }; + + // Toggle raw mode for a message (by default, messages are in markdown mode) + const toggleRawMode = (message: Message) => { + const key = getMessageKey(message); + setRawModeMessages((prev) => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }; + + // Check if message has any Plain text content + const hasPlainText = (message: Message): boolean => { + return message.message_chain.some((c) => c.type === 'Plain'); + }; + + // Extract plain text from message chain + const getPlainText = (message: Message): string => { + return message.message_chain + .filter((c) => c.type === 'Plain') + .map((c) => (c as Plain).text) + .join(''); + }; + const renderMessageContent = (message: Message) => { + const key = getMessageKey(message); + const isRawMode = rawModeMessages.has(key); + + // By default, render with markdown if there's plain text (unless raw mode is enabled) + if (!isRawMode && hasPlainText(message)) { + const plainText = getPlainText(message); + const nonPlainComponents = message.message_chain.filter( + (c) => c.type !== 'Plain' && c.type !== 'Source', + ); + + return ( +
+ {/* Render non-Plain components first */} + {nonPlainComponents.map((component, index) => + renderMessageComponent(component, index), + )} + {/* Render Plain text as markdown */} +
+
    {children}
, + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) =>
  • {children}
  • , + img: ({ src, alt, ...props }) => { + const imageSrc = src || ''; + + if (typeof imageSrc !== 'string') { + return ( + {alt + ); + } + + return ( + {alt + ); + }, + }} + > + {plainText} +
    +
    +
    + ); + } + return (
    {message.message_chain.map((component, index) => @@ -619,68 +734,109 @@ export default function DebugDialog({
    setHoveredMessageId(message.id)} - onMouseLeave={() => setHoveredMessageId(null)} >
    + {renderMessageContent(message)}
    - {renderMessageContent(message)} -
    +
    {message.role === 'user' ? t('pipelines.debugDialog.userMessage') : t('pipelines.debugDialog.botMessage')} - - {formatTimestamp(getMessageTimestamp(message))} - -
    -
    - {hoveredMessageId === message.id && ( - + )} + - )} + + + + {t('pipelines.debugDialog.reply')} + +
    + + {formatTimestamp(getMessageTimestamp(message))} + +
    )) diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx index cce71b5a..5c76f9a1 100644 --- a/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme.tsx @@ -8,7 +8,7 @@ import rehypeHighlight from 'rehype-highlight'; import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import { getAPILanguageCode } from '@/i18n/I18nProvider'; -import './github-markdown.css'; +import '@/styles/github-markdown.css'; export default function PluginReadme({ pluginAuthor, diff --git a/web/src/app/infra/websocket/WebSocketClient.ts b/web/src/app/infra/websocket/WebSocketClient.ts index 426ee387..32b1a793 100644 --- a/web/src/app/infra/websocket/WebSocketClient.ts +++ b/web/src/app/infra/websocket/WebSocketClient.ts @@ -149,12 +149,28 @@ export class WebSocketClient { break; case 'response': + // 检查 session_type 是否匹配 - 如果消息没有 session_type 或者不匹配当前session,都忽略 + if (!data.session_type || data.session_type !== this.sessionType) { + // 忽略不匹配的 session_type 消息 + console.debug( + `忽略不匹配的消息: 当前session=${this.sessionType}, 消息session=${data.session_type}`, + ); + break; + } if (data.data) { this.onMessageCallback?.(data.data); } break; case 'user_message': + // 检查 session_type 是否匹配 - 如果消息没有 session_type 或者不匹配当前session,都忽略 + if (!data.session_type || data.session_type !== this.sessionType) { + // 忽略不匹配的 session_type 消息 + console.debug( + `忽略不匹配的用户消息: 当前session=${this.sessionType}, 消息session=${data.session_type}`, + ); + break; + } // 用户消息广播(包括自己发送的消息) if (data.data) { this.onMessageCallback?.(data.data); diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index c92ff544..6613a75d 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -525,6 +525,8 @@ const enUS = { imageUploadFailed: 'Image upload failed', reply: 'Reply', replyTo: 'Reply to', + showMarkdown: 'Show Markdown', + showRaw: 'Show Raw', }, }, knowledge: { diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index d1c21d14..349fedab 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -529,6 +529,8 @@ const jaJP = { imageUploadFailed: '画像のアップロードに失敗しました', reply: '返信', replyTo: '返信先', + showMarkdown: 'Markdownで表示', + showRaw: '原文で表示', }, }, knowledge: { diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 4877ba22..ccbebaac 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -508,6 +508,8 @@ const zhHans = { imageUploadFailed: '图片上传失败', reply: '回复', replyTo: '回复给', + showMarkdown: '渲染', + showRaw: '原文', }, }, knowledge: { diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 8f661d53..393f50c8 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -506,6 +506,8 @@ const zhHant = { imageUploadFailed: '圖片上傳失敗', reply: '回覆', replyTo: '回覆給', + showMarkdown: '渲染', + showRaw: '原文', }, }, knowledge: { diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-readme/github-markdown.css b/web/src/styles/github-markdown.css similarity index 100% rename from web/src/app/home/plugins/components/plugin-installed/plugin-readme/github-markdown.css rename to web/src/styles/github-markdown.css