mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-17 12:54:40 +08:00
feat(chat): segmented message display, progressive loading, and real-time segment persistence
This commit is contained in:
parent
ec7ea038e7
commit
dafdcb4182
@ -169,11 +169,37 @@ public abstract class BaseAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected List<Message> buildConversationHistory(String conversationId, String currentUserMessage) {
|
protected List<Message> buildConversationHistory(String conversationId, String currentUserMessage) {
|
||||||
List<MessageEntity> history = conversationService.listMessages(conversationId);
|
// ===== 两阶段加载:短对话全量,长对话分页(递进式) =====
|
||||||
if (history.isEmpty()) {
|
long totalCount = conversationService.countMessages(conversationId);
|
||||||
|
if (totalCount <= 0) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int windowSize = getEffectiveWindowSize();
|
||||||
|
List<MessageEntity> history;
|
||||||
|
|
||||||
|
if (totalCount <= windowSize) {
|
||||||
|
// 短对话:全量加载(与旧逻辑一致)
|
||||||
|
history = conversationService.listMessages(conversationId);
|
||||||
|
} else {
|
||||||
|
// 长对话:只加载最近 windowSize 条
|
||||||
|
history = conversationService.listRecentMessages(conversationId, windowSize);
|
||||||
|
log.info("[{}] Progressive load: {} of {} messages (window={})",
|
||||||
|
agentName, history.size(), totalCount, windowSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 识别持久化的压缩摘要:从摘要位置开始,跳过更早消息 =====
|
||||||
|
for (int i = 0; i < history.size(); i++) {
|
||||||
|
MessageEntity msg = history.get(i);
|
||||||
|
if ("system".equals(msg.getRole()) && isCompressionSummary(msg)) {
|
||||||
|
history = new ArrayList<>(history.subList(i, history.size()));
|
||||||
|
log.info("[{}] Found compression summary, loading from index {} ({} messages)",
|
||||||
|
agentName, i, history.size());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 转换为 Spring AI Message 对象 =====
|
||||||
int limit = history.size();
|
int limit = history.size();
|
||||||
if (limit > 0) {
|
if (limit > 0) {
|
||||||
MessageEntity last = history.get(limit - 1);
|
MessageEntity last = history.get(limit - 1);
|
||||||
@ -202,6 +228,24 @@ public abstract class BaseAgent {
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断消息是否为持久化的压缩摘要。
|
||||||
|
*/
|
||||||
|
private boolean isCompressionSummary(MessageEntity msg) {
|
||||||
|
return msg.getMetadata() != null && msg.getMetadata().contains("compression_summary");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态计算窗口大小:基于模型上下文长度估算能容纳多少条消息。
|
||||||
|
* 保守估算:每条消息平均 200 token,预留 30% 给系统提示词和当前消息。
|
||||||
|
*/
|
||||||
|
private int getEffectiveWindowSize() {
|
||||||
|
int contextTokens = maxInputTokens != null && maxInputTokens > 0
|
||||||
|
? maxInputTokens : 128000;
|
||||||
|
int window = (int) (contextTokens * 0.7) / 200;
|
||||||
|
return Math.max(20, Math.min(window, 500));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否为审批占位消息(委托给共享工具类)
|
* 判断是否为审批占位消息(委托给共享工具类)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import vip.mate.agent.prompt.PromptLoader;
|
import vip.mate.agent.prompt.PromptLoader;
|
||||||
import vip.mate.config.ConversationWindowProperties;
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
import vip.mate.memory.spi.MemoryManager;
|
import vip.mate.memory.spi.MemoryManager;
|
||||||
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -80,6 +81,7 @@ public class ConversationWindowManager {
|
|||||||
|
|
||||||
private final ConversationWindowProperties properties;
|
private final ConversationWindowProperties properties;
|
||||||
private final MemoryManager memoryManager;
|
private final MemoryManager memoryManager;
|
||||||
|
private final ConversationService conversationService;
|
||||||
|
|
||||||
// ==================== 状态 ====================
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
@ -242,6 +244,16 @@ public class ConversationWindowManager {
|
|||||||
int count = compressionCounts.merge(conversationId, 1, Integer::sum);
|
int count = compressionCounts.merge(conversationId, 1, Integer::sum);
|
||||||
log.info("[ConversationWindow] 生成结构化摘要 ({} 字符, 第 {} 次压缩), 压缩 {} 条旧消息, conv={}",
|
log.info("[ConversationWindow] 生成结构化摘要 ({} 字符, 第 {} 次压缩), 压缩 {} 条旧消息, conv={}",
|
||||||
summary.length(), count, oldMessages.size(), conversationId);
|
summary.length(), count, oldMessages.size(), conversationId);
|
||||||
|
|
||||||
|
// 持久化摘要到 DB:下次加载历史时可直接从摘要位置开始,跳过重复压缩
|
||||||
|
if (conversationService != null) {
|
||||||
|
try {
|
||||||
|
conversationService.saveCompressionSummary(
|
||||||
|
conversationId, SUMMARY_PREFIX + summary, oldMessages.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ConversationWindow] Failed to persist compression summary: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1219,6 +1219,9 @@ public class ChatController {
|
|||||||
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 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 = "";
|
||||||
@ -1262,12 +1265,16 @@ public class ChatController {
|
|||||||
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 或创建新的
|
||||||
|
appendToContentSegment(delta.content());
|
||||||
}
|
}
|
||||||
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 或创建新的
|
||||||
|
appendToThinkingSegment(delta.thinking());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1283,6 +1290,15 @@ public class ChatController {
|
|||||||
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
|
||||||
|
closeRunningSegments("thinking", "content");
|
||||||
|
Map<String, Object> seg = new LinkedHashMap<>();
|
||||||
|
seg.put("id", "tc-" + segCounter++);
|
||||||
|
seg.put("type", "tool_call");
|
||||||
|
seg.put("status", "running");
|
||||||
|
seg.put("toolName", data.getOrDefault("toolName", ""));
|
||||||
|
seg.put("toolArgs", data.getOrDefault("arguments", ""));
|
||||||
|
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", ""));
|
||||||
for (int i = toolCalls.size() - 1; i >= 0; i--) {
|
for (int i = toolCalls.size() - 1; i >= 0; i--) {
|
||||||
@ -1294,6 +1310,65 @@ public class ChatController {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 分段:标记对应 tool_call segment 完成
|
||||||
|
for (int i = segments.size() - 1; i >= 0; i--) {
|
||||||
|
Map<String, Object> 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");
|
||||||
|
seg.put("toolResult", data.getOrDefault("result", ""));
|
||||||
|
seg.put("toolSuccess", data.getOrDefault("success", true));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 分段构建辅助方法 ====================
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没找到,创建新的
|
||||||
|
Map<String, Object> seg = new LinkedHashMap<>();
|
||||||
|
seg.put("id", "th-" + segCounter++);
|
||||||
|
seg.put("type", "thinking");
|
||||||
|
seg.put("status", "running");
|
||||||
|
seg.put("thinkingText", text);
|
||||||
|
segments.add(seg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendToContentSegment(String text) {
|
||||||
|
// 查找最后一个 running content segment
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没找到,关闭 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeRunningSegments(String... types) {
|
||||||
|
java.util.Set<String> typeSet = java.util.Set.of(types);
|
||||||
|
for (Map<String, Object> seg : segments) {
|
||||||
|
if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) {
|
||||||
|
seg.put("status", "completed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1350,13 +1425,18 @@ public class ChatController {
|
|||||||
* 生成 metadata JSON:包含 toolCalls 及其他元数据
|
* 生成 metadata JSON:包含 toolCalls 及其他元数据
|
||||||
*/
|
*/
|
||||||
synchronized String toMetadataJson() {
|
synchronized String toMetadataJson() {
|
||||||
// 确保所有 tool calls 都不是 running 状态
|
// 确保所有 tool calls 和 segments 都不是 running 状态
|
||||||
finalizeToolCalls();
|
finalizeToolCalls();
|
||||||
|
closeRunningSegments("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()) {
|
||||||
|
metadata.put("segments", segments);
|
||||||
|
}
|
||||||
return objectMapper.writeValueAsString(metadata);
|
return objectMapper.writeValueAsString(metadata);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to serialize metadata: {}", e.getMessage());
|
log.warn("Failed to serialize metadata: {}", e.getMessage());
|
||||||
|
|||||||
@ -35,6 +35,9 @@ public class MemorySchemaMigration implements ApplicationRunner {
|
|||||||
log.info("[MemorySchemaMigration] MySQL FULLTEXT index on mate_message.content created (or already exists)");
|
log.info("[MemorySchemaMigration] MySQL FULLTEXT index on mate_message.content created (or already exists)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// max_iterations 升级:旧版默认 10,新版 25。确保已有 agent 也更新
|
||||||
|
safeExecute("UPDATE mate_agent SET max_iterations = 25 WHERE max_iterations = 10 AND deleted = 0");
|
||||||
|
|
||||||
log.debug("[MemorySchemaMigration] Incremental migration completed");
|
log.debug("[MemorySchemaMigration] Incremental migration completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -315,6 +317,61 @@ public class ConversationService {
|
|||||||
.orderByAsc(MessageEntity::getId));
|
.orderByAsc(MessageEntity::getId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载最近 N 条消息(倒序取出后翻转为正序)。
|
||||||
|
* 利用复合索引 (conversation_id, create_time) 高效分页。
|
||||||
|
*/
|
||||||
|
public List<MessageEntity> listRecentMessages(String conversationId, int lastN) {
|
||||||
|
List<MessageEntity> recent = messageMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<MessageEntity>()
|
||||||
|
.eq(MessageEntity::getConversationId, conversationId)
|
||||||
|
.orderByDesc(MessageEntity::getCreateTime)
|
||||||
|
.orderByDesc(MessageEntity::getId)
|
||||||
|
.last("LIMIT " + lastN));
|
||||||
|
Collections.reverse(recent);
|
||||||
|
return recent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页加载指定 ID 之前的消息(用于前端上拉加载更早消息)。
|
||||||
|
* 返回倒序结果,调用方需自行 reverse。
|
||||||
|
*/
|
||||||
|
public List<MessageEntity> listMessagesBefore(String conversationId, Long beforeId, int limit) {
|
||||||
|
List<MessageEntity> results = messageMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<MessageEntity>()
|
||||||
|
.eq(MessageEntity::getConversationId, conversationId)
|
||||||
|
.lt(MessageEntity::getId, beforeId)
|
||||||
|
.orderByDesc(MessageEntity::getCreateTime)
|
||||||
|
.orderByDesc(MessageEntity::getId)
|
||||||
|
.last("LIMIT " + limit));
|
||||||
|
Collections.reverse(results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会话消息总数。
|
||||||
|
*/
|
||||||
|
public long countMessages(String conversationId) {
|
||||||
|
return messageMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<MessageEntity>()
|
||||||
|
.eq(MessageEntity::getConversationId, conversationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将压缩摘要持久化为 role=system 的特殊消息。
|
||||||
|
* 下次加载历史时识别此消息,跳过它之前的已压缩消息。
|
||||||
|
*/
|
||||||
|
public void saveCompressionSummary(String conversationId, String summary, int compressedCount) {
|
||||||
|
MessageEntity entity = new MessageEntity();
|
||||||
|
entity.setConversationId(conversationId);
|
||||||
|
entity.setRole("system");
|
||||||
|
entity.setContent(summary);
|
||||||
|
entity.setStatus("completed");
|
||||||
|
entity.setMetadata("{\"type\":\"compression_summary\",\"compressedCount\":" + compressedCount + "}");
|
||||||
|
messageMapper.insert(entity);
|
||||||
|
log.info("[Conversation] Saved compression summary for conv={}, compressedCount={}", conversationId, compressedCount);
|
||||||
|
}
|
||||||
|
|
||||||
public List<MessageVO> listMessageViews(String conversationId) {
|
public List<MessageVO> listMessageViews(String conversationId) {
|
||||||
return listMessages(conversationId).stream()
|
return listMessages(conversationId).stream()
|
||||||
.map(message -> MessageVO.from(message, parseMessageParts(message), renderMessageContent(message)))
|
.map(message -> MessageVO.from(message, parseMessageParts(message), renderMessageContent(message)))
|
||||||
|
|||||||
@ -42,18 +42,57 @@ public class ConversationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定会话的消息历史
|
* 获取指定会话的消息历史(支持分页)。
|
||||||
|
* <p>
|
||||||
|
* 不传 limit 时返回全部消息(向后兼容)。
|
||||||
|
* 传 limit 时返回最新 limit 条 + hasMore 标志。
|
||||||
|
* 传 beforeId + limit 时返回该 ID 之前的 limit 条(上拉加载更早消息)。
|
||||||
*/
|
*/
|
||||||
@Operation(summary = "获取会话消息历史")
|
@Operation(summary = "获取会话消息历史(支持分页)")
|
||||||
@GetMapping("/{conversationId}/messages")
|
@GetMapping("/{conversationId}/messages")
|
||||||
public R<List<MessageVO>> listMessages(@PathVariable String conversationId, Authentication auth) {
|
public R<?> listMessages(@PathVariable String conversationId,
|
||||||
|
@RequestParam(required = false) Long beforeId,
|
||||||
|
@RequestParam(required = false) Integer limit,
|
||||||
|
Authentication auth) {
|
||||||
String username = auth != null ? auth.getName() : "anonymous";
|
String username = auth != null ? auth.getName() : "anonymous";
|
||||||
if (!conversationService.isConversationOwner(conversationId, username)) {
|
if (!conversationService.isConversationOwner(conversationId, username)) {
|
||||||
return R.fail("无权访问该会话");
|
return R.fail("无权访问该会话");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 向后兼容:不传 limit 则返回全部消息(旧前端行为)
|
||||||
|
if (limit == null || limit <= 0) {
|
||||||
return R.ok(conversationService.listMessageViews(conversationId));
|
return R.ok(conversationService.listMessageViews(conversationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分页模式
|
||||||
|
java.util.List<vip.mate.workspace.conversation.model.MessageEntity> messages;
|
||||||
|
boolean hasMore;
|
||||||
|
|
||||||
|
if (beforeId != null) {
|
||||||
|
// 上拉加载:取 beforeId 之前的 limit+1 条,多取一条用于判断 hasMore
|
||||||
|
messages = conversationService.listMessagesBefore(conversationId, beforeId, limit + 1);
|
||||||
|
hasMore = messages.size() > limit;
|
||||||
|
if (hasMore) {
|
||||||
|
messages = messages.subList(messages.size() - limit, messages.size());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 初始加载:最新 limit 条
|
||||||
|
long total = conversationService.countMessages(conversationId);
|
||||||
|
messages = conversationService.listRecentMessages(conversationId, limit);
|
||||||
|
hasMore = total > limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
java.util.List<vip.mate.workspace.conversation.vo.MessageVO> views = messages.stream()
|
||||||
|
.map(m -> vip.mate.workspace.conversation.vo.MessageVO.from(
|
||||||
|
m, conversationService.parseMessageParts(m), conversationService.renderMessageContent(m)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return R.ok(java.util.Map.of(
|
||||||
|
"messages", views,
|
||||||
|
"hasMore", hasMore
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除会话(同时删除消息)
|
* 删除会话(同时删除消息)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -128,8 +128,8 @@ export const chatApi = {
|
|||||||
// ==================== Conversation ====================
|
// ==================== Conversation ====================
|
||||||
export const conversationApi = {
|
export const conversationApi = {
|
||||||
list: () => http.get('/conversations'),
|
list: () => http.get('/conversations'),
|
||||||
listMessages: (conversationId: string) =>
|
listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number }) =>
|
||||||
http.get(`/conversations/${conversationId}/messages`),
|
http.get(`/conversations/${conversationId}/messages`, { params }),
|
||||||
getStatus: (conversationId: string) =>
|
getStatus: (conversationId: string) =>
|
||||||
http.get(`/conversations/${conversationId}/status`),
|
http.get(`/conversations/${conversationId}/status`),
|
||||||
delete: (conversationId: string) =>
|
delete: (conversationId: string) =>
|
||||||
|
|||||||
85
mateclaw-ui/src/components/chat/CompressionSummary.vue
Normal file
85
mateclaw-ui/src/components/chat/CompressionSummary.vue
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ArrowDown } from '@element-plus/icons-vue'
|
||||||
|
import type { Message } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
message: Message
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
const compressedCount = computed(() => {
|
||||||
|
try {
|
||||||
|
const metadata = typeof props.message.metadata === 'string'
|
||||||
|
? JSON.parse(props.message.metadata)
|
||||||
|
: props.message.metadata
|
||||||
|
return metadata?.compressedCount || 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="seg-compression">
|
||||||
|
<div class="seg-compression__header" @click="expanded = !expanded">
|
||||||
|
<span class="seg-compression__icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||||
|
</span>
|
||||||
|
<span v-if="compressedCount > 0" class="seg-compression__label">
|
||||||
|
{{ `之前的 ${compressedCount} 条对话已整理为摘要` }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="seg-compression__label">之前的对话已整理为摘要</span>
|
||||||
|
<el-icon class="seg-compression__arrow" :class="{ 'is-open': expanded }" :size="12"><ArrowDown /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div v-if="expanded" class="seg-compression__body">
|
||||||
|
<div class="markdown-body">{{ message.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.seg-compression {
|
||||||
|
margin: 6px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed var(--mc-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.seg-compression__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.seg-compression__header:hover {
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
}
|
||||||
|
.seg-compression__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.seg-compression__label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.seg-compression__arrow {
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.seg-compression__arrow.is-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.seg-compression__body {
|
||||||
|
padding: 0 12px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
33
mateclaw-ui/src/components/chat/ContentSegment.vue
Normal file
33
mateclaw-ui/src/components/chat/ContentSegment.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||||
|
import TypingCursor from './TypingCursor.vue'
|
||||||
|
import type { MessageSegment } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segment: MessageSegment
|
||||||
|
showCursor?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { renderMarkdown } = useMarkdownRenderer()
|
||||||
|
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
return renderMarkdown(props.segment.text || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const isRunning = computed(() => props.segment.status === 'running')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="segment segment--content">
|
||||||
|
<div class="markdown-body" v-html="renderedContent"></div>
|
||||||
|
<TypingCursor v-if="isRunning && showCursor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.segment--content {
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -18,6 +18,20 @@
|
|||||||
<!-- 消息体 -->
|
<!-- 消息体 -->
|
||||||
<div class="msg-body" :class="`${role}-body`">
|
<div class="msg-body" :class="`${role}-body`">
|
||||||
<div class="msg-bubble" :class="`${role}-bubble`">
|
<div class="msg-bubble" :class="`${role}-bubble`">
|
||||||
|
<!-- ===== 分段式渲染模式(Claude Code 风格)===== -->
|
||||||
|
<template v-if="useSegmentedView">
|
||||||
|
<div class="segments-view">
|
||||||
|
<template v-for="seg 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" :show-cursor="showCursor && seg.status === 'running'" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ===== 传统合并渲染模式(降级兼容)===== -->
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
<!-- 思考面板 -->
|
<!-- 思考面板 -->
|
||||||
<div v-if="showThinkingPanel" class="thinking-section">
|
<div v-if="showThinkingPanel" class="thinking-section">
|
||||||
<button class="thinking-toggle" type="button" @click="toggleThinking">
|
<button class="thinking-toggle" type="button" @click="toggleThinking">
|
||||||
@ -178,6 +192,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</template><!-- /传统合并渲染模式 -->
|
||||||
|
|
||||||
<!-- 附件列表 -->
|
<!-- 附件列表 -->
|
||||||
<div v-if="attachments?.length" class="message-attachments">
|
<div v-if="attachments?.length" class="message-attachments">
|
||||||
<div
|
<div
|
||||||
@ -292,8 +308,11 @@ import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttach
|
|||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import TypingCursor from './TypingCursor.vue'
|
import TypingCursor from './TypingCursor.vue'
|
||||||
import BrowserTimeline from './BrowserTimeline.vue'
|
import BrowserTimeline from './BrowserTimeline.vue'
|
||||||
|
import ToolCallSegment from './ToolCallSegment.vue'
|
||||||
|
import ThinkingSegment from './ThinkingSegment.vue'
|
||||||
|
import ContentSegment from './ContentSegment.vue'
|
||||||
import type { BrowserAction } from './BrowserTimeline.vue'
|
import type { BrowserAction } from './BrowserTimeline.vue'
|
||||||
import type { Message, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
|
import type { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
|
||||||
import type { ChatErrorInfo } from '@/types/chatError'
|
import type { ChatErrorInfo } from '@/types/chatError'
|
||||||
|
|
||||||
const { renderMarkdown } = useMarkdownRenderer()
|
const { renderMarkdown } = useMarkdownRenderer()
|
||||||
@ -554,6 +573,56 @@ const formatFileSize = (size: number) => {
|
|||||||
// --- 执行过程面板 ---
|
// --- 执行过程面板 ---
|
||||||
const executionExpanded = ref(false)
|
const executionExpanded = ref(false)
|
||||||
|
|
||||||
|
// --- 分段式渲染(Claude Code 风格) ---
|
||||||
|
const parsedMetadata = computed(() => {
|
||||||
|
const raw = props.message.metadata
|
||||||
|
if (!raw) return {} as any
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try { return JSON.parse(raw) } catch { return {} }
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
})
|
||||||
|
|
||||||
|
const segments = computed<MessageSegment[]>(() => {
|
||||||
|
const meta = parsedMetadata.value
|
||||||
|
if (props.message.role !== 'assistant') return []
|
||||||
|
|
||||||
|
// 从 contentParts 中提取 thinking(适用于所有来源:流式/历史/DB)
|
||||||
|
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking')
|
||||||
|
|
||||||
|
// 优先使用后端持久化的 segments
|
||||||
|
if (meta?.segments && meta.segments.length > 0) {
|
||||||
|
const segs = [...meta.segments] as MessageSegment[]
|
||||||
|
// 补充:如果后端 segments 没有 thinking 但 contentParts 有(非原生 thinking 模型)
|
||||||
|
const hasThinking = segs.some(s => s.type === 'thinking')
|
||||||
|
if (!hasThinking && thinkingPart?.text) {
|
||||||
|
segs.unshift({ id: 'th-0', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text })
|
||||||
|
}
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:从 toolCalls + contentParts 重建 segments
|
||||||
|
const segs: MessageSegment[] = []
|
||||||
|
if (thinkingPart?.text) {
|
||||||
|
segs.push({ id: 'th-0', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text })
|
||||||
|
}
|
||||||
|
const toolCalls = meta?.toolCalls || []
|
||||||
|
toolCalls.forEach((tc: ToolCallMeta, i: number) => {
|
||||||
|
segs.push({
|
||||||
|
id: `tc-${i}`, type: 'tool_call', status: 'completed',
|
||||||
|
toolName: tc.name, toolArgs: tc.arguments,
|
||||||
|
toolResult: tc.result, toolSuccess: tc.success,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (props.message.content) {
|
||||||
|
segs.push({ id: 'ct-0', type: 'content', status: 'completed', text: props.message.content })
|
||||||
|
}
|
||||||
|
return segs
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 是否使用分段模式渲染(有 segments 数据且包含多个分段) */
|
||||||
|
const useSegmentedView = computed(() => segments.value.length > 1)
|
||||||
|
|
||||||
const toolCallsMeta = computed<ToolCallMeta[]>(() => {
|
const toolCallsMeta = computed<ToolCallMeta[]>(() => {
|
||||||
return props.message.metadata?.toolCalls || []
|
return props.message.metadata?.toolCalls || []
|
||||||
})
|
})
|
||||||
@ -646,6 +715,14 @@ watch(isGenerating, (generating) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 分段式渲染容器 */
|
||||||
|
.segments-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.message-wrapper {
|
.message-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@ -38,9 +38,26 @@
|
|||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- 上拉加载更早消息触发器 -->
|
||||||
|
<div v-if="hasMore" ref="loadMoreRef" class="load-more-trigger text-center py-3">
|
||||||
|
<div v-if="loadingOlder" class="text-gray-400 dark:text-gray-500 text-sm flex items-center justify-center gap-2">
|
||||||
|
<span class="animate-spin inline-block w-4 h-4 border-2 border-gray-300 border-t-gray-500 rounded-full"></span>
|
||||||
|
加载更早的消息...
|
||||||
|
</div>
|
||||||
|
<button v-else class="text-gray-400 dark:text-gray-500 text-sm hover:text-gray-600 dark:hover:text-gray-300 transition-colors" @click="$emit('load-more')">
|
||||||
|
点击加载更早的消息
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="(msg, index) in messages" :key="msg.id || index">
|
||||||
|
<!-- 压缩摘要消息特殊渲染 -->
|
||||||
|
<CompressionSummary
|
||||||
|
v-if="isCompressionSummary(msg)"
|
||||||
|
:message="msg"
|
||||||
|
/>
|
||||||
|
<!-- 普通消息气泡 -->
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
v-for="(msg, index) in messages"
|
v-else
|
||||||
:key="msg.id || index"
|
|
||||||
:message="msg"
|
:message="msg"
|
||||||
:is-last="index === messages.length - 1"
|
:is-last="index === messages.length - 1"
|
||||||
:assistant-icon="assistantIcon"
|
:assistant-icon="assistantIcon"
|
||||||
@ -52,6 +69,7 @@
|
|||||||
@deny="(pendingId) => $emit('deny', pendingId)"
|
@deny="(pendingId) => $emit('deny', pendingId)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 加载指示器:只在无消息时显示(有消息时由输入框显示停止按钮) -->
|
<!-- 加载指示器:只在无消息时显示(有消息时由输入框显示停止按钮) -->
|
||||||
<div v-if="loading && messages.length === 0" class="loading-more">
|
<div v-if="loading && messages.length === 0" class="loading-more">
|
||||||
@ -69,6 +87,7 @@
|
|||||||
import { computed, watch, nextTick } from 'vue'
|
import { computed, watch, nextTick } from 'vue'
|
||||||
import { ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
|
import { ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
|
||||||
import MessageBubble from './MessageBubble.vue'
|
import MessageBubble from './MessageBubble.vue'
|
||||||
|
import CompressionSummary from './CompressionSummary.vue'
|
||||||
import { useStickToBottom } from '@/composables/chat/useStickToBottom'
|
import { useStickToBottom } from '@/composables/chat/useStickToBottom'
|
||||||
import type { Message } from '@/types'
|
import type { Message } from '@/types'
|
||||||
|
|
||||||
@ -89,6 +108,10 @@ interface Props {
|
|||||||
suggestions?: string[]
|
suggestions?: string[]
|
||||||
/** 是否自动滚动到底部 */
|
/** 是否自动滚动到底部 */
|
||||||
autoScroll?: boolean
|
autoScroll?: boolean
|
||||||
|
/** 是否还有更早的消息可加载 */
|
||||||
|
hasMore?: boolean
|
||||||
|
/** 是否正在加载更早消息 */
|
||||||
|
loadingOlder?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -99,6 +122,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
subtitle: '',
|
subtitle: '',
|
||||||
suggestions: () => [],
|
suggestions: () => [],
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
|
hasMore: false,
|
||||||
|
loadingOlder: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -108,8 +133,20 @@ const emit = defineEmits<{
|
|||||||
scroll: [event: Event]
|
scroll: [event: Event]
|
||||||
approve: [pendingId: string]
|
approve: [pendingId: string]
|
||||||
deny: [pendingId: string]
|
deny: [pendingId: string]
|
||||||
|
'load-more': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 判断消息是否为压缩摘要
|
||||||
|
const isCompressionSummary = (msg: Message) => {
|
||||||
|
if (msg.role !== 'system') return false
|
||||||
|
try {
|
||||||
|
const metadata = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata
|
||||||
|
return metadata?.type === 'compression_summary'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 智能滚动
|
// 智能滚动
|
||||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({
|
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({
|
||||||
enabled: props.autoScroll,
|
enabled: props.autoScroll,
|
||||||
|
|||||||
110
mateclaw-ui/src/components/chat/ThinkingSegment.vue
Normal file
110
mateclaw-ui/src/components/chat/ThinkingSegment.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Opportunity, ArrowDown } from '@element-plus/icons-vue'
|
||||||
|
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||||
|
import type { MessageSegment } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segment: MessageSegment
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(props.segment.status === 'running')
|
||||||
|
const { renderMarkdown } = useMarkdownRenderer()
|
||||||
|
|
||||||
|
const renderedThinking = computed(() => renderMarkdown(props.segment.thinkingText || ''))
|
||||||
|
const isRunning = computed(() => props.segment.status === 'running')
|
||||||
|
|
||||||
|
// running 结束后自动折叠
|
||||||
|
watch(() => props.segment.status, (val) => {
|
||||||
|
if (val === 'completed') expanded.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const lengthHint = computed(() => {
|
||||||
|
const len = props.segment.thinkingText?.length || 0
|
||||||
|
if (len < 100) return ''
|
||||||
|
return len < 1000 ? `${len} 字` : `${(len / 1000).toFixed(1)}k 字`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="seg-thinking" :class="{ 'is-active': isRunning }">
|
||||||
|
<div class="seg-thinking__header" @click="expanded = !expanded">
|
||||||
|
<span class="seg-thinking__icon">
|
||||||
|
<el-icon :class="{ 'is-loading': isRunning }" :size="14"><Opportunity /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="seg-thinking__label">{{ isRunning ? '思考中...' : '深度思考' }}</span>
|
||||||
|
<span v-if="lengthHint" class="seg-thinking__hint">{{ lengthHint }}</span>
|
||||||
|
<el-icon class="seg-thinking__arrow" :class="{ 'is-open': expanded }" :size="12"><ArrowDown /></el-icon>
|
||||||
|
</div>
|
||||||
|
<Transition name="seg-slide">
|
||||||
|
<div v-if="expanded" class="seg-thinking__body markdown-body" v-html="renderedThinking"></div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.seg-thinking {
|
||||||
|
margin: 3px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mc-thinking-bg);
|
||||||
|
border: 1px solid var(--mc-thinking-border);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.seg-thinking:hover {
|
||||||
|
background: var(--mc-thinking-hover);
|
||||||
|
}
|
||||||
|
.seg-thinking.is-active {
|
||||||
|
border-color: var(--mc-primary-light);
|
||||||
|
}
|
||||||
|
.seg-thinking__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mc-thinking-text);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.seg-thinking__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--mc-thinking-icon-bg);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.seg-thinking__label {
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.seg-thinking__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
|
.seg-thinking__arrow {
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.seg-thinking__arrow.is-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.seg-thinking__body {
|
||||||
|
padding: 0 12px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--mc-thinking-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
183
mateclaw-ui/src/components/chat/ToolCallSegment.vue
Normal file
183
mateclaw-ui/src/components/chat/ToolCallSegment.vue
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Loading, Select, CloseBold, ArrowDown, Document, Setting } from '@element-plus/icons-vue'
|
||||||
|
import type { MessageSegment } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segment: MessageSegment
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(props.segment.status === 'running')
|
||||||
|
|
||||||
|
// running → completed 时自动折叠
|
||||||
|
watch(() => props.segment.status, (val) => {
|
||||||
|
if (val !== 'running') expanded.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayName = computed(() => (props.segment.toolName || '').replace(/_/g, ' '))
|
||||||
|
|
||||||
|
const truncatedArgs = computed(() => {
|
||||||
|
const args = props.segment.toolArgs || ''
|
||||||
|
if (!args) return ''
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(args)
|
||||||
|
const vals = Object.values(parsed).filter(v => typeof v === 'string') as string[]
|
||||||
|
const s = vals.join(', ')
|
||||||
|
return s.length > 80 ? s.slice(0, 80) + '...' : s
|
||||||
|
} catch {
|
||||||
|
return args.length > 80 ? args.slice(0, 80) + '...' : args
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const resultPreview = computed(() => {
|
||||||
|
const r = props.segment.toolResult || ''
|
||||||
|
return r.length <= 600 ? r : r.slice(0, 600) + '\n... [truncated]'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isRead = computed(() => {
|
||||||
|
const n = props.segment.toolName || ''
|
||||||
|
return n.includes('read') || n.includes('Read')
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSuccess = computed(() => props.segment.status === 'completed' && props.segment.toolSuccess !== false)
|
||||||
|
const isError = computed(() => props.segment.status === 'error' || props.segment.toolSuccess === false)
|
||||||
|
const isRunning = computed(() => props.segment.status === 'running')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="seg-tool" :class="{ 'is-running': isRunning, 'is-success': isSuccess, 'is-error': isError }">
|
||||||
|
<div class="seg-tool__header" @click="segment.toolResult ? (expanded = !expanded) : null">
|
||||||
|
<span class="seg-tool__status">
|
||||||
|
<el-icon v-if="isRunning" class="is-loading" :size="13"><Loading /></el-icon>
|
||||||
|
<el-icon v-else-if="isSuccess" :size="13"><Select /></el-icon>
|
||||||
|
<el-icon v-else :size="13"><CloseBold /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="seg-tool__type-icon">
|
||||||
|
<el-icon v-if="isRead" :size="12"><Document /></el-icon>
|
||||||
|
<el-icon v-else :size="12"><Setting /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="seg-tool__name">{{ displayName }}</span>
|
||||||
|
<span v-if="truncatedArgs" class="seg-tool__args">{{ truncatedArgs }}</span>
|
||||||
|
<el-icon
|
||||||
|
v-if="segment.toolResult"
|
||||||
|
class="seg-tool__arrow"
|
||||||
|
:class="{ 'is-open': expanded }"
|
||||||
|
:size="11"
|
||||||
|
><ArrowDown /></el-icon>
|
||||||
|
</div>
|
||||||
|
<Transition name="seg-slide">
|
||||||
|
<div v-if="expanded && segment.toolResult" class="seg-tool__body">
|
||||||
|
<pre>{{ resultPreview }}</pre>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.seg-tool {
|
||||||
|
margin: 1px 0;
|
||||||
|
border-left: 3px solid var(--mc-border-light);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.seg-tool:hover {
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
}
|
||||||
|
.seg-tool.is-running {
|
||||||
|
border-left-color: var(--mc-primary);
|
||||||
|
background: var(--mc-primary-bg);
|
||||||
|
}
|
||||||
|
.seg-tool.is-success {
|
||||||
|
border-left-color: var(--mc-success);
|
||||||
|
}
|
||||||
|
.seg-tool.is-error {
|
||||||
|
border-left-color: var(--mc-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-tool__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.seg-tool__header:hover {
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-tool__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.is-success .seg-tool__status { color: var(--mc-success); }
|
||||||
|
.is-error .seg-tool__status { color: var(--mc-danger); }
|
||||||
|
.is-running .seg-tool__status { color: var(--mc-primary); }
|
||||||
|
|
||||||
|
.seg-tool__type-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-tool__name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-tool__args {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-tool__arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.seg-tool__arrow.is-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-tool__body {
|
||||||
|
padding: 0 10px 6px 22px;
|
||||||
|
}
|
||||||
|
.seg-tool__body pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--mc-bg-sunken);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@ -12,7 +12,7 @@ import { ref, computed } from 'vue'
|
|||||||
import { useMessages } from './useMessages'
|
import { useMessages } from './useMessages'
|
||||||
import { useStream } from './useStream'
|
import { useStream } from './useStream'
|
||||||
import { useMessageQueue } from './useMessageQueue'
|
import { useMessageQueue } from './useMessageQueue'
|
||||||
import type { Message, MessageContentPart, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData } from '@/types'
|
import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData } from '@/types'
|
||||||
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
|
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
|
|
||||||
@ -109,6 +109,23 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
let stopFallbackTimer: ReturnType<typeof setTimeout> | null = null
|
let stopFallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const streamPhase = ref<StreamPhase>('idle')
|
const streamPhase = ref<StreamPhase>('idle')
|
||||||
const phaseInfo = ref<PhaseEventData | null>(null)
|
const phaseInfo = ref<PhaseEventData | null>(null)
|
||||||
|
|
||||||
|
/** 分段式展示数据:当前助手消息的所有分段 */
|
||||||
|
const currentSegments = ref<MessageSegment[]>([])
|
||||||
|
const segIdCounter = { value: 0 }
|
||||||
|
const genSegId = () => `seg-${Date.now()}-${segIdCounter.value++}`
|
||||||
|
|
||||||
|
/** 将当前 segments 同步到助手消息的 metadata 中(实时渲染用) */
|
||||||
|
const flushSegmentsToMessage = () => {
|
||||||
|
if (!currentAssistantId.value || currentSegments.value.length === 0) return
|
||||||
|
const msg = getMessage(currentAssistantId.value)
|
||||||
|
if (!msg) return
|
||||||
|
const metadata = parseMetadata((msg as any).metadata)
|
||||||
|
updateMessage(currentAssistantId.value, {
|
||||||
|
...msg,
|
||||||
|
metadata: { ...metadata, segments: [...currentSegments.value] }
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
const heartbeat = ref<HeartbeatData | null>(null)
|
const heartbeat = ref<HeartbeatData | null>(null)
|
||||||
/** Track which conversation the current stream belongs to */
|
/** Track which conversation the current stream belongs to */
|
||||||
let streamConversationId = ''
|
let streamConversationId = ''
|
||||||
@ -174,6 +191,18 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) {
|
if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) {
|
||||||
streamPhase.value = 'streaming'
|
streamPhase.value = 'streaming'
|
||||||
}
|
}
|
||||||
|
// 分段:追加到当前 content segment 或创建新的
|
||||||
|
const segs = currentSegments.value
|
||||||
|
let contentSeg = segs.findLast((s: MessageSegment) => s.type === 'content' && s.status === 'running')
|
||||||
|
if (!contentSeg) {
|
||||||
|
// 关闭之前的 thinking segment
|
||||||
|
const thinkingSeg = segs.findLast((s: MessageSegment) => s.type === 'thinking' && s.status === 'running')
|
||||||
|
if (thinkingSeg) thinkingSeg.status = 'completed'
|
||||||
|
contentSeg = { id: genSegId(), type: 'content', status: 'running', text: '', timestamp: Date.now() }
|
||||||
|
segs.push(contentSeg)
|
||||||
|
flushSegmentsToMessage() // 新 content segment 创建时同步一次
|
||||||
|
}
|
||||||
|
contentSeg.text = (contentSeg.text || '') + (data.delta || '')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -183,6 +212,15 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
if (streamPhase.value !== 'summarizing_observations') {
|
if (streamPhase.value !== 'summarizing_observations') {
|
||||||
streamPhase.value = 'thinking'
|
streamPhase.value = 'thinking'
|
||||||
}
|
}
|
||||||
|
// 分段:追加到当前 thinking segment 或创建新的
|
||||||
|
const segs = currentSegments.value
|
||||||
|
let thinkSeg = segs.findLast((s: MessageSegment) => s.type === 'thinking' && s.status === 'running')
|
||||||
|
if (!thinkSeg) {
|
||||||
|
thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() }
|
||||||
|
segs.push(thinkSeg)
|
||||||
|
flushSegmentsToMessage() // 新 thinking segment 创建时同步一次
|
||||||
|
}
|
||||||
|
thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -196,6 +234,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重置分段列表
|
||||||
|
currentSegments.value = []
|
||||||
|
segIdCounter.value = 0
|
||||||
|
|
||||||
const assistantMessage = createAssistantMessage('')
|
const assistantMessage = createAssistantMessage('')
|
||||||
if (streamConversationId) {
|
if (streamConversationId) {
|
||||||
assistantMessage.conversationId = streamConversationId
|
assistantMessage.conversationId = streamConversationId
|
||||||
@ -252,6 +294,19 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
// 关键修复:不在这里清除 currentAssistantId
|
// 关键修复:不在这里清除 currentAssistantId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分段:标记所有 running segments 为 completed,并持久化到 message metadata
|
||||||
|
if (currentAssistantId.value && currentSegments.value.length > 0) {
|
||||||
|
currentSegments.value.forEach((s: MessageSegment) => { if (s.status === 'running') s.status = 'completed' })
|
||||||
|
const msg = getMessage(currentAssistantId.value)
|
||||||
|
if (msg) {
|
||||||
|
const metadata = parseMetadata((msg as any).metadata)
|
||||||
|
updateMessage(currentAssistantId.value, {
|
||||||
|
...msg,
|
||||||
|
metadata: { ...metadata, segments: [...currentSegments.value] }
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === 自动 TTS:message_complete 且 status=completed 时触发 ===
|
// === 自动 TTS:message_complete 且 status=completed 时触发 ===
|
||||||
if (data.status === 'completed' && data.hasContent && currentAssistantId.value) {
|
if (data.status === 'completed' && data.hasContent && currentAssistantId.value) {
|
||||||
const msg = getMessage(currentAssistantId.value)
|
const msg = getMessage(currentAssistantId.value)
|
||||||
@ -367,6 +422,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
metadata: { ...metadata, toolCalls, currentPhase: 'executing_tool', runningToolName: data.toolName }
|
metadata: { ...metadata, toolCalls, currentPhase: 'executing_tool', runningToolName: data.toolName }
|
||||||
} as any)
|
} as any)
|
||||||
}
|
}
|
||||||
|
// 分段:关闭之前的 thinking/content segment,创建新的 tool_call segment
|
||||||
|
const segs = currentSegments.value
|
||||||
|
const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content'))
|
||||||
|
if (runningSeg) runningSeg.status = 'completed'
|
||||||
|
segs.push({
|
||||||
|
id: genSegId(), type: 'tool_call', status: 'running',
|
||||||
|
toolName: data.toolName, toolArgs: data.arguments,
|
||||||
|
timestamp: data.timestamp || Date.now(),
|
||||||
|
})
|
||||||
|
flushSegmentsToMessage()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -390,6 +455,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
metadata: { ...metadata, toolCalls, runningToolName: undefined }
|
metadata: { ...metadata, toolCalls, runningToolName: undefined }
|
||||||
} as any)
|
} as any)
|
||||||
}
|
}
|
||||||
|
// 分段:找到对应的 running tool_call segment 并标记完成
|
||||||
|
const segs = currentSegments.value
|
||||||
|
const toolSeg = segs.findLast((s: MessageSegment) =>
|
||||||
|
s.type === 'tool_call' && s.status === 'running' && s.toolName === data.toolName)
|
||||||
|
if (toolSeg) {
|
||||||
|
toolSeg.status = data.success !== false ? 'completed' : 'error'
|
||||||
|
toolSeg.toolResult = data.result
|
||||||
|
toolSeg.toolSuccess = data.success
|
||||||
|
}
|
||||||
|
flushSegmentsToMessage()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,10 @@ export interface UseMessagesReturn {
|
|||||||
messages: import('vue').Ref<Message[]>
|
messages: import('vue').Ref<Message[]>
|
||||||
/** 是否正在生成 */
|
/** 是否正在生成 */
|
||||||
isGenerating: import('vue').ComputedRef<boolean>
|
isGenerating: import('vue').ComputedRef<boolean>
|
||||||
|
/** 是否还有更早的消息可加载 */
|
||||||
|
hasMore: import('vue').Ref<boolean>
|
||||||
|
/** 是否正在加载更早消息 */
|
||||||
|
loadingOlder: import('vue').Ref<boolean>
|
||||||
/** 最后一条消息 */
|
/** 最后一条消息 */
|
||||||
lastMessage: import('vue').ComputedRef<Message | undefined>
|
lastMessage: import('vue').ComputedRef<Message | undefined>
|
||||||
/** 最后一条用户消息 */
|
/** 最后一条用户消息 */
|
||||||
@ -45,6 +49,10 @@ export interface UseMessagesReturn {
|
|||||||
createUserMessage: (content: string, contentParts?: MessageContentPart[]) => Message
|
createUserMessage: (content: string, contentParts?: MessageContentPart[]) => Message
|
||||||
/** 创建助手消息 */
|
/** 创建助手消息 */
|
||||||
createAssistantMessage: (content?: string) => Message
|
createAssistantMessage: (content?: string) => Message
|
||||||
|
/** 在消息列表头部插入更早的消息(分页加载) */
|
||||||
|
prependMessages: (olderMessages: Message[]) => void
|
||||||
|
/** 设置 hasMore 状态 */
|
||||||
|
setHasMore: (value: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一 ID
|
// 生成唯一 ID
|
||||||
@ -54,6 +62,8 @@ export function useMessages(options: UseMessagesOptions = {}): UseMessagesReturn
|
|||||||
const { initialMessages = [], onUpdate, onComplete } = options
|
const { initialMessages = [], onUpdate, onComplete } = options
|
||||||
|
|
||||||
const messages = ref<Message[]>([...initialMessages])
|
const messages = ref<Message[]>([...initialMessages])
|
||||||
|
const hasMore = ref(false)
|
||||||
|
const loadingOlder = ref(false)
|
||||||
|
|
||||||
// 是否正在生成
|
// 是否正在生成
|
||||||
const isGenerating = computed(() => {
|
const isGenerating = computed(() => {
|
||||||
@ -207,9 +217,22 @@ export function useMessages(options: UseMessagesOptions = {}): UseMessagesReturn
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 在消息列表头部插入更早的消息(分页加载用)
|
||||||
|
const prependMessages = (olderMessages: Message[]) => {
|
||||||
|
messages.value = [...olderMessages, ...messages.value]
|
||||||
|
onUpdate?.(messages.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置是否有更多消息
|
||||||
|
const setHasMore = (value: boolean) => {
|
||||||
|
hasMore.value = value
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
|
hasMore,
|
||||||
|
loadingOlder,
|
||||||
lastMessage,
|
lastMessage,
|
||||||
lastUserMessage,
|
lastUserMessage,
|
||||||
lastAssistantMessage,
|
lastAssistantMessage,
|
||||||
@ -222,6 +245,8 @@ export function useMessages(options: UseMessagesOptions = {}): UseMessagesReturn
|
|||||||
getMessage,
|
getMessage,
|
||||||
createUserMessage,
|
createUserMessage,
|
||||||
createAssistantMessage,
|
createAssistantMessage,
|
||||||
|
prependMessages,
|
||||||
|
setHasMore,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -131,6 +131,30 @@ export interface PendingApprovalMeta {
|
|||||||
summary?: string
|
summary?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单个展示分段(Claude Code 风格分段式渲染) */
|
||||||
|
export interface MessageSegment {
|
||||||
|
id: string
|
||||||
|
type: 'thinking' | 'tool_call' | 'content' | 'phase' | 'approval' | 'plan'
|
||||||
|
status: 'running' | 'completed' | 'error'
|
||||||
|
/** type=thinking */
|
||||||
|
thinkingText?: string
|
||||||
|
/** type=tool_call */
|
||||||
|
toolName?: string
|
||||||
|
toolArgs?: string
|
||||||
|
toolResult?: string
|
||||||
|
toolSuccess?: boolean
|
||||||
|
/** type=content */
|
||||||
|
text?: string
|
||||||
|
/** type=phase */
|
||||||
|
phaseName?: string
|
||||||
|
/** type=approval */
|
||||||
|
approval?: PendingApprovalMeta
|
||||||
|
/** type=plan */
|
||||||
|
plan?: PlanMeta
|
||||||
|
/** 时间戳 */
|
||||||
|
timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageMetadata {
|
export interface MessageMetadata {
|
||||||
currentPhase?: string
|
currentPhase?: string
|
||||||
toolCalls?: ToolCallMeta[]
|
toolCalls?: ToolCallMeta[]
|
||||||
@ -140,6 +164,8 @@ export interface MessageMetadata {
|
|||||||
runningToolName?: string
|
runningToolName?: string
|
||||||
/** 服务端警告列表 */
|
/** 服务端警告列表 */
|
||||||
warnings?: string[]
|
warnings?: string[]
|
||||||
|
/** 分段式展示数据(新版渲染用) */
|
||||||
|
segments?: MessageSegment[]
|
||||||
/** 浏览器执行操作记录 */
|
/** 浏览器执行操作记录 */
|
||||||
browserActions?: Array<{
|
browserActions?: Array<{
|
||||||
action: string
|
action: string
|
||||||
|
|||||||
@ -1173,6 +1173,11 @@ 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 返回字符串)
|
||||||
|
if (typeof msg.metadata === 'string') {
|
||||||
|
try { msg.metadata = JSON.parse(msg.metadata) } catch { msg.metadata = {} as any }
|
||||||
|
}
|
||||||
|
|
||||||
// 保留后端返回的 token 字段(MessageVO 新增)
|
// 保留后端返回的 token 字段(MessageVO 新增)
|
||||||
if ((raw as any).promptTokens) msg.promptTokens = (raw as any).promptTokens
|
if ((raw as any).promptTokens) msg.promptTokens = (raw as any).promptTokens
|
||||||
if ((raw as any).completionTokens) msg.completionTokens = (raw as any).completionTokens
|
if ((raw as any).completionTokens) msg.completionTokens = (raw as any).completionTokens
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user