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 保留兼容旧 UIsegments 是按事件顺序的完整时间线
*/ */
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 事件元数据JSONtoolCalls, plan, currentPhase /**
* Agent 事件元数据toolCalls, segments
* 类型为 ObjectJackson 序列化时直接输出 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 字符串解析为 MapJackson 序列化时直接输出对象而非转义字符串
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) { // 线 thinkingtoolscontent
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
*
* - limitR.ok(List<MessageVO>) res.data
* - limitR.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