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