mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(chat): harden multimodal attachment handling across all paths
This commit is contained in:
parent
84a205509b
commit
cc0569440e
@ -276,21 +276,24 @@ public abstract class BaseAgent {
|
||||
/**
|
||||
* 构建当前用户消息的 UserMessage(含 multimodal 图片注入)。
|
||||
* <p>
|
||||
* 从 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<MessageEntity> 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) {
|
||||
|
||||
@ -736,14 +736,15 @@ public class ChatController {
|
||||
|
||||
String message = request.getMessage();
|
||||
Long agentId = request.getAgentId();
|
||||
List<MessageContentPart> 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<MessageContentPart> contentParts;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 })
|
||||
|
||||
Loading…
Reference in New Issue
Block a user