fix(agent): preserve per-subtask results in multi-task answer synthesis, add listAvailableSkills tool

This commit is contained in:
matevip 2026-04-10 17:10:02 +08:00
parent ba34f1eed3
commit 367dd4bed0
11 changed files with 351 additions and 180 deletions

View File

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

View File

@ -1215,27 +1215,34 @@ public class ChatController {
|| lower.contains("client abort") || lower.contains("closed");
}
/**
* 流式累积器 — 收集 StreamDelta 事件,持久化到 DB。
* <p>
* 维护两份数据:
* <ul>
* <li>{@code toolCalls} — 兼容旧逻辑(执行面板等 UI 使用)</li>
* <li>{@code segments} — 按事件到达顺序记录的有序时间线(前端分段渲染用)</li>
* </ul>
* 两份数据从同一事件流构建,保证一致。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<Map<String, Object>> toolCalls = new ArrayList<>();
/** 实时分段列表(与前端 currentSegments 对齐) */
/** 有序事件时间线 — 前端分段渲染的权威数据源 */
private final List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> newSegment(String type) {
Map<String, Object> 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<String, Object> findLastRunning(String type) {
for (int i = segments.size() - 1; i >= 0; i--) {
Map<String, Object> 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<String, Object> 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<String> typeSet = java.util.Set.of(types);
for (Map<String, Object> 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<String, Object> 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<String, Object> metadata = new LinkedHashMap<>();
if (!toolCalls.isEmpty()) {
metadata.put("toolCalls", toolCalls);
}
// 使用实时构建的 segments(精确保留事件顺序,包含中间步骤)
if (!segments.isEmpty()) {
metadata.put("segments", segments);
}

View File

@ -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<ResolvedSkill> 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<String, Object> tree, String indent) {
for (Map.Entry<String, Object> entry : tree.entrySet()) {

View File

@ -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<Map<String, Object>>() {});
} catch (Exception e) {
// 解析失败时返回空 map 而非原始字符串,避免前端拿到字符串
return Collections.emptyMap();
}
}
}

View File

@ -1,10 +1,20 @@
你是一个信息整理助手。请基于用户的原始问题和多轮工具调用的观察结果,生成一份精简的上下文摘要。
你是一个信息整理助手。请基于用户的原始问题和多轮工具调用的观察结果,生成一份结构化的上下文摘要。
要求:
1. 保留关键结论和支撑证据
2. 删除重复或冗余的观察内容
3. 对信息不确定的地方明确标注
4. 不要包含原始工具调用日志或技术细节
5. 输出控制在 800 字以内
6. 使用清晰的结构化格式(要点列表)
7. 如果有多个工具的结果相互补充,合并为统一结论
1. 识别用户问题中的每个独立子任务或子问题
2. 每个子任务的结果必须保留为独立段落,使用编号或标题区分,不要合并不同子任务的结果
3. 每个子任务段落中保留关键结论、具体数据和支撑证据
4. 删除每个子任务内部的重复或冗余内容,但不要跨子任务合并
5. 对信息不确定的地方明确标注
6. 不要包含原始工具调用日志或技术细节
7. 每个子任务的摘要控制在 400 字以内,总输出不超过 2000 字
8. 使用清晰的结构化格式:先列出子任务标题,再给出该子任务的要点列表
格式示例:
### 子任务 1: [任务描述]
- 结论要点 1
- 结论要点 2
### 子任务 2: [任务描述]
- 结论要点 1
- 结论要点 2

View File

@ -3,4 +3,4 @@
工具调用观察记录:
{observations}
请生成精简的上下文摘要。
请先识别用户问题中包含的独立子任务,然后为每个子任务分别生成摘要。确保每个子任务的关键结果都被完整保留。

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ArrowDown, InfoFilled } from '@element-plus/icons-vue'
import { computed } from 'vue'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import TypingCursor from './TypingCursor.vue'
import type { MessageSegment } from '@/types'
@ -8,96 +7,26 @@ import type { MessageSegment } from '@/types'
const props = withDefaults(defineProps<{
segment: MessageSegment
showCursor?: boolean
/** 是否是最后一个 content segment(最终回答) */
isLast?: boolean
}>(), {
showCursor: false,
isLast: true,
})
const { renderMarkdown } = useMarkdownRenderer()
const expanded = ref(props.isLast) // 最终回答默认展开,中间摘要默认折叠
const renderedContent = computed(() => renderMarkdown(props.segment.text || ''))
const isRunning = computed(() => props.segment.status === 'running')
const isIntermediate = computed(() => !props.isLast)
const textLength = computed(() => {
const len = props.segment.text?.length || 0
return len < 1000 ? `${len} 字` : `${(len / 1000).toFixed(1)}k 字`
})
</script>
<template>
<div class="seg-content" :class="{ 'is-intermediate': isIntermediate }">
<!-- 中间推理摘要:折叠式显示 -->
<div v-if="isIntermediate" class="seg-content__header" @click="expanded = !expanded">
<el-icon class="seg-content__info-icon" :size="13"><InfoFilled /></el-icon>
<span class="seg-content__label">中间推理总结</span>
<span class="seg-content__hint">{{ textLength }}</span>
<el-icon class="seg-content__arrow" :class="{ 'is-open': expanded }" :size="11"><ArrowDown /></el-icon>
</div>
<!-- 内容体 -->
<Transition name="seg-slide">
<div v-if="expanded || isLast" class="seg-content__body">
<div class="markdown-body" v-html="renderedContent"></div>
<TypingCursor v-if="isRunning && showCursor" />
</div>
</Transition>
<div class="seg-content">
<div class="markdown-body" v-html="renderedContent"></div>
<TypingCursor v-if="isRunning && showCursor" />
</div>
</template>
<style scoped>
.seg-content {
padding: 2px 0;
}
.seg-content.is-intermediate {
margin: 3px 0;
border-radius: 8px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
overflow: hidden;
}
.seg-content__header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
color: var(--mc-text-tertiary);
user-select: none;
transition: color 0.15s;
}
.seg-content__header:hover {
color: var(--mc-text-secondary);
}
.seg-content__info-icon {
color: var(--mc-primary-light);
}
.seg-content__label {
font-weight: 500;
flex: 1;
}
.seg-content__hint {
font-size: 11px;
}
.seg-content__arrow {
transition: transform 0.2s;
}
.seg-content__arrow.is-open {
transform: rotate(180deg);
}
.is-intermediate .seg-content__body {
padding: 0 12px 8px;
}
.seg-slide-enter-active, .seg-slide-leave-active {
transition: all 0.2s ease;
}
.seg-slide-enter-from, .seg-slide-leave-to {
opacity: 0;
transform: translateY(-4px);
padding: 4px 0;
margin-top: 4px;
}
</style>

View File

@ -24,7 +24,7 @@
<template v-for="(seg, index) in segments" :key="seg.id">
<ThinkingSegment v-if="seg.type === 'thinking'" :segment="seg" />
<ToolCallSegment v-if="seg.type === 'tool_call'" :segment="seg" />
<ContentSegment v-if="seg.type === 'content'" :segment="seg" :is-last="index === segments.length - 1" :show-cursor="showCursor && seg.status === 'running'" />
<ContentSegment v-if="seg.type === 'content'" :segment="seg" :show-cursor="showCursor && seg.status === 'running'" />
</template>
</div>
</template>
@ -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<MessageSegment[]>(() => {
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 })
}

View File

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

View File

@ -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<string, any> {
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<string, Message>()
for (const m of local) {
localMap.set(String(m.id), m)
}
const matchedLocalIds = new Set<string>()
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<MessageVO>) → 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 }
}

View File

@ -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 新增)