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

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

View File

@ -604,6 +604,14 @@ public class AgentGraphBuilder {
If a tool requires approval due to security policies, the system will prompt the user for confirmation.
Only state you cannot access something if no relevant tool is available.
## Multi-Part Question Guidelines
When the user asks multiple questions or requests multiple tasks in a single message:
1. Structure your final answer with numbered sections, one per sub-task
2. Each section must contain the complete, detailed result for that sub-task
3. Never compress earlier sub-tasks into summary sentences while expanding the last one
4. If observations were summarized during processing, reconstruct each section from the summary
5. Treat each sub-task's result as equally important regardless of processing order
## File Reading Guidelines
**Text Files** (use read_file):

View File

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

View File

@ -147,6 +147,43 @@ public class SkillFileTool {
return sb.toString();
}
@Tool(description = """
列出所有当前可用的技能Skills包括名称图标和描述
使用此工具查看系统中有哪些已启用且可运行的技能
注意这里列出的是技能Skills不是 Agent如需列出可用 Agent请使用 listAvailableAgents
Returns: A formatted list of active skills with name, icon, and description.
""")
public String listAvailableSkills() {
log.info("Listing available skills");
List<ResolvedSkill> activeSkills = runtimeService.getActiveSkills();
if (activeSkills.isEmpty()) {
return "当前没有可用的技能Skills";
}
StringBuilder sb = new StringBuilder("可用技能Skills列表\n\n");
for (ResolvedSkill skill : activeSkills) {
sb.append("- **").append(skill.getName()).append("**");
if (skill.getIcon() != null && !skill.getIcon().isBlank()) {
sb.append(" ").append(skill.getIcon());
}
if (skill.getDescription() != null && !skill.getDescription().isBlank()) {
String desc = skill.getDescription();
if (desc.length() > 200) {
desc = desc.substring(0, 200) + "...";
}
sb.append("").append(desc);
}
sb.append("\n");
}
sb.append("\n共 ").append(activeSkills.size()).append(" 个可用技能。");
sb.append("\n\n使用 `readSkillFile` 读取技能详情,使用 `runSkillScript` 执行技能脚本。");
return sb.toString();
}
@SuppressWarnings("unchecked")
private void formatTree(StringBuilder sb, Map<String, Object> tree, String indent) {
for (Map.Entry<String, Object> entry : tree.entrySet()) {

View File

@ -1,15 +1,21 @@
package vip.mate.workspace.conversation.vo;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Data
public class MessageVO {
private static final ObjectMapper MAPPER = new ObjectMapper();
private Long id;
private String conversationId;
@ -22,7 +28,12 @@ public class MessageVO {
private String status;
private String metadata; // Agent 事件元数据JSONtoolCalls, plan, currentPhase
/**
* Agent 事件元数据toolCalls, segments
* 类型为 ObjectJackson 序列化时直接输出 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 字符串解析为 MapJackson 序列化时直接输出对象而非转义字符串
vo.setMetadata(parseMetadataToObject(entity.getMetadata()));
vo.setPromptTokens(entity.getPromptTokens());
vo.setCompletionTokens(entity.getCompletionTokens());
vo.setCreateTime(entity.getCreateTime());
@ -52,4 +64,25 @@ public class MessageVO {
vo.setContentParts(contentParts);
return vo;
}
private static Object parseMetadataToObject(String metadataJson) {
if (metadataJson == null || metadataJson.isBlank()) {
return Collections.emptyMap();
}
try {
// H2 JSON 列可能返回带引号包裹的字符串需要先 unwrap
String json = metadataJson.trim();
if (json.startsWith("\"") && json.endsWith("\"")) {
// 双重包裹H2 JSON 类型 getString() 返回了 JSON string literal
json = MAPPER.readValue(json, String.class);
}
if (json.isBlank() || "{}".equals(json)) {
return Collections.emptyMap();
}
return MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
// 解析失败时返回空 map 而非原始字符串避免前端拿到字符串
return Collections.emptyMap();
}
}
}

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@
<template v-for="(seg, index) in segments" :key="seg.id">
<ThinkingSegment v-if="seg.type === 'thinking'" :segment="seg" />
<ToolCallSegment v-if="seg.type === 'tool_call'" :segment="seg" />
<ContentSegment v-if="seg.type === 'content'" :segment="seg" :is-last="index === segments.length - 1" :show-cursor="showCursor && seg.status === 'running'" />
<ContentSegment v-if="seg.type === 'content'" :segment="seg" :show-cursor="showCursor && seg.status === 'running'" />
</template>
</div>
</template>
@ -578,31 +578,49 @@ const parsedMetadata = computed(() => {
const raw = props.message.metadata
if (!raw) return {} as any
if (typeof raw === 'string') {
try { return JSON.parse(raw) } catch { return {} }
try {
let parsed = JSON.parse(raw)
// JSON DB metadata Jackson
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed) } catch { /* ignore */ }
}
return parsed
} catch { return {} }
}
return raw
})
const segments = computed<MessageSegment[]>(() => {
const meta = parsedMetadata.value
if (props.message.role !== 'assistant') return []
const meta = parsedMetadata.value
// contentParts thinking//DB
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking')
// DEBUG:
if (meta?.segments) {
console.debug('[MessageBubble] segments found:', meta.segments.length, 'first:', meta.segments[0]?.type)
} else {
console.debug('[MessageBubble] NO segments in metadata, keys:', Object.keys(meta || {}))
}
// 使 segments
if (meta?.segments && meta.segments.length > 0) {
const segs = [...meta.segments] as MessageSegment[]
// 使 metadata.segments
// 线 thinkingtoolscontent
if (meta?.segments && Array.isArray(meta.segments) && meta.segments.length > 0
&& typeof meta.segments[0] === 'object' && meta.segments[0]?.type) {
// segments thinking contentParts thinking
const segs = [...meta.segments] as MessageSegment[]
const hasThinking = segs.some(s => s.type === 'thinking')
if (!hasThinking && thinkingPart?.text) {
segs.unshift({ id: 'th-0', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text })
if (!hasThinking) {
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking')
if (thinkingPart?.text) {
segs.unshift({ id: 'th-fb', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text })
}
}
return segs
}
// toolCalls + contentParts segments
// Fallback toolCalls + contentParts best-effort
// thinking tool calls content
const segs: MessageSegment[] = []
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking')
if (thinkingPart?.text) {
segs.push({ id: 'th-0', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text })
}

View File

@ -139,7 +139,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (!metadata) return {}
if (typeof metadata === 'string') {
try {
return JSON.parse(metadata)
let parsed = JSON.parse(metadata)
// 处理双重 JSON 编码DB metadata 是字符串Jackson 可能再次转义)
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed) } catch { /* ignore */ }
}
return parsed
} catch (e) {
console.warn('[useChat] Failed to parse metadata:', e)
return {}
@ -329,12 +334,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
setMessageStatus(currentAssistantId.value, data.status || 'completed')
}
// 更新 token 信息
// 更新 token 信息 + 用持久化 id 替换本地临时 id关键让 reconcile 能匹配)
const msgIndex = messages.value.findIndex(m => m.id === currentAssistantId.value)
if (msgIndex >= 0) {
const msg = messages.value[msgIndex]
if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens
if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens
// 用后端持久化 id 替换本地临时 id使 reconcile 时能按 id 匹配
if (data.assistantMessageId) {
msg.id = data.assistantMessageId
}
messages.value[msgIndex] = { ...msg }
}
currentAssistantId.value = null

View File

@ -0,0 +1,126 @@
/**
* reconcile poorer DB local rich message
*
* onStreamEnd refreshCurrentConversationMessages() DB
* API metadata JSON segments
* segments rich message poorer
*
* id "丰富度"
*/
import type { Message } from '@/types'
/** 安全 parse metadata处理 string / double-encoded / object / null */
function safeParseMeta(metadata: any): Record<string, any> {
if (!metadata) return {}
if (typeof metadata === 'object') return metadata
if (typeof metadata === 'string') {
try {
let parsed = JSON.parse(metadata)
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed) } catch { /* ignore */ }
}
return typeof parsed === 'object' && parsed !== null ? parsed : {}
} catch {
return {}
}
}
return {}
}
/**
*
* assistant
*/
export function messageRichness(msg: Message): number {
if (msg.role !== 'assistant') return 0
let score = 0
const meta = safeParseMeta(msg.metadata)
// segments 是分段渲染的权威数据源,权重最高
const segs = Array.isArray(meta.segments) ? meta.segments : []
score += segs.length * 10
// toolCalls
const tcs = Array.isArray(meta.toolCalls) ? meta.toolCalls : []
score += tcs.length * 5
// contentParts 中的 thinking / tool_call
const parts = Array.isArray(msg.contentParts) ? msg.contentParts : []
if (parts.some(p => p.type === 'thinking')) score += 20
score += parts.filter(p => p.type === 'tool_call').length * 5
// content 文本长度capped避免长文本主导
score += Math.min((msg.content?.length || 0), 500)
return score
}
/**
* reconcile id
*
* - fetched local fetched
* - assistant fetched
* - assistant richness
* - fetched assistant lagging snapshot
*/
export function reconcileMessages(local: Message[], fetched: Message[]): Message[] {
if (!local.length) return fetched
if (!fetched.length) return local
const localMap = new Map<string, Message>()
for (const m of local) {
localMap.set(String(m.id), m)
}
const matchedLocalIds = new Set<string>()
const result: Message[] = []
for (const fm of fetched) {
const fid = String(fm.id)
const lm = localMap.get(fid)
if (!lm) {
result.push(fm)
} else if (fm.role !== 'assistant') {
result.push(fm)
matchedLocalIds.add(fid)
} else {
// assistant 消息:比较丰富度,取更高分的
const lr = messageRichness(lm)
const fr = messageRichness(fm)
result.push(fr >= lr ? fm : lm)
matchedLocalIds.add(fid)
}
}
// 保留 fetched 中不存在的本地 assistant 消息(防止 lagging snapshot 丢弃刚完成的消息)
for (const lm of local) {
const lid = String(lm.id)
if (!matchedLocalIds.has(lid) && lm.role === 'assistant') {
// 检查是否是 fetched 末尾之后的消息刚完成DB 还没返回)
const lastFetchedTime = result.length > 0 ? result[result.length - 1].createTime : ''
if (!lastFetchedTime || (lm.createTime && lm.createTime >= lastFetchedTime)) {
result.push(lm)
}
}
}
return result
}
/**
* API
*
* - 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 { 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