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. 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. 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 ## File Reading Guidelines
**Text Files** (use read_file): **Text Files** (use read_file):

View File

@ -1215,27 +1215,34 @@ public class ChatController {
|| lower.contains("client abort") || lower.contains("closed"); || 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 class StreamAccumulator {
private final StringBuilder content = new StringBuilder(); private final StringBuilder content = new StringBuilder();
private final StringBuilder thinking = new StringBuilder(); private final StringBuilder thinking = new StringBuilder();
private final List<Map<String, Object>> toolCalls = new ArrayList<>(); private final List<Map<String, Object>> toolCalls = new ArrayList<>();
/** 实时分段列表(与前端 currentSegments 对齐) */ /** 有序事件时间线 — 前端分段渲染的权威数据源 */
private final List<Map<String, Object>> segments = new ArrayList<>(); private final List<Map<String, Object>> segments = new ArrayList<>();
private int segCounter = 0; private int segCounter = 0;
private int promptTokens = 0; private int promptTokens = 0;
private int completionTokens = 0; private int completionTokens = 0;
private String runtimeModelName = ""; private String runtimeModelName = "";
private String runtimeProviderId = ""; private String runtimeProviderId = "";
/** 标记本次流是否因工具审批挂起而终止 */
private boolean awaitingApproval = false; private boolean awaitingApproval = false;
synchronized void accept(AgentService.StreamDelta delta, String conversationId) { synchronized void accept(AgentService.StreamDelta delta, String conversationId) {
if (delta == null) { if (delta == null) return;
return;
}
// 事件类型:直接广播为独立 SSE 事件名,不进入内容累积
if (delta.isEvent()) { if (delta.isEvent()) {
// 拦截内部 usage 事件,不广播给前端
if ("_usage_final".equals(delta.eventType())) { if ("_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
@ -1248,12 +1255,10 @@ public class ChatController {
String phase = String.valueOf(delta.eventData().getOrDefault("phase", "")); String phase = String.valueOf(delta.eventData().getOrDefault("phase", ""));
if (!phase.isBlank()) { if (!phase.isBlank()) {
streamTracker.updatePhase(conversationId, phase); streamTracker.updatePhase(conversationId, phase);
// 关键:phase 变化时关闭 running 的 content/thinking segments // phase 切换时关闭 running 的 content/thinking segment,保留边界
// 让每个阶段(reasoning → tool → summarize → final answer)的文字成为独立 segment finalizeRunningSegments("content", "thinking");
closeRunningSegments("content", "thinking");
} }
} }
// 累积工具调用事件,用于持久化到消息历史
accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId); accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId);
try { try {
broadcastEvent(conversationId, delta.eventType(), delta.eventData()); broadcastEvent(conversationId, delta.eventType(), delta.eventData());
@ -1262,22 +1267,40 @@ public class ChatController {
} }
return; return;
} }
// content_delta
if (delta.content() != null && !delta.content().isBlank()) { if (delta.content() != null && !delta.content().isBlank()) {
content.append(delta.content()); content.append(delta.content());
streamTracker.updatePhase(conversationId, "drafting_answer"); streamTracker.updatePhase(conversationId, "drafting_answer");
if (!delta.persistenceOnly()) { if (!delta.persistenceOnly()) {
broadcastEvent(conversationId, "content_delta", Map.of("delta", delta.content())); broadcastEvent(conversationId, "content_delta", Map.of("delta", delta.content()));
} }
// 分段:追加到当前 content segment 或创建新的 // segments: 追加到当前 running content segment,或创建新的
appendToContentSegment(delta.content()); 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()) { if (delta.thinking() != null && !delta.thinking().isBlank()) {
thinking.append(delta.thinking()); thinking.append(delta.thinking());
if (!delta.persistenceOnly()) { if (!delta.persistenceOnly()) {
broadcastEvent(conversationId, "thinking_delta", Map.of("delta", delta.thinking())); broadcastEvent(conversationId, "thinking_delta", Map.of("delta", delta.thinking()));
} }
// 分段:追加到当前 thinking segment 或创建新的 var seg = findLastRunning("thinking");
appendToThinkingSegment(delta.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; awaitingApproval = true;
streamTracker.updatePhase(conversationId, "awaiting_approval"); streamTracker.updatePhase(conversationId, "awaiting_approval");
} else if ("tool_call_started".equals(eventType)) { } else if ("tool_call_started".equals(eventType)) {
// toolCalls(兼容)
Map<String, Object> tc = new LinkedHashMap<>(); Map<String, Object> tc = new LinkedHashMap<>();
tc.put("name", data.getOrDefault("toolName", "")); tc.put("name", data.getOrDefault("toolName", ""));
tc.put("arguments", data.getOrDefault("arguments", "")); tc.put("arguments", data.getOrDefault("arguments", ""));
tc.put("status", "running"); tc.put("status", "running");
toolCalls.add(tc); toolCalls.add(tc);
// 分段:关闭 running 的 thinking/content,创建新 tool_call segment // segments: 关闭 running thinking/content,插入 tool_call
closeRunningSegments("thinking", "content"); finalizeRunningSegments("thinking", "content");
Map<String, Object> seg = new LinkedHashMap<>(); var seg = newSegment("tool_call");
seg.put("id", "tc-" + segCounter++);
seg.put("type", "tool_call");
seg.put("status", "running");
seg.put("toolName", data.getOrDefault("toolName", "")); seg.put("toolName", data.getOrDefault("toolName", ""));
seg.put("toolArgs", data.getOrDefault("arguments", "")); seg.put("toolArgs", data.getOrDefault("arguments", ""));
segments.add(seg); segments.add(seg);
} else if ("tool_call_completed".equals(eventType)) { } else if ("tool_call_completed".equals(eventType)) {
String toolName = String.valueOf(data.getOrDefault("toolName", "")); String toolName = String.valueOf(data.getOrDefault("toolName", ""));
// toolCalls(兼容)
for (int i = toolCalls.size() - 1; i >= 0; i--) { for (int i = toolCalls.size() - 1; i >= 0; i--) {
Map<String, Object> tc = toolCalls.get(i); Map<String, Object> tc = toolCalls.get(i);
if ("running".equals(tc.get("status")) && toolName.equals(tc.get("name"))) { if ("running".equals(tc.get("status")) && toolName.equals(tc.get("name"))) {
@ -1313,9 +1335,9 @@ public class ChatController {
break; break;
} }
} }
// 分段:标记对应 tool_call segment 完成 // segments: 标记对应 tool_call 完成
for (int i = segments.size() - 1; i >= 0; i--) { 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")) if ("tool_call".equals(seg.get("type")) && "running".equals(seg.get("status"))
&& toolName.equals(seg.get("toolName"))) { && toolName.equals(seg.get("toolName"))) {
seg.put("status", "completed"); seg.put("status", "completed");
@ -1327,62 +1349,37 @@ public class ChatController {
} }
} }
// ==================== 分段构建辅助方法 ==================== // ==================== Segment helpers ====================
private void appendToThinkingSegment(String text) { private Map<String, Object> newSegment(String type) {
// 查找最后一个 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;
}
}
// 没找到,创建新的
Map<String, Object> seg = new LinkedHashMap<>(); Map<String, Object> seg = new LinkedHashMap<>();
seg.put("id", "th-" + segCounter++); seg.put("id", type.substring(0, 2) + "-" + segCounter++);
seg.put("type", "thinking"); seg.put("type", type);
seg.put("status", "running"); seg.put("status", "running");
seg.put("thinkingText", text); return seg;
segments.add(seg);
} }
private void appendToContentSegment(String text) { private Map<String, Object> findLastRunning(String type) {
// 查找最后一个 running content segment
for (int i = segments.size() - 1; i >= 0; i--) { for (int i = segments.size() - 1; i >= 0; i--) {
Map<String, Object> seg = segments.get(i); var seg = segments.get(i);
if ("content".equals(seg.get("type")) && "running".equals(seg.get("status"))) { if (type.equals(seg.get("type")) && "running".equals(seg.get("status"))) return seg;
seg.put("text", seg.getOrDefault("text", "") + text);
return;
}
} }
// 没找到,关闭 thinking,创建新 content segment return null;
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);
} }
private void closeRunningSegments(String... types) { private void finalizeRunningSegments(String... types) {
java.util.Set<String> typeSet = java.util.Set.of(types); var typeSet = java.util.Set.of(types);
for (Map<String, Object> seg : segments) { for (var seg : segments) {
if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) { if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) {
seg.put("status", "completed"); 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 getPromptTokens() { return promptTokens; }
int getCompletionTokens() { return completionTokens; } int getCompletionTokens() { return completionTokens; }
String getRuntimeModelName() { return runtimeModelName; } String getRuntimeModelName() { return runtimeModelName; }
@ -1412,31 +1409,24 @@ public class ChatController {
return parts; return parts;
} }
/**
* 将所有仍为 running 的 tool calls 标记为 completed(流结束时调用)。
* 防止历史消息中出现永远转圈的工具调用。
*/
void finalizeToolCalls() { void finalizeToolCalls() {
for (Map<String, Object> tc : toolCalls) { for (Map<String, Object> tc : toolCalls) {
if ("running".equals(tc.get("status"))) { if ("running".equals(tc.get("status"))) tc.put("status", "completed");
tc.put("status", "completed");
}
} }
} }
/** /**
* 生成 metadata JSON:包含 toolCalls 及其他元数据 * 生成 metadata JSON:包含 toolCalls + segments。
* toolCalls 保留兼容旧 UI,segments 是按事件顺序的完整时间线。
*/ */
synchronized String toMetadataJson() { synchronized String toMetadataJson() {
// 确保所有 tool calls 和 segments 都不是 running 状态
finalizeToolCalls(); finalizeToolCalls();
closeRunningSegments("thinking", "content", "tool_call"); finalizeRunningSegments("thinking", "content", "tool_call");
try { try {
Map<String, Object> metadata = new LinkedHashMap<>(); Map<String, Object> metadata = new LinkedHashMap<>();
if (!toolCalls.isEmpty()) { if (!toolCalls.isEmpty()) {
metadata.put("toolCalls", toolCalls); metadata.put("toolCalls", toolCalls);
} }
// 使用实时构建的 segments(精确保留事件顺序,包含中间步骤)
if (!segments.isEmpty()) { if (!segments.isEmpty()) {
metadata.put("segments", segments); metadata.put("segments", segments);
} }

View File

@ -147,6 +147,43 @@ public class SkillFileTool {
return sb.toString(); 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") @SuppressWarnings("unchecked")
private void formatTree(StringBuilder sb, Map<String, Object> tree, String indent) { private void formatTree(StringBuilder sb, Map<String, Object> tree, String indent) {
for (Map.Entry<String, Object> entry : tree.entrySet()) { for (Map.Entry<String, Object> entry : tree.entrySet()) {

View File

@ -1,15 +1,21 @@
package vip.mate.workspace.conversation.vo; package vip.mate.workspace.conversation.vo;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data; import lombok.Data;
import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.model.MessageEntity; import vip.mate.workspace.conversation.model.MessageEntity;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
@Data @Data
public class MessageVO { public class MessageVO {
private static final ObjectMapper MAPPER = new ObjectMapper();
private Long id; private Long id;
private String conversationId; private String conversationId;
@ -22,7 +28,12 @@ public class MessageVO {
private String status; private String status;
private String metadata; // Agent 事件元数据(JSON):toolCalls, plan, currentPhase 等 /**
* Agent 事件元数据:toolCalls, segments 等。
* 类型为 Object,Jackson 序列化时直接输出 JSON 对象(而非字符串),
* 前端无需额外 parse。
*/
private Object metadata;
/** Prompt tokens 消耗 */ /** Prompt tokens 消耗 */
private Integer promptTokens; private Integer promptTokens;
@ -44,7 +55,8 @@ public class MessageVO {
vo.setContent(renderedContent); vo.setContent(renderedContent);
vo.setToolName(entity.getToolName()); vo.setToolName(entity.getToolName());
vo.setStatus(entity.getStatus()); vo.setStatus(entity.getStatus());
vo.setMetadata(entity.getMetadata()); // 包含元数据(toolCalls 等) // 将 JSON 字符串解析为 Map,Jackson 序列化时直接输出对象而非转义字符串
vo.setMetadata(parseMetadataToObject(entity.getMetadata()));
vo.setPromptTokens(entity.getPromptTokens()); vo.setPromptTokens(entity.getPromptTokens());
vo.setCompletionTokens(entity.getCompletionTokens()); vo.setCompletionTokens(entity.getCompletionTokens());
vo.setCreateTime(entity.getCreateTime()); vo.setCreateTime(entity.getCreateTime());
@ -52,4 +64,25 @@ public class MessageVO {
vo.setContentParts(contentParts); vo.setContentParts(contentParts);
return vo; 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. 保留关键结论和支撑证据 1. 识别用户问题中的每个独立子任务或子问题
2. 删除重复或冗余的观察内容 2. 每个子任务的结果必须保留为独立段落,使用编号或标题区分,不要合并不同子任务的结果
3. 对信息不确定的地方明确标注 3. 每个子任务段落中保留关键结论、具体数据和支撑证据
4. 不要包含原始工具调用日志或技术细节 4. 删除每个子任务内部的重复或冗余内容,但不要跨子任务合并
5. 输出控制在 800 字以内 5. 对信息不确定的地方明确标注
6. 使用清晰的结构化格式(要点列表) 6. 不要包含原始工具调用日志或技术细节
7. 如果有多个工具的结果相互补充,合并为统一结论 7. 每个子任务的摘要控制在 400 字以内,总输出不超过 2000 字
8. 使用清晰的结构化格式:先列出子任务标题,再给出该子任务的要点列表
格式示例:
### 子任务 1: [任务描述]
- 结论要点 1
- 结论要点 2
### 子任务 2: [任务描述]
- 结论要点 1
- 结论要点 2

View File

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

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { computed } from 'vue'
import { ArrowDown, InfoFilled } from '@element-plus/icons-vue'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer' import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import TypingCursor from './TypingCursor.vue' import TypingCursor from './TypingCursor.vue'
import type { MessageSegment } from '@/types' import type { MessageSegment } from '@/types'
@ -8,96 +7,26 @@ import type { MessageSegment } from '@/types'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
segment: MessageSegment segment: MessageSegment
showCursor?: boolean showCursor?: boolean
/** 是否是最后一个 content segment(最终回答) */
isLast?: boolean
}>(), { }>(), {
showCursor: false, showCursor: false,
isLast: true,
}) })
const { renderMarkdown } = useMarkdownRenderer() const { renderMarkdown } = useMarkdownRenderer()
const expanded = ref(props.isLast) // 最终回答默认展开,中间摘要默认折叠
const renderedContent = computed(() => renderMarkdown(props.segment.text || '')) const renderedContent = computed(() => renderMarkdown(props.segment.text || ''))
const isRunning = computed(() => props.segment.status === 'running') 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> </script>
<template> <template>
<div class="seg-content" :class="{ 'is-intermediate': isIntermediate }"> <div class="seg-content">
<!-- 中间推理摘要:折叠式显示 --> <div class="markdown-body" v-html="renderedContent"></div>
<div v-if="isIntermediate" class="seg-content__header" @click="expanded = !expanded"> <TypingCursor v-if="isRunning && showCursor" />
<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> </div>
</template> </template>
<style scoped> <style scoped>
.seg-content { .seg-content {
padding: 2px 0; padding: 4px 0;
} margin-top: 4px;
.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);
} }
</style> </style>

View File

@ -24,7 +24,7 @@
<template v-for="(seg, index) in segments" :key="seg.id"> <template v-for="(seg, index) in segments" :key="seg.id">
<ThinkingSegment v-if="seg.type === 'thinking'" :segment="seg" /> <ThinkingSegment v-if="seg.type === 'thinking'" :segment="seg" />
<ToolCallSegment v-if="seg.type === 'tool_call'" :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> </template>
</div> </div>
</template> </template>
@ -578,31 +578,49 @@ const parsedMetadata = computed(() => {
const raw = props.message.metadata const raw = props.message.metadata
if (!raw) return {} as any if (!raw) return {} as any
if (typeof raw === 'string') { 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 return raw
}) })
const segments = computed<MessageSegment[]>(() => { const segments = computed<MessageSegment[]>(() => {
const meta = parsedMetadata.value
if (props.message.role !== 'assistant') return [] if (props.message.role !== 'assistant') return []
const meta = parsedMetadata.value
// 从 contentParts 中提取 thinking(适用于所有来源:流式/历史/DB) // DEBUG: 临时日志,验证后删除
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking') 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 // 优先:使用 metadata.segments(流式时由前端写入,历史时由后端持久化)
if (meta?.segments && meta.segments.length > 0) { // 这是按事件顺序记录的完整时间线,保留了 thinking→tools→content 的真实交错
const segs = [...meta.segments] as MessageSegment[] if (meta?.segments && Array.isArray(meta.segments) && meta.segments.length > 0
&& typeof meta.segments[0] === 'object' && meta.segments[0]?.type) {
// 补充:如果后端 segments 没有 thinking 但 contentParts 有(非原生 thinking 模型) // 补充:如果后端 segments 没有 thinking 但 contentParts 有(非原生 thinking 模型)
const segs = [...meta.segments] as MessageSegment[]
const hasThinking = segs.some(s => s.type === 'thinking') const hasThinking = segs.some(s => s.type === 'thinking')
if (!hasThinking && thinkingPart?.text) { if (!hasThinking) {
segs.unshift({ id: 'th-0', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text }) 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 return segs
} }
// 降级:从 toolCalls + contentParts 重建 segments // Fallback:从 toolCalls + contentParts 做 best-effort 重建(旧消息兼容)
// 注意:这会丢失事件交错顺序(所有 thinking 在前,所有 tool calls 在中,content 在后)
const segs: MessageSegment[] = [] const segs: MessageSegment[] = []
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking')
if (thinkingPart?.text) { if (thinkingPart?.text) {
segs.push({ id: 'th-0', type: 'thinking', status: 'completed', thinkingText: 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 (!metadata) return {}
if (typeof metadata === 'string') { if (typeof metadata === 'string') {
try { 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) { } catch (e) {
console.warn('[useChat] Failed to parse metadata:', e) console.warn('[useChat] Failed to parse metadata:', e)
return {} return {}
@ -329,12 +334,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
setMessageStatus(currentAssistantId.value, data.status || 'completed') setMessageStatus(currentAssistantId.value, data.status || 'completed')
} }
// 更新 token 信息 // 更新 token 信息 + 用持久化 id 替换本地临时 id(关键:让 reconcile 能匹配)
const msgIndex = messages.value.findIndex(m => m.id === currentAssistantId.value) const msgIndex = messages.value.findIndex(m => m.id === currentAssistantId.value)
if (msgIndex >= 0) { if (msgIndex >= 0) {
const msg = messages.value[msgIndex] const msg = messages.value[msgIndex]
if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens
if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens
// 用后端持久化 id 替换本地临时 id,使 reconcile 时能按 id 匹配
if (data.assistantMessageId) {
msg.id = data.assistantMessageId
}
messages.value[msgIndex] = { ...msg } messages.value[msgIndex] = { ...msg }
} }
currentAssistantId.value = null 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 { channelIconUrl } from '@/utils/channelSource'
import { useChat } from '@/composables/chat/useChat' import { useChat } from '@/composables/chat/useChat'
import { reconstructErrorInfo } from '@/types/chatError' 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' 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 if (streamPhase.value === 'awaiting_approval') return
try { try {
const res: any = await conversationApi.listMessages(conversationId) 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) { } catch (e) {
console.warn('[ChatConsole] Failed to refresh current conversation messages:', e) console.warn('[ChatConsole] Failed to refresh current conversation messages:', e)
} }
@ -773,7 +776,7 @@ async function hydrateStateFromRoute() {
messages.value = [] messages.value = []
try { try {
const res: any = await conversationApi.listMessages(conversationId) 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 { } catch {
// 消息加载失败,保持空 // 消息加载失败,保持空
} }
@ -808,7 +811,7 @@ async function selectConversation(conv: Conversation) {
selectedAgentId.value = conv.agentId || selectedAgentId.value selectedAgentId.value = conv.agentId || selectedAgentId.value
try { try {
const res: any = await conversationApi.listMessages(conv.conversationId) 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:恢复刷新后丢失的审批卡片 // Hydrate pending approvals:恢复刷新后丢失的审批卡片
try { try {
@ -1173,9 +1176,17 @@ function buildOutgoingParts(text: string, attachments: ChatAttachment[]): Messag
function normalizeMessage(raw: Message): Message { function normalizeMessage(raw: Message): Message {
const msg: Message = { ...raw, contentParts: raw.contentParts ? [...raw.contentParts] : [] } const msg: Message = { ...raw, contentParts: raw.contentParts ? [...raw.contentParts] : [] }
// 统一解析 metadata:确保是对象而非 JSON 字符串(后端 API 返回字符串) // 统一解析 metadata:确保是对象而非 JSON 字符串
// 注意:后端 metadata 在 DB 中是 JSON 字符串,Jackson 序列化时可能双重编码
if (typeof msg.metadata === 'string') { 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 新增) // 保留后端返回的 token 字段(MessageVO 新增)