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 `
`;
+}
+
+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,