diff --git a/channel/chat_channel.py b/channel/chat_channel.py index 6cbf840f..64a649aa 100644 --- a/channel/chat_channel.py +++ b/channel/chat_channel.py @@ -347,38 +347,30 @@ class ChatChannel(Channel): if media_items: logger.info(f"[chat_channel] Extracted {len(media_items)} media item(s) from reply") - # 先发送文本(保持原文本不变) + # Send text first (the frontend will embed video players via renderMarkdown). logger.info(f"[chat_channel] Sending text content before media: {reply.content[:100]}...") self._send(reply, context) logger.info(f"[chat_channel] Text sent, now sending {len(media_items)} media item(s)") - # 然后逐个发送媒体文件 for i, (url, media_type) in enumerate(media_items): try: - # 判断是本地文件还是URL + # Determine whether it is a remote URL or a local file. if url.startswith(('http://', 'https://')): - # 网络资源 if media_type == 'video': - # 视频使用 FILE 类型发送 media_reply = Reply(ReplyType.FILE, url) media_reply.file_name = os.path.basename(url) else: - # 图片使用 IMAGE_URL 类型 media_reply = Reply(ReplyType.IMAGE_URL, url) elif os.path.exists(url): - # 本地文件 if media_type == 'video': - # 视频使用 FILE 类型,转换为 file:// URL media_reply = Reply(ReplyType.FILE, f"file://{url}") media_reply.file_name = os.path.basename(url) else: - # 图片使用 IMAGE_URL 类型,转换为 file:// URL media_reply = Reply(ReplyType.IMAGE_URL, f"file://{url}") else: logger.warning(f"[chat_channel] Media file not found or invalid URL: {url}") continue - # 发送媒体文件(添加小延迟避免频率限制) if i > 0: time.sleep(0.5) self._send(media_reply, context) diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 756c1ba4..7139b1c7 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -270,8 +270,42 @@ function createMd() { const md = createMd(); +const VIDEO_EXT_RE = /\.(?:mp4|webm|mov|avi|mkv)$/i; // tested against URL without query string + +function _buildVideoHtml(url) { + const fileName = url.split('/').pop().split('?')[0]; + return `
` + + `` + + `` + + ` ${escapeHtml(fileName)}
`; +} + +function injectVideoPlayers(html) { + // Step 1: replace markdown-it anchor tags whose href points to a video file. + const step1 = html.replace( + /]*>[^<]*<\/a>/gi, + (match, url) => VIDEO_EXT_RE.test(url.split('?')[0]) ? _buildVideoHtml(url) : match + ); + // Step 2: replace any remaining bare video URLs in text nodes (not inside HTML tags). + // Split on HTML tags to avoid touching src/href attributes already in markup. + return step1.split(/(<[^>]+>)/).map((chunk, idx) => { + // Even indices are text nodes; odd indices are HTML tags — leave them untouched. + if (idx % 2 !== 0) return chunk; + return chunk.replace(/https?:\/\/\S+/gi, (url) => { + const bare = url.replace(/[),.\s]+$/, ''); // strip trailing punctuation + return VIDEO_EXT_RE.test(bare.split('?')[0]) ? _buildVideoHtml(bare) : url; + }); + }).join(''); +} + function renderMarkdown(text) { - try { return md.render(text); } + try { + const html = md.render(text); + return injectVideoPlayers(html); + } catch (e) { return text.replace(/\n/g, '
'); } } @@ -886,6 +920,22 @@ function startSSE(requestId, loadingEl, timestamp) { mediaEl.appendChild(imgEl); scrollChatToBottom(); + } else if (item.type === 'text') { + // Intermediate text sent before media items; display it but keep SSE open. + ensureBotEl(); + contentEl.classList.remove('sse-streaming'); + const textContent = item.content || accumulatedText; + if (textContent) contentEl.innerHTML = renderMarkdown(textContent); + applyHighlighting(botEl); + scrollChatToBottom(); + + } else if (item.type === 'video') { + ensureBotEl(); + const wrapper = document.createElement('div'); + wrapper.innerHTML = _buildVideoHtml(item.content); + mediaEl.appendChild(wrapper.firstElementChild || wrapper); + scrollChatToBottom(); + } else if (item.type === 'file') { ensureBotEl(); const fileName = item.file_name || item.content.split('/').pop(); @@ -912,6 +962,7 @@ function startSSE(requestId, loadingEl, timestamp) { es.close(); delete activeStreams[requestId]; + // item.content may be empty when "done" is only a stream-close signal after media. const finalText = item.content || accumulatedText; if (!botEl && finalText) { @@ -919,6 +970,7 @@ function startSSE(requestId, loadingEl, timestamp) { addBotMessage(finalText, new Date((item.timestamp || Date.now() / 1000) * 1000), requestId); } else if (botEl) { contentEl.classList.remove('sse-streaming'); + // Only update text content when there is something new to show. if (finalText) contentEl.innerHTML = renderMarkdown(finalText); applyHighlighting(botEl); } diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index a5bf389a..ab8f7c02 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -126,6 +126,13 @@ class WebChannel(ChatChannel): logger.debug(f"SSE skipped duplicate file for request {request_id}") return + # Skip http-URL FILE/IMAGE_URL replies produced by chat_channel's media extraction: + # the text reply (already sent as "done") contains the URL and the frontend will + # render it via renderMarkdown/injectVideoPlayers, so no separate SSE event needed. + if reply.type in (ReplyType.FILE, ReplyType.IMAGE_URL) and content.startswith(("http://", "https://")): + logger.debug(f"SSE skipped http media reply for request {request_id}") + return + self.sse_queues[request_id].put({ "type": "done", "content": content,