diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 09c49f29..b82b5927 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -276,21 +276,24 @@ public abstract class BaseAgent { /** * 构建当前用户消息的 UserMessage(含 multimodal 图片注入)。 *

- * 从 DB 读取最新的 user 消息的 contentParts,提取图片附件并注入 Media。 - * 用于 StateGraphReActAgent.buildInitialState 等需要构建当前消息的场景。 + * 从 DB 读取最后一条 user 消息的 contentParts,提取图片附件并注入 Media。 + * 不依赖文本相等匹配(避免重复文本误绑定到错误轮次),而是直接取最后一条 user 消息, + * 因为 buildInitialState 在 saveMessage 之后调用,最后一条 user 消息就是当前消息。 * * @param conversationId 会话 ID - * @param userMessageText 用户消息文本 + * @param userMessageText 用户消息文本(作为 fallback 内容) * @return 带图片 Media 的 UserMessage(如果有图片附件),否则纯文本 UserMessage */ protected UserMessage buildCurrentUserMessage(String conversationId, String userMessageText) { try { List history = conversationService.listMessages(conversationId); - // 倒序找最新的 user 消息(内容匹配) + // 倒序取最后一条 user 消息(buildInitialState 在 saveMessage 后调用,所以最后一条就是当前消息) for (int i = history.size() - 1; i >= 0; i--) { MessageEntity msg = history.get(i); - if ("user".equals(msg.getRole()) && userMessageText.equals(msg.getContent())) { - return buildUserMessage(msg, userMessageText); + if ("user".equals(msg.getRole())) { + // 用 DB 中的实际内容(可能包含 contentParts),不用传入的 text + String content = conversationService.renderMessageContent(msg); + return buildUserMessage(msg, content != null && !content.isBlank() ? content : userMessageText); } } } catch (Exception e) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 8d7d394b..4f71a0bd 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -736,14 +736,15 @@ public class ChatController { String message = request.getMessage(); Long agentId = request.getAgentId(); + List contentParts = request.getContentParts(); // 判断当前阶段是否可中断 // awaiting_approval 阶段不直接中断,只排队 boolean isAwaitingApproval = approvalService.findPendingByConversation(conversationId) != null; if (isAwaitingApproval) { - // 不可中断:排队但不打断。先持久化再入队(persisted=true) - conversationService.saveMessage(conversationId, "user", message, null, "queued"); + // 不可中断:排队但不打断。先持久化(含 contentParts)再入队(persisted=true) + conversationService.saveMessage(conversationId, "user", message, contentParts, "queued"); boolean queued = streamTracker.enqueueMessage(conversationId, message, agentId, true); log.info("Interrupt requested during approval, message queued: conversationId={}, user={}, queueSize={}", conversationId, username, streamTracker.getQueueSize(conversationId)); @@ -754,8 +755,8 @@ public class ChatController { )); } - // 可中断:先持久化再打断并入队(persisted=true) - conversationService.saveMessage(conversationId, "user", message, null, "queued"); + // 可中断:先持久化(含 contentParts)再打断并入队(persisted=true) + conversationService.saveMessage(conversationId, "user", message, contentParts, "queued"); boolean interrupted = streamTracker.requestInterrupt(conversationId, message, agentId, true); log.info("Interrupt requested: conversationId={}, user={}, interrupted={}, queueSize={}", conversationId, username, interrupted, streamTracker.getQueueSize(conversationId)); @@ -772,6 +773,8 @@ public class ChatController { public static class InterruptRequest { private String message; private Long agentId; + /** 结构化内容片段(含图片等附件),排队消息带附件时由前端传入 */ + private List contentParts; } /** diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index b6e9ed65..ac4831fd 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -659,7 +659,11 @@ export function useChat(options: UseChatOptions): UseChatReturn { try { const res = await fetchWithAuth(`${baseUrl}/api/v1/chat/${conversationId}/interrupt`, { method: 'POST', - body: JSON.stringify({ message: content, agentId }), + body: JSON.stringify({ + message: content, + agentId, + contentParts: options.contentParts || [], + }), }) const result = await res.json() diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 38f76adb..74a58096 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -474,6 +474,8 @@ onBeforeUnmount(() => { document.removeEventListener('click', handleCodeCopy) mobileQuery?.removeEventListener('change', handleMobileChange) stopChatGeneration() + // 释放所有附件的 ObjectURL,防止内存泄漏 + revokeAllPreviewUrls() }) watch(() => route.query, () => { @@ -809,6 +811,9 @@ async function handleSendMessage(content: string) { const outgoingAttachments = pendingAttachments.value.map((attachment) => ({ ...attachment })) const contentParts = buildOutgoingParts(content, outgoingAttachments) + // 先暂存,发送成功后再清空(失败时恢复) + const savedInput = inputText.value + const savedAttachments = [...pendingAttachments.value] inputText.value = '' chatInputRef.value?.clear?.() pendingAttachments.value = [] @@ -828,8 +833,13 @@ async function handleSendMessage(content: string) { path: a.path, })), }) + // 发送成功后释放 ObjectURL + revokeAllPreviewUrls() } catch (e) { console.error('Send message failed:', e) + // 发送失败:恢复输入和附件,用户不丢失已上传的文件 + if (!inputText.value) inputText.value = savedInput + if (pendingAttachments.value.length === 0) pendingAttachments.value = savedAttachments } } @@ -926,11 +936,25 @@ async function handleFileSelect(files: File[]) { } function removeAttachment(key: string) { + // revoke 被移除附件的 ObjectURL,防止内存泄漏 + const removed = pendingAttachments.value.find(a => a.storedName === key || a.path === key) + if (removed?.previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(removed.previewUrl) + } pendingAttachments.value = pendingAttachments.value.filter( a => a.storedName !== key && a.path !== key ) } +/** 释放所有 pending 附件的 ObjectURL */ +function revokeAllPreviewUrls() { + for (const a of pendingAttachments.value) { + if (a.previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(a.previewUrl) + } + } +} + function buildOutgoingParts(text: string, attachments: ChatAttachment[]): MessageContentPart[] { const parts: MessageContentPart[] = [] if (text) parts.push({ type: 'text', text })