mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent): preserve per-subtask results in multi-task answer synthesis, add listAvailableSkills tool
This commit is contained in:
parent
ba34f1eed3
commit
367dd4bed0
@ -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):
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -3,4 +3,4 @@
|
||||
工具调用观察记录:
|
||||
{observations}
|
||||
|
||||
请生成精简的上下文摘要。
|
||||
请先识别用户问题中包含的独立子任务,然后为每个子任务分别生成摘要。确保每个子任务的关键结果都被完整保留。
|
||||
@ -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>
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
126
mateclaw-ui/src/utils/messageReconcile.ts
Normal file
126
mateclaw-ui/src/utils/messageReconcile.ts
Normal 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 }
|
||||
}
|
||||
@ -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 新增)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user