diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 8231bd48..20f9ba28 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -604,6 +604,14 @@ public class AgentGraphBuilder { If a tool requires approval due to security policies, the system will prompt the user for confirmation. Only state you cannot access something if no relevant tool is available. + ## Multi-Part Question Guidelines + When the user asks multiple questions or requests multiple tasks in a single message: + 1. Structure your final answer with numbered sections, one per sub-task + 2. Each section must contain the complete, detailed result for that sub-task + 3. Never compress earlier sub-tasks into summary sentences while expanding the last one + 4. If observations were summarized during processing, reconstruct each section from the summary + 5. Treat each sub-task's result as equally important regardless of processing order + ## File Reading Guidelines **Text Files** (use read_file): 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 c2042a57..05df5d95 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 @@ -1215,27 +1215,34 @@ public class ChatController { || lower.contains("client abort") || lower.contains("closed"); } + /** + * 流式累积器 — 收集 StreamDelta 事件,持久化到 DB。 + *

+ * 维护两份数据: + *

+ * 两份数据从同一事件流构建,保证一致。segments 保留了 thinking → tools → content + * 的真实交错顺序,toolCalls 是 segments 中 tool_call 类型的平铺视图。 + */ private final class StreamAccumulator { private final StringBuilder content = new StringBuilder(); private final StringBuilder thinking = new StringBuilder(); private final List> toolCalls = new ArrayList<>(); - /** 实时分段列表(与前端 currentSegments 对齐) */ + /** 有序事件时间线 — 前端分段渲染的权威数据源 */ private final List> segments = new ArrayList<>(); private int segCounter = 0; private int promptTokens = 0; private int completionTokens = 0; private String runtimeModelName = ""; private String runtimeProviderId = ""; - /** 标记本次流是否因工具审批挂起而终止 */ private boolean awaitingApproval = false; synchronized void accept(AgentService.StreamDelta delta, String conversationId) { - if (delta == null) { - return; - } - // 事件类型:直接广播为独立 SSE 事件名,不进入内容累积 + if (delta == null) return; + if (delta.isEvent()) { - // 拦截内部 usage 事件,不广播给前端 if ("_usage_final".equals(delta.eventType())) { Map data = delta.eventData(); promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); @@ -1248,12 +1255,10 @@ public class ChatController { String phase = String.valueOf(delta.eventData().getOrDefault("phase", "")); if (!phase.isBlank()) { streamTracker.updatePhase(conversationId, phase); - // 关键:phase 变化时关闭 running 的 content/thinking segments - // 让每个阶段(reasoning → tool → summarize → final answer)的文字成为独立 segment - closeRunningSegments("content", "thinking"); + // phase 切换时关闭 running 的 content/thinking segment,保留边界 + finalizeRunningSegments("content", "thinking"); } } - // 累积工具调用事件,用于持久化到消息历史 accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId); try { broadcastEvent(conversationId, delta.eventType(), delta.eventData()); @@ -1262,22 +1267,40 @@ public class ChatController { } return; } + + // content_delta if (delta.content() != null && !delta.content().isBlank()) { content.append(delta.content()); streamTracker.updatePhase(conversationId, "drafting_answer"); if (!delta.persistenceOnly()) { broadcastEvent(conversationId, "content_delta", Map.of("delta", delta.content())); } - // 分段:追加到当前 content segment 或创建新的 - appendToContentSegment(delta.content()); + // segments: 追加到当前 running content segment,或创建新的 + var seg = findLastRunning("content"); + if (seg != null) { + seg.put("text", seg.getOrDefault("text", "") + delta.content()); + } else { + finalizeRunningSegments("thinking"); + var s = newSegment("content"); + s.put("text", delta.content()); + segments.add(s); + } } + + // thinking_delta if (delta.thinking() != null && !delta.thinking().isBlank()) { thinking.append(delta.thinking()); if (!delta.persistenceOnly()) { broadcastEvent(conversationId, "thinking_delta", Map.of("delta", delta.thinking())); } - // 分段:追加到当前 thinking segment 或创建新的 - appendToThinkingSegment(delta.thinking()); + var seg = findLastRunning("thinking"); + if (seg != null) { + seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + delta.thinking()); + } else { + var s = newSegment("thinking"); + s.put("thinkingText", delta.thinking()); + segments.add(s); + } } } @@ -1288,22 +1311,21 @@ public class ChatController { awaitingApproval = true; streamTracker.updatePhase(conversationId, "awaiting_approval"); } else if ("tool_call_started".equals(eventType)) { + // toolCalls(兼容) Map tc = new LinkedHashMap<>(); tc.put("name", data.getOrDefault("toolName", "")); tc.put("arguments", data.getOrDefault("arguments", "")); tc.put("status", "running"); toolCalls.add(tc); - // 分段:关闭 running 的 thinking/content,创建新 tool_call segment - closeRunningSegments("thinking", "content"); - Map seg = new LinkedHashMap<>(); - seg.put("id", "tc-" + segCounter++); - seg.put("type", "tool_call"); - seg.put("status", "running"); + // segments: 关闭 running thinking/content,插入 tool_call + finalizeRunningSegments("thinking", "content"); + var seg = newSegment("tool_call"); seg.put("toolName", data.getOrDefault("toolName", "")); seg.put("toolArgs", data.getOrDefault("arguments", "")); segments.add(seg); } else if ("tool_call_completed".equals(eventType)) { String toolName = String.valueOf(data.getOrDefault("toolName", "")); + // toolCalls(兼容) for (int i = toolCalls.size() - 1; i >= 0; i--) { Map tc = toolCalls.get(i); if ("running".equals(tc.get("status")) && toolName.equals(tc.get("name"))) { @@ -1313,9 +1335,9 @@ public class ChatController { break; } } - // 分段:标记对应 tool_call segment 完成 + // segments: 标记对应 tool_call 完成 for (int i = segments.size() - 1; i >= 0; i--) { - Map seg = segments.get(i); + var seg = segments.get(i); if ("tool_call".equals(seg.get("type")) && "running".equals(seg.get("status")) && toolName.equals(seg.get("toolName"))) { seg.put("status", "completed"); @@ -1327,62 +1349,37 @@ public class ChatController { } } - // ==================== 分段构建辅助方法 ==================== + // ==================== Segment helpers ==================== - private void appendToThinkingSegment(String text) { - // 查找最后一个 running thinking segment - for (int i = segments.size() - 1; i >= 0; i--) { - Map seg = segments.get(i); - if ("thinking".equals(seg.get("type")) && "running".equals(seg.get("status"))) { - seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + text); - return; - } - } - // 没找到,创建新的 + private Map newSegment(String type) { Map seg = new LinkedHashMap<>(); - seg.put("id", "th-" + segCounter++); - seg.put("type", "thinking"); + seg.put("id", type.substring(0, 2) + "-" + segCounter++); + seg.put("type", type); seg.put("status", "running"); - seg.put("thinkingText", text); - segments.add(seg); + return seg; } - private void appendToContentSegment(String text) { - // 查找最后一个 running content segment + private Map findLastRunning(String type) { for (int i = segments.size() - 1; i >= 0; i--) { - Map seg = segments.get(i); - if ("content".equals(seg.get("type")) && "running".equals(seg.get("status"))) { - seg.put("text", seg.getOrDefault("text", "") + text); - return; - } + var seg = segments.get(i); + if (type.equals(seg.get("type")) && "running".equals(seg.get("status"))) return seg; } - // 没找到,关闭 thinking,创建新 content segment - closeRunningSegments("thinking"); - Map seg = new LinkedHashMap<>(); - seg.put("id", "ct-" + segCounter++); - seg.put("type", "content"); - seg.put("status", "running"); - seg.put("text", text); - segments.add(seg); + return null; } - private void closeRunningSegments(String... types) { - java.util.Set typeSet = java.util.Set.of(types); - for (Map seg : segments) { + private void finalizeRunningSegments(String... types) { + var typeSet = java.util.Set.of(types); + for (var seg : segments) { if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) { seg.put("status", "completed"); } } } - String getContent() { - return content.toString().trim(); - } - - String getThinking() { - return thinking.toString().trim(); - } + // ==================== 原有访问器 ==================== + String getContent() { return content.toString().trim(); } + String getThinking() { return thinking.toString().trim(); } int getPromptTokens() { return promptTokens; } int getCompletionTokens() { return completionTokens; } String getRuntimeModelName() { return runtimeModelName; } @@ -1412,31 +1409,24 @@ public class ChatController { return parts; } - /** - * 将所有仍为 running 的 tool calls 标记为 completed(流结束时调用)。 - * 防止历史消息中出现永远转圈的工具调用。 - */ void finalizeToolCalls() { for (Map tc : toolCalls) { - if ("running".equals(tc.get("status"))) { - tc.put("status", "completed"); - } + if ("running".equals(tc.get("status"))) tc.put("status", "completed"); } } /** - * 生成 metadata JSON:包含 toolCalls 及其他元数据 + * 生成 metadata JSON:包含 toolCalls + segments。 + * toolCalls 保留兼容旧 UI,segments 是按事件顺序的完整时间线。 */ synchronized String toMetadataJson() { - // 确保所有 tool calls 和 segments 都不是 running 状态 finalizeToolCalls(); - closeRunningSegments("thinking", "content", "tool_call"); + finalizeRunningSegments("thinking", "content", "tool_call"); try { Map metadata = new LinkedHashMap<>(); if (!toolCalls.isEmpty()) { metadata.put("toolCalls", toolCalls); } - // 使用实时构建的 segments(精确保留事件顺序,包含中间步骤) if (!segments.isEmpty()) { metadata.put("segments", segments); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java index cf2859c4..abc0c89e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java @@ -147,6 +147,43 @@ public class SkillFileTool { return sb.toString(); } + @Tool(description = """ + 列出所有当前可用的技能(Skills),包括名称、图标和描述。 + 使用此工具查看系统中有哪些已启用且可运行的技能。 + 注意:这里列出的是技能(Skills),不是 Agent。如需列出可用 Agent,请使用 listAvailableAgents。 + + Returns: A formatted list of active skills with name, icon, and description. + """) + public String listAvailableSkills() { + log.info("Listing available skills"); + + List activeSkills = runtimeService.getActiveSkills(); + + if (activeSkills.isEmpty()) { + return "当前没有可用的技能(Skills)。"; + } + + StringBuilder sb = new StringBuilder("可用技能(Skills)列表:\n\n"); + for (ResolvedSkill skill : activeSkills) { + sb.append("- **").append(skill.getName()).append("**"); + if (skill.getIcon() != null && !skill.getIcon().isBlank()) { + sb.append(" ").append(skill.getIcon()); + } + if (skill.getDescription() != null && !skill.getDescription().isBlank()) { + String desc = skill.getDescription(); + if (desc.length() > 200) { + desc = desc.substring(0, 200) + "..."; + } + sb.append(" — ").append(desc); + } + sb.append("\n"); + } + + sb.append("\n共 ").append(activeSkills.size()).append(" 个可用技能。"); + sb.append("\n\n使用 `readSkillFile` 读取技能详情,使用 `runSkillScript` 执行技能脚本。"); + return sb.toString(); + } + @SuppressWarnings("unchecked") private void formatTree(StringBuilder sb, Map tree, String indent) { for (Map.Entry entry : tree.entrySet()) { diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java index a513a466..37c7381e 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/MessageVO.java @@ -1,15 +1,21 @@ package vip.mate.workspace.conversation.vo; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Data; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; +import java.util.Map; @Data public class MessageVO { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private Long id; private String conversationId; @@ -22,7 +28,12 @@ public class MessageVO { private String status; - private String metadata; // Agent 事件元数据(JSON):toolCalls, plan, currentPhase 等 + /** + * Agent 事件元数据:toolCalls, segments 等。 + * 类型为 Object,Jackson 序列化时直接输出 JSON 对象(而非字符串), + * 前端无需额外 parse。 + */ + private Object metadata; /** Prompt tokens 消耗 */ private Integer promptTokens; @@ -44,7 +55,8 @@ public class MessageVO { vo.setContent(renderedContent); vo.setToolName(entity.getToolName()); vo.setStatus(entity.getStatus()); - vo.setMetadata(entity.getMetadata()); // 包含元数据(toolCalls 等) + // 将 JSON 字符串解析为 Map,Jackson 序列化时直接输出对象而非转义字符串 + vo.setMetadata(parseMetadataToObject(entity.getMetadata())); vo.setPromptTokens(entity.getPromptTokens()); vo.setCompletionTokens(entity.getCompletionTokens()); vo.setCreateTime(entity.getCreateTime()); @@ -52,4 +64,25 @@ public class MessageVO { vo.setContentParts(contentParts); return vo; } + + private static Object parseMetadataToObject(String metadataJson) { + if (metadataJson == null || metadataJson.isBlank()) { + return Collections.emptyMap(); + } + try { + // H2 JSON 列可能返回带引号包裹的字符串,需要先 unwrap + String json = metadataJson.trim(); + if (json.startsWith("\"") && json.endsWith("\"")) { + // 双重包裹:H2 JSON 类型 getString() 返回了 JSON string literal + json = MAPPER.readValue(json, String.class); + } + if (json.isBlank() || "{}".equals(json)) { + return Collections.emptyMap(); + } + return MAPPER.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + // 解析失败时返回空 map 而非原始字符串,避免前端拿到字符串 + return Collections.emptyMap(); + } + } } diff --git a/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt b/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt index 93b209f1..1a83da75 100644 --- a/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt +++ b/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt @@ -1,10 +1,20 @@ -你是一个信息整理助手。请基于用户的原始问题和多轮工具调用的观察结果,生成一份精简的上下文摘要。 +你是一个信息整理助手。请基于用户的原始问题和多轮工具调用的观察结果,生成一份结构化的上下文摘要。 要求: -1. 保留关键结论和支撑证据 -2. 删除重复或冗余的观察内容 -3. 对信息不确定的地方明确标注 -4. 不要包含原始工具调用日志或技术细节 -5. 输出控制在 800 字以内 -6. 使用清晰的结构化格式(要点列表) -7. 如果有多个工具的结果相互补充,合并为统一结论 \ No newline at end of file +1. 识别用户问题中的每个独立子任务或子问题 +2. 每个子任务的结果必须保留为独立段落,使用编号或标题区分,不要合并不同子任务的结果 +3. 每个子任务段落中保留关键结论、具体数据和支撑证据 +4. 删除每个子任务内部的重复或冗余内容,但不要跨子任务合并 +5. 对信息不确定的地方明确标注 +6. 不要包含原始工具调用日志或技术细节 +7. 每个子任务的摘要控制在 400 字以内,总输出不超过 2000 字 +8. 使用清晰的结构化格式:先列出子任务标题,再给出该子任务的要点列表 + +格式示例: +### 子任务 1: [任务描述] +- 结论要点 1 +- 结论要点 2 + +### 子任务 2: [任务描述] +- 结论要点 1 +- 结论要点 2 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/graph/summarize-user.txt b/mateclaw-server/src/main/resources/prompts/graph/summarize-user.txt index 9a8316e6..67597523 100644 --- a/mateclaw-server/src/main/resources/prompts/graph/summarize-user.txt +++ b/mateclaw-server/src/main/resources/prompts/graph/summarize-user.txt @@ -3,4 +3,4 @@ 工具调用观察记录: {observations} -请生成精简的上下文摘要。 \ No newline at end of file +请先识别用户问题中包含的独立子任务,然后为每个子任务分别生成摘要。确保每个子任务的关键结果都被完整保留。 \ No newline at end of file diff --git a/mateclaw-ui/src/components/chat/ContentSegment.vue b/mateclaw-ui/src/components/chat/ContentSegment.vue index db792060..1d6fef1d 100644 --- a/mateclaw-ui/src/components/chat/ContentSegment.vue +++ b/mateclaw-ui/src/components/chat/ContentSegment.vue @@ -1,6 +1,5 @@ diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 67c39282..3cef443d 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -24,7 +24,7 @@ @@ -578,31 +578,49 @@ const parsedMetadata = computed(() => { const raw = props.message.metadata if (!raw) return {} as any if (typeof raw === 'string') { - try { return JSON.parse(raw) } catch { return {} } + try { + let parsed = JSON.parse(raw) + // 处理双重 JSON 编码(DB 中 metadata 是字符串,Jackson 序列化时可能再次转义) + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed) } catch { /* ignore */ } + } + return parsed + } catch { return {} } } return raw }) const segments = computed(() => { - const meta = parsedMetadata.value if (props.message.role !== 'assistant') return [] + const meta = parsedMetadata.value - // 从 contentParts 中提取 thinking(适用于所有来源:流式/历史/DB) - const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking') + // DEBUG: 临时日志,验证后删除 + if (meta?.segments) { + console.debug('[MessageBubble] segments found:', meta.segments.length, 'first:', meta.segments[0]?.type) + } else { + console.debug('[MessageBubble] NO segments in metadata, keys:', Object.keys(meta || {})) + } - // 优先使用后端持久化的 segments - if (meta?.segments && meta.segments.length > 0) { - const segs = [...meta.segments] as MessageSegment[] + // 优先:使用 metadata.segments(流式时由前端写入,历史时由后端持久化) + // 这是按事件顺序记录的完整时间线,保留了 thinking→tools→content 的真实交错 + if (meta?.segments && Array.isArray(meta.segments) && meta.segments.length > 0 + && typeof meta.segments[0] === 'object' && meta.segments[0]?.type) { // 补充:如果后端 segments 没有 thinking 但 contentParts 有(非原生 thinking 模型) + const segs = [...meta.segments] as MessageSegment[] const hasThinking = segs.some(s => s.type === 'thinking') - if (!hasThinking && thinkingPart?.text) { - segs.unshift({ id: 'th-0', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text }) + if (!hasThinking) { + const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking') + if (thinkingPart?.text) { + segs.unshift({ id: 'th-fb', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text }) + } } return segs } - // 降级:从 toolCalls + contentParts 重建 segments + // Fallback:从 toolCalls + contentParts 做 best-effort 重建(旧消息兼容) + // 注意:这会丢失事件交错顺序(所有 thinking 在前,所有 tool calls 在中,content 在后) const segs: MessageSegment[] = [] + const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking') if (thinkingPart?.text) { segs.push({ id: 'th-0', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text }) } diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 43d5fe2c..ae9d502d 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -139,7 +139,12 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (!metadata) return {} if (typeof metadata === 'string') { try { - return JSON.parse(metadata) + let parsed = JSON.parse(metadata) + // 处理双重 JSON 编码(DB metadata 是字符串,Jackson 可能再次转义) + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed) } catch { /* ignore */ } + } + return parsed } catch (e) { console.warn('[useChat] Failed to parse metadata:', e) return {} @@ -329,12 +334,16 @@ export function useChat(options: UseChatOptions): UseChatReturn { setMessageStatus(currentAssistantId.value, data.status || 'completed') } - // 更新 token 信息 + // 更新 token 信息 + 用持久化 id 替换本地临时 id(关键:让 reconcile 能匹配) const msgIndex = messages.value.findIndex(m => m.id === currentAssistantId.value) if (msgIndex >= 0) { const msg = messages.value[msgIndex] if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens + // 用后端持久化 id 替换本地临时 id,使 reconcile 时能按 id 匹配 + if (data.assistantMessageId) { + msg.id = data.assistantMessageId + } messages.value[msgIndex] = { ...msg } } currentAssistantId.value = null diff --git a/mateclaw-ui/src/utils/messageReconcile.ts b/mateclaw-ui/src/utils/messageReconcile.ts new file mode 100644 index 00000000..3906cc1e --- /dev/null +++ b/mateclaw-ui/src/utils/messageReconcile.ts @@ -0,0 +1,126 @@ +/** + * 消息 reconcile 工具 — 防止 poorer DB 快照覆盖 local rich message。 + * + * 问题场景:流结束后 onStreamEnd → refreshCurrentConversationMessages() 从 DB 拉消息, + * 但此时后端 API 返回的 metadata 可能因 JSON 编码问题丢失 segments, + * 整表替换会把本地包含完整 segments 的 rich message 覆盖为 poorer 版本。 + * + * 解决:逐条按 id 比较"丰富度",只接受更完整的版本。 + */ +import type { Message } from '@/types' + +/** 安全 parse metadata(处理 string / double-encoded / object / null) */ +function safeParseMeta(metadata: any): Record { + if (!metadata) return {} + if (typeof metadata === 'object') return metadata + if (typeof metadata === 'string') { + try { + let parsed = JSON.parse(metadata) + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed) } catch { /* ignore */ } + } + return typeof parsed === 'object' && parsed !== null ? parsed : {} + } catch { + return {} + } + } + return {} +} + +/** + * 计算消息丰富度分数。分数越高,消息包含的展示信息越完整。 + * 只对 assistant 消息有意义。 + */ +export function messageRichness(msg: Message): number { + if (msg.role !== 'assistant') return 0 + let score = 0 + const meta = safeParseMeta(msg.metadata) + + // segments 是分段渲染的权威数据源,权重最高 + const segs = Array.isArray(meta.segments) ? meta.segments : [] + score += segs.length * 10 + + // toolCalls + const tcs = Array.isArray(meta.toolCalls) ? meta.toolCalls : [] + score += tcs.length * 5 + + // contentParts 中的 thinking / tool_call + const parts = Array.isArray(msg.contentParts) ? msg.contentParts : [] + if (parts.some(p => p.type === 'thinking')) score += 20 + score += parts.filter(p => p.type === 'tool_call').length * 5 + + // content 文本长度(capped,避免长文本主导) + score += Math.min((msg.content?.length || 0), 500) + + return score +} + +/** + * 逐条 reconcile:对每条消息按 id 匹配,只接受更丰富的版本。 + * + * - 新消息(fetched 有但 local 没有):接受 fetched + * - 非 assistant 消息:直接用 fetched + * - assistant 消息:比较 richness,取更高分的版本 + * - 本地有但 fetched 没有的 assistant 消息:保留(防止 lagging snapshot 丢消息) + */ +export function reconcileMessages(local: Message[], fetched: Message[]): Message[] { + if (!local.length) return fetched + if (!fetched.length) return local + + const localMap = new Map() + for (const m of local) { + localMap.set(String(m.id), m) + } + + const matchedLocalIds = new Set() + const result: Message[] = [] + + for (const fm of fetched) { + const fid = String(fm.id) + const lm = localMap.get(fid) + + if (!lm) { + result.push(fm) + } else if (fm.role !== 'assistant') { + result.push(fm) + matchedLocalIds.add(fid) + } else { + // assistant 消息:比较丰富度,取更高分的 + const lr = messageRichness(lm) + const fr = messageRichness(fm) + result.push(fr >= lr ? fm : lm) + matchedLocalIds.add(fid) + } + } + + // 保留 fetched 中不存在的本地 assistant 消息(防止 lagging snapshot 丢弃刚完成的消息) + for (const lm of local) { + const lid = String(lm.id) + if (!matchedLocalIds.has(lid) && lm.role === 'assistant') { + // 检查是否是 fetched 末尾之后的消息(刚完成,DB 还没返回) + const lastFetchedTime = result.length > 0 ? result[result.length - 1].createTime : '' + if (!lastFetchedTime || (lm.createTime && lm.createTime >= lastFetchedTime)) { + result.push(lm) + } + } + } + + return result +} + +/** + * 统一解析 API 响应中的消息列表。 + * 后端有两种返回模式: + * - 不传 limit:R.ok(List) → res.data 是数组 + * - 传 limit:R.ok({messages, hasMore}) → res.data 是对象 + */ +export function extractMessages(res: any): { messages: any[], hasMore: boolean } { + const data = res?.data + if (Array.isArray(data)) { + return { messages: data, hasMore: false } + } + if (data && Array.isArray(data.messages)) { + return { messages: data.messages, hasMore: !!data.hasMore } + } + return { messages: [], hasMore: false } +} diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 79a3876b..c85773f1 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -271,6 +271,7 @@ import { conversationApi, agentApi, modelApi, chatApi } from '@/api/index' import { channelIconUrl } from '@/utils/channelSource' import { useChat } from '@/composables/chat/useChat' import { reconstructErrorInfo } from '@/types/chatError' +import { reconcileMessages, extractMessages } from '@/utils/messageReconcile' import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo, ChatAttachment, MessageContentPart, Message, ToolCallMeta, StreamPhase } from '@/types' // 导入组件化组件 @@ -749,7 +750,9 @@ async function refreshCurrentConversationMessages(conversationId: string) { if (streamPhase.value === 'awaiting_approval') return try { const res: any = await conversationApi.listMessages(conversationId) - messages.value = (res.data || []).map((msg: Message) => normalizeMessage(msg)) + const fetched = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg)) + // 逐条 reconcile:只接受更丰富的版本,防止 poorer DB 快照覆盖 local rich message + messages.value = reconcileMessages(messages.value, fetched) } catch (e) { console.warn('[ChatConsole] Failed to refresh current conversation messages:', e) } @@ -773,7 +776,7 @@ async function hydrateStateFromRoute() { messages.value = [] try { const res: any = await conversationApi.listMessages(conversationId) - messages.value = (res.data || []).map((msg: Message) => normalizeMessage(msg)) + messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg)) } catch { // 消息加载失败,保持空 } @@ -808,7 +811,7 @@ async function selectConversation(conv: Conversation) { selectedAgentId.value = conv.agentId || selectedAgentId.value try { const res: any = await conversationApi.listMessages(conv.conversationId) - messages.value = (res.data || []).map((msg: Message) => normalizeMessage(msg)) + messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg)) // Hydrate pending approvals:恢复刷新后丢失的审批卡片 try { @@ -1173,9 +1176,17 @@ function buildOutgoingParts(text: string, attachments: ChatAttachment[]): Messag function normalizeMessage(raw: Message): Message { const msg: Message = { ...raw, contentParts: raw.contentParts ? [...raw.contentParts] : [] } - // 统一解析 metadata:确保是对象而非 JSON 字符串(后端 API 返回字符串) + // 统一解析 metadata:确保是对象而非 JSON 字符串 + // 注意:后端 metadata 在 DB 中是 JSON 字符串,Jackson 序列化时可能双重编码 if (typeof msg.metadata === 'string') { - try { msg.metadata = JSON.parse(msg.metadata) } catch { msg.metadata = {} as any } + try { + let parsed = JSON.parse(msg.metadata) + // 处理双重编码:parse 后仍然是字符串的情况 + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed) } catch { /* ignore */ } + } + msg.metadata = parsed + } catch { msg.metadata = {} as any } } // 保留后端返回的 token 字段(MessageVO 新增)