feat: show video in web channel

This commit is contained in:
zhayujie
2026-04-03 17:09:38 +08:00
parent 9cc173cc4d
commit 443e0c2806
3 changed files with 62 additions and 11 deletions

View File

@@ -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)

View File

@@ -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 `<div style="margin:10px 0;">` +
`<video controls preload="metadata" ` +
`style="max-width:100%;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,0.15);display:block;">` +
`<source src="${url}"></video>` +
`<a href="${url}" target="_blank" ` +
`style="display:inline-flex;align-items:center;gap:4px;margin-top:4px;font-size:12px;color:#8b8fa8;text-decoration:none;">` +
`<i class="fas fa-download"></i> ${escapeHtml(fileName)}</a></div>`;
}
function injectVideoPlayers(html) {
// Step 1: replace markdown-it anchor tags whose href points to a video file.
const step1 = html.replace(
/<a\s+href="(https?:\/\/[^"]+)"[^>]*>[^<]*<\/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, '<br>'); }
}
@@ -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);
}

View File

@@ -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,