fix(chat): harden multimodal attachment handling across all paths

This commit is contained in:
matevip 2026-04-06 07:03:11 +08:00
parent 84a205509b
commit cc0569440e
4 changed files with 45 additions and 11 deletions

View File

@ -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) {

View File

@ -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;
}
/**

View File

@ -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()

View File

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