From 9dfb1567d2d81880c3564d5f5eb53ed799cf4744 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 27 Apr 2026 12:57:13 +0800 Subject: [PATCH] fix: render user media previews from media store refs --- server/chat_flow_task_main.py | 16 +++- server/conversation.py | 41 +++++++++- static/src/app/methods/history.ts | 11 ++- static/src/app/methods/taskPolling.ts | 46 ++++++++++-- static/src/components/chat/ChatArea.vue | 91 +++++++++++++++++++++-- static/src/composables/useLegacySocket.ts | 28 ++++++- static/src/stores/chat.ts | 9 ++- utils/media_store.py | 28 ++++++- 8 files changed, 250 insertions(+), 20 deletions(-) diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index fa851a9..b0c4f4b 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -752,13 +752,27 @@ async def handle_task_with_sender( if auto_user_message_event: user_message_metadata["is_auto_generated"] = True user_message_metadata["auto_message_type"] = "completion_notice" - web_terminal.context_manager.add_conversation( + saved_user_message = web_terminal.context_manager.add_conversation( "user", message, images=images, videos=videos, metadata=user_message_metadata ) + if not auto_user_message_event: + try: + sender( + 'user_message', + { + "message": message, + "images": (saved_user_message or {}).get("images") or images or [], + "videos": (saved_user_message or {}).get("videos") or videos or [], + "media_refs": (saved_user_message or {}).get("media_refs") or [], + "conversation_id": conversation_id, + }, + ) + except Exception as exc: + debug_log(f"[TaskFlow] 发送 user_message 回显失败: {exc}") try: user_message_index = len(getattr(web_terminal.context_manager, "conversation_history", []) or []) - 1 except Exception: diff --git a/server/conversation.py b/server/conversation.py index e79a874..ea174f9 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -8,9 +8,10 @@ import asyncio, json, time, re, os from datetime import datetime, timedelta from pathlib import Path from collections import defaultdict, Counter, deque +from io import BytesIO from typing import Dict, Any, Optional, List, Tuple -from flask import Blueprint, request, jsonify, session +from flask import Blueprint, request, jsonify, session, send_file from flask_socketio import emit from werkzeug.utils import secure_filename import zipfile @@ -542,6 +543,44 @@ def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace: }), 500 +@conversation_bp.route('/api/conversations/media/', methods=['GET']) +@api_login_required +@with_terminal +def download_conversation_media(media_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): + """按 media_store 中的 media_id 下载会话媒体。""" + try: + ctx = getattr(terminal, "context_manager", None) + media_store = getattr(ctx, "media_store", None) + if media_store is None: + return jsonify({"success": False, "error": "media_store 不可用"}), 503 + + target_id = str(media_id or "").strip() + if not target_id: + return jsonify({"success": False, "error": "media_id 不能为空"}), 400 + + entry = media_store.get_media_entry(target_id) + if not isinstance(entry, dict): + return jsonify({"success": False, "error": "媒体不存在"}), 404 + payload = media_store.load_bytes_by_media_id(target_id) + if payload is None: + return jsonify({"success": False, "error": "媒体文件不存在"}), 404 + + mime_type = str(entry.get("mime_type") or "application/octet-stream").strip() or "application/octet-stream" + blob_name = str(entry.get("blob_rel_path") or "") + filename = Path(blob_name).name if blob_name else target_id.replace(":", "_") + return send_file( + BytesIO(payload), + mimetype=mime_type, + as_attachment=False, + download_name=filename, + conditional=True, + etag=True, + max_age=86400, + ) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @conversation_bp.route('/api/conversations//versioning', methods=['GET']) @api_login_required @with_terminal diff --git a/static/src/app/methods/history.ts b/static/src/app/methods/history.ts index 6acac47..8dc8750 100644 --- a/static/src/app/methods/history.ts +++ b/static/src/app/methods/history.ts @@ -152,8 +152,12 @@ export const historyMethods = { } const messagesData = await messagesResponse.json(); - const rawMessages = Array.isArray(messagesData?.data?.messages) ? messagesData.data.messages : []; - const lastAssistantRaw = [...rawMessages].reverse().find((m: any) => m?.role === 'assistant'); + const rawMessages = Array.isArray(messagesData?.data?.messages) + ? messagesData.data.messages + : []; + const lastAssistantRaw = [...rawMessages] + .reverse() + .find((m: any) => m?.role === 'assistant'); restoreDebugLog('history:fetch:response', { loadSeq, success: !!messagesData?.success, @@ -272,6 +276,8 @@ export const historyMethods = { } const images = message.images || (message.metadata && message.metadata.images) || []; const videos = message.videos || (message.metadata && message.metadata.videos) || []; + const mediaRefs = + message.media_refs || (message.metadata && message.metadata.media_refs) || []; if (Array.isArray(images) && images.length) { historyHasImages = true; } @@ -283,6 +289,7 @@ export const historyMethods = { content: message.content || '', images, videos, + media_refs: Array.isArray(mediaRefs) ? mediaRefs : [], metadata: message.metadata || {} }); debugLog('添加用户消息:', message.content?.substring(0, 50) + '...'); diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index f2a8086..700026a 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -994,7 +994,11 @@ export const taskPollingMethods = { if (targetAction.tool && targetAction.tool.name === 'manage_personalization') { let result = data.result; if (typeof result === 'string') { - try { result = JSON.parse(result); } catch (e) { /* ignore */ } + try { + result = JSON.parse(result); + } catch (e) { + /* ignore */ + } } // 处理主题变更 if (result?.theme_changed === true && result.new_theme) { @@ -1029,7 +1033,11 @@ export const taskPollingMethods = { // 待办工具执行完成后主动刷新左侧待办列表(网页端不再依赖 websocket todo_updated) if (data.status === 'completed') { const toolName = String(targetAction?.tool?.name || '').toLowerCase(); - if (toolName.startsWith('todo_') || toolName === 'todo_create' || toolName === 'todo_update_task') { + if ( + toolName.startsWith('todo_') || + toolName === 'todo_create' || + toolName === 'todo_update_task' + ) { this.scheduleTodoListRefresh(80); } } @@ -1269,7 +1277,33 @@ export const taskPollingMethods = { }); const isAutoUserMessage = isSystemAutoUserMessagePayload(data); if (!isAutoUserMessage) { - this.chatAddUserMessage(message, data?.images || [], data?.videos || []); + const incomingImages = Array.isArray(data?.images) ? data.images : []; + const incomingVideos = Array.isArray(data?.videos) ? data.videos : []; + const incomingMediaRefs = Array.isArray(data?.media_refs) + ? data.media_refs + : Array.isArray(data?.mediaRefs) + ? data.mediaRefs + : []; + const last = + Array.isArray(this.messages) && this.messages.length > 0 + ? this.messages[this.messages.length - 1] + : null; + const isLikelyOptimisticEcho = !!( + last && + last.role === 'user' && + String(last.content || '').trim() === message && + JSON.stringify(last.images || []) === JSON.stringify(incomingImages || []) && + JSON.stringify(last.videos || []) === JSON.stringify(incomingVideos || []) + ); + if (isLikelyOptimisticEcho) { + last.media_refs = incomingMediaRefs; + last.metadata = { + ...(last.metadata || {}), + media_refs: incomingMediaRefs + }; + } else { + this.chatAddUserMessage(message, incomingImages, incomingVideos, incomingMediaRefs); + } this.taskInProgress = true; this.streamingMessage = false; this.stopRequested = false; @@ -1317,7 +1351,8 @@ export const taskPollingMethods = { userMDebug('taskPolling.handleSystemMessage:after-append', { currentMessagesLength: Array.isArray(this.messages) ? this.messages.length : -1, lastRole: this.messages?.[this.messages.length - 1]?.role || null, - lastActions: this.messages?.[this.messages.length - 1]?.actions?.map((a: any) => a?.type) || [] + lastActions: + this.messages?.[this.messages.length - 1]?.actions?.map((a: any) => a?.type) || [] }); }, @@ -1358,8 +1393,7 @@ export const taskPollingMethods = { const errorMessage = data.message || '未知错误'; const errorType = data.error_type || 'unknown'; const isToolArgumentParseError = - errorType === 'parameter_format_error' || - /工具参数解析失败/.test(String(errorMessage || '')); + errorType === 'parameter_format_error' || /工具参数解析失败/.test(String(errorMessage || '')); // 工具参数解析失败属于“单个工具调用失败”,后端会继续执行主任务。 // 这里不能停止轮询,否则会出现“后端继续跑、前端不再更新”的假死状态。 diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 55e50b6..995151c 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -18,18 +18,26 @@
{{ msg.content }}
-
+
-
+
@@ -1054,17 +1062,86 @@ function iconStyleSafe(key: string, size?: string) { return {}; } -function formatImageName(path: string): string { - if (!path) return ''; +function normalizeMediaPath(input: any): string { + if (!input) return ''; + if (typeof input === 'string') { + return input; + } + if (typeof input?.path === 'string') { + return input.path; + } + if (typeof input?.source_path === 'string') { + return input.source_path; + } + return ''; +} + +function formatImageName(input: any): string { + const path = normalizeMediaPath(input); + if (!path) { + if (typeof input?.name === 'string' && input.name) { + return input.name; + } + if (typeof input?.title === 'string' && input.title) { + return input.title; + } + if (typeof input?.media_id === 'string' && input.media_id) { + return input.media_id; + } + return ''; + } const parts = path.split(/[/\\]/); return parts[parts.length - 1] || path; } -function getPreviewUrl(path: string): string { +function resolveMessageMediaRef(message: any, input: any, kind: 'image' | 'video'): any | null { + const refs = message?.media_refs || message?.metadata?.media_refs || []; + if (!Array.isArray(refs) || !refs.length) { + return null; + } + const mediaId = typeof input?.media_id === 'string' ? input.media_id : ''; + if (mediaId) { + const matched = refs.find((item: any) => item?.media_id === mediaId); + if (matched) return matched; + } + const sourcePath = normalizeMediaPath(input); + if (sourcePath) { + const matched = refs.find( + (item: any) => + item && + item.kind === kind && + typeof item.source_path === 'string' && + item.source_path === sourcePath + ); + if (matched) return matched; + } + const firstSameKind = refs.find((item: any) => item && item.kind === kind); + return firstSameKind || null; +} + +function getPreviewUrl(message: any, input: any, kind: 'image' | 'video'): string { + const mediaRef = resolveMessageMediaRef(message, input, kind); + const mediaId = typeof mediaRef?.media_id === 'string' ? mediaRef.media_id : ''; + if (mediaId) { + return `/api/conversations/media/${encodeURIComponent(mediaId)}`; + } + const path = normalizeMediaPath(input); if (!path) return ''; return `/api/gui/files/download?path=${encodeURIComponent(path)}`; } +function mediaPreviewKey(message: any, input: any, index: number): string { + const mediaRef = + resolveMessageMediaRef(message, input, 'image') || + resolveMessageMediaRef(message, input, 'video'); + if (typeof mediaRef?.media_id === 'string' && mediaRef.media_id) { + return `${mediaRef.media_id}-${index}`; + } + const path = normalizeMediaPath(input); + if (path) return `${path}-${index}`; + return `media-${index}`; +} + function formatDurationMs(durationMs: number): string { const totalSeconds = Math.max(0, Math.floor((durationMs || 0) / 1000)); const hours = Math.floor(totalSeconds / 3600); diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index ea8ee85..d297181 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -903,7 +903,33 @@ export async function initializeLegacySocket(ctx: any) { if (!message) { return; } - ctx.chatAddUserMessage(message, data.images || [], data.videos || []); + const incomingImages = Array.isArray(data.images) ? data.images : []; + const incomingVideos = Array.isArray(data.videos) ? data.videos : []; + const incomingMediaRefs = Array.isArray(data.media_refs) + ? data.media_refs + : Array.isArray(data.mediaRefs) + ? data.mediaRefs + : []; + const last = + Array.isArray(ctx.messages) && ctx.messages.length + ? ctx.messages[ctx.messages.length - 1] + : null; + const isLikelyOptimisticEcho = !!( + last && + last.role === 'user' && + String(last.content || '').trim() === message && + JSON.stringify(last.images || []) === JSON.stringify(incomingImages || []) && + JSON.stringify(last.videos || []) === JSON.stringify(incomingVideos || []) + ); + if (isLikelyOptimisticEcho) { + last.media_refs = incomingMediaRefs; + last.metadata = { + ...(last.metadata || {}), + media_refs: incomingMediaRefs + }; + } else { + ctx.chatAddUserMessage(message, incomingImages, incomingVideos, incomingMediaRefs); + } ctx.taskInProgress = true; ctx.streamingMessage = false; ctx.stopRequested = false; diff --git a/static/src/stores/chat.ts b/static/src/stores/chat.ts index 9e7b2b5..ee92a69 100644 --- a/static/src/stores/chat.ts +++ b/static/src/stores/chat.ts @@ -174,14 +174,21 @@ export const useChatStore = defineStore('chat', { this.currentMessageIndex = this.messages.length - 1; return message; }, - addUserMessage(content: string, images: string[] = [], videos: string[] = []) { + addUserMessage( + content: string, + images: string[] = [], + videos: string[] = [], + mediaRefs: Array> = [] + ) { const startedAt = new Date().toISOString(); this.messages.push({ role: 'user', content, images, videos, + media_refs: Array.isArray(mediaRefs) ? mediaRefs : [], metadata: { + media_refs: Array.isArray(mediaRefs) ? mediaRefs : [], work_timer: { status: 'working', started_at: startedAt diff --git a/utils/media_store.py b/utils/media_store.py index 03dbe10..7cf720c 100644 --- a/utils/media_store.py +++ b/utils/media_store.py @@ -321,6 +321,18 @@ class MediaStore: return candidate, entry return None + def get_media_entry(self, media_id: str) -> Optional[Dict[str, Any]]: + target_id = str(media_id or "").strip() + if not target_id: + return None + with self._lock: + index = self._load_index() + media = index.get("media") if isinstance(index.get("media"), dict) else {} + entry = media.get(target_id) + if isinstance(entry, dict): + return dict(entry) + return None + def load_bytes(self, media_ref: Dict[str, Any]) -> Optional[bytes]: resolved = self._resolve_blob_path(media_ref or {}) if not resolved: @@ -331,6 +343,21 @@ class MediaStore: except Exception: return None + def load_bytes_by_media_id(self, media_id: str) -> Optional[bytes]: + target_id = str(media_id or "").strip() + if not target_id: + return None + entry = self.get_media_entry(target_id) + if not isinstance(entry, dict): + return None + ref = { + "media_id": target_id, + "store_path": entry.get("blob_rel_path"), + "kind": entry.get("kind"), + "mime_type": entry.get("mime_type"), + } + return self.load_bytes(ref) + def to_data_url(self, media_ref: Dict[str, Any]) -> Optional[str]: payload = self.load_bytes(media_ref) if payload is None: @@ -366,4 +393,3 @@ class MediaStore: } message_map[conv] = conv_map self._save_index(index) -