mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
feat: show video in web channel
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user