diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 84a75bd9..bdd844b5 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -169,11 +169,37 @@ public abstract class BaseAgent { } protected List buildConversationHistory(String conversationId, String currentUserMessage) { - List history = conversationService.listMessages(conversationId); - if (history.isEmpty()) { + // ===== 两阶段加载:短对话全量,长对话分页(递进式) ===== + long totalCount = conversationService.countMessages(conversationId); + if (totalCount <= 0) { return List.of(); } + int windowSize = getEffectiveWindowSize(); + List 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(); if (limit > 0) { MessageEntity last = history.get(limit - 1); @@ -202,6 +228,24 @@ public abstract class BaseAgent { 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)); + } + /** * 判断是否为审批占位消息(委托给共享工具类) */ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index 251e11b3..46d7a84a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Component; import vip.mate.agent.prompt.PromptLoader; import vip.mate.config.ConversationWindowProperties; import vip.mate.memory.spi.MemoryManager; +import vip.mate.workspace.conversation.ConversationService; import java.util.ArrayList; import java.util.List; @@ -80,6 +81,7 @@ public class ConversationWindowManager { private final ConversationWindowProperties properties; private final MemoryManager memoryManager; + private final ConversationService conversationService; // ==================== 状态 ==================== @@ -242,6 +244,16 @@ public class ConversationWindowManager { int count = compressionCounts.merge(conversationId, 1, Integer::sum); log.info("[ConversationWindow] 生成结构化摘要 ({} 字符, 第 {} 次压缩), 压缩 {} 条旧消息, conv={}", 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()); + } + } } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 10b330ce..283551dd 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1219,6 +1219,9 @@ public class ChatController { private final StringBuilder content = new StringBuilder(); private final StringBuilder thinking = new StringBuilder(); private final List> toolCalls = new ArrayList<>(); + /** 实时分段列表(与前端 currentSegments 对齐) */ + private final List> segments = new ArrayList<>(); + private int segCounter = 0; private int promptTokens = 0; private int completionTokens = 0; private String runtimeModelName = ""; @@ -1262,12 +1265,16 @@ public class ChatController { if (!delta.persistenceOnly()) { broadcastEvent(conversationId, "content_delta", Map.of("delta", delta.content())); } + // 分段:追加到当前 content segment 或创建新的 + appendToContentSegment(delta.content()); } 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()); } } @@ -1283,6 +1290,15 @@ public class ChatController { tc.put("arguments", data.getOrDefault("arguments", "")); tc.put("status", "running"); toolCalls.add(tc); + // 分段:关闭 running 的 thinking/content,创建新 tool_call segment + closeRunningSegments("thinking", "content"); + Map 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)) { String toolName = String.valueOf(data.getOrDefault("toolName", "")); for (int i = toolCalls.size() - 1; i >= 0; i--) { @@ -1294,6 +1310,65 @@ public class ChatController { break; } } + // 分段:标记对应 tool_call segment 完成 + for (int i = segments.size() - 1; i >= 0; i--) { + Map 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 seg = segments.get(i); + if ("thinking".equals(seg.get("type")) && "running".equals(seg.get("status"))) { + seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + text); + return; + } + } + // 没找到,创建新的 + Map 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 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 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 typeSet = java.util.Set.of(types); + for (Map 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 及其他元数据 */ synchronized String toMetadataJson() { - // 确保所有 tool calls 都不是 running 状态 + // 确保所有 tool calls 和 segments 都不是 running 状态 finalizeToolCalls(); + closeRunningSegments("thinking", "content", "tool_call"); try { Map metadata = new LinkedHashMap<>(); if (!toolCalls.isEmpty()) { metadata.put("toolCalls", toolCalls); } + // 使用实时构建的 segments(精确保留事件顺序,包含中间步骤) + if (!segments.isEmpty()) { + metadata.put("segments", segments); + } return objectMapper.writeValueAsString(metadata); } catch (Exception e) { log.warn("Failed to serialize metadata: {}", e.getMessage()); diff --git a/mateclaw-server/src/main/java/vip/mate/memory/config/MemorySchemaMigration.java b/mateclaw-server/src/main/java/vip/mate/memory/config/MemorySchemaMigration.java index 19b26801..f95b600a 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/config/MemorySchemaMigration.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/config/MemorySchemaMigration.java @@ -35,6 +35,9 @@ public class MemorySchemaMigration implements ApplicationRunner { 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"); } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 3c31d6f3..c7248109 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -23,6 +23,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -315,6 +317,61 @@ public class ConversationService { .orderByAsc(MessageEntity::getId)); } + /** + * 加载最近 N 条消息(倒序取出后翻转为正序)。 + * 利用复合索引 (conversation_id, create_time) 高效分页。 + */ + public List listRecentMessages(String conversationId, int lastN) { + List recent = messageMapper.selectList( + new LambdaQueryWrapper() + .eq(MessageEntity::getConversationId, conversationId) + .orderByDesc(MessageEntity::getCreateTime) + .orderByDesc(MessageEntity::getId) + .last("LIMIT " + lastN)); + Collections.reverse(recent); + return recent; + } + + /** + * 分页加载指定 ID 之前的消息(用于前端上拉加载更早消息)。 + * 返回倒序结果,调用方需自行 reverse。 + */ + public List listMessagesBefore(String conversationId, Long beforeId, int limit) { + List results = messageMapper.selectList( + new LambdaQueryWrapper() + .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() + .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 listMessageViews(String conversationId) { return listMessages(conversationId).stream() .map(message -> MessageVO.from(message, parseMessageParts(message), renderMessageContent(message))) diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java index 93b10b3e..10d7dc63 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java @@ -42,16 +42,55 @@ public class ConversationController { } /** - * 获取指定会话的消息历史 + * 获取指定会话的消息历史(支持分页)。 + *

+ * 不传 limit 时返回全部消息(向后兼容)。 + * 传 limit 时返回最新 limit 条 + hasMore 标志。 + * 传 beforeId + limit 时返回该 ID 之前的 limit 条(上拉加载更早消息)。 */ - @Operation(summary = "获取会话消息历史") + @Operation(summary = "获取会话消息历史(支持分页)") @GetMapping("/{conversationId}/messages") - public R> 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"; if (!conversationService.isConversationOwner(conversationId, username)) { return R.fail("无权访问该会话"); } - return R.ok(conversationService.listMessageViews(conversationId)); + + // 向后兼容:不传 limit 则返回全部消息(旧前端行为) + if (limit == null || limit <= 0) { + return R.ok(conversationService.listMessageViews(conversationId)); + } + + // 分页模式 + java.util.List 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 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 + )); } /** diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index e39290be..815c2108 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -128,8 +128,8 @@ export const chatApi = { // ==================== Conversation ==================== export const conversationApi = { list: () => http.get('/conversations'), - listMessages: (conversationId: string) => - http.get(`/conversations/${conversationId}/messages`), + listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number }) => + http.get(`/conversations/${conversationId}/messages`, { params }), getStatus: (conversationId: string) => http.get(`/conversations/${conversationId}/status`), delete: (conversationId: string) => diff --git a/mateclaw-ui/src/components/chat/CompressionSummary.vue b/mateclaw-ui/src/components/chat/CompressionSummary.vue new file mode 100644 index 00000000..02a33c4c --- /dev/null +++ b/mateclaw-ui/src/components/chat/CompressionSummary.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/mateclaw-ui/src/components/chat/ContentSegment.vue b/mateclaw-ui/src/components/chat/ContentSegment.vue new file mode 100644 index 00000000..038fa4a5 --- /dev/null +++ b/mateclaw-ui/src/components/chat/ContentSegment.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 4271fd6a..ba526a32 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -18,6 +18,20 @@

+ + + + + +
{ // --- 执行过程面板 --- 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(() => { + 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(() => { return props.message.metadata?.toolCalls || [] }) @@ -646,6 +715,14 @@ watch(isGenerating, (generating) => { diff --git a/mateclaw-ui/src/components/chat/ToolCallSegment.vue b/mateclaw-ui/src/components/chat/ToolCallSegment.vue new file mode 100644 index 00000000..c0e7e679 --- /dev/null +++ b/mateclaw-ui/src/components/chat/ToolCallSegment.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 61fbd58a..43d5fe2c 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -12,7 +12,7 @@ import { ref, computed } from 'vue' import { useMessages } from './useMessages' import { useStream } from './useStream' 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 { http } from '@/api' @@ -109,6 +109,23 @@ export function useChat(options: UseChatOptions): UseChatReturn { let stopFallbackTimer: ReturnType | null = null const streamPhase = ref('idle') const phaseInfo = ref(null) + + /** 分段式展示数据:当前助手消息的所有分段 */ + const currentSegments = ref([]) + 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(null) /** Track which conversation the current stream belongs to */ let streamConversationId = '' @@ -174,6 +191,18 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) { 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') { 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 } + // 重置分段列表 + currentSegments.value = [] + segIdCounter.value = 0 + const assistantMessage = createAssistantMessage('') if (streamConversationId) { assistantMessage.conversationId = streamConversationId @@ -252,6 +294,19 @@ export function useChat(options: UseChatOptions): UseChatReturn { // 关键修复:不在这里清除 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 时触发 === if (data.status === 'completed' && data.hasContent && 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 } } 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 } } 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() } }) diff --git a/mateclaw-ui/src/composables/chat/useMessages.ts b/mateclaw-ui/src/composables/chat/useMessages.ts index dcb7d06e..317ed462 100644 --- a/mateclaw-ui/src/composables/chat/useMessages.ts +++ b/mateclaw-ui/src/composables/chat/useMessages.ts @@ -21,6 +21,10 @@ export interface UseMessagesReturn { messages: import('vue').Ref /** 是否正在生成 */ isGenerating: import('vue').ComputedRef + /** 是否还有更早的消息可加载 */ + hasMore: import('vue').Ref + /** 是否正在加载更早消息 */ + loadingOlder: import('vue').Ref /** 最后一条消息 */ lastMessage: import('vue').ComputedRef /** 最后一条用户消息 */ @@ -45,6 +49,10 @@ export interface UseMessagesReturn { createUserMessage: (content: string, contentParts?: MessageContentPart[]) => Message /** 创建助手消息 */ createAssistantMessage: (content?: string) => Message + /** 在消息列表头部插入更早的消息(分页加载) */ + prependMessages: (olderMessages: Message[]) => void + /** 设置 hasMore 状态 */ + setHasMore: (value: boolean) => void } // 生成唯一 ID @@ -54,6 +62,8 @@ export function useMessages(options: UseMessagesOptions = {}): UseMessagesReturn const { initialMessages = [], onUpdate, onComplete } = options const messages = ref([...initialMessages]) + const hasMore = ref(false) + const loadingOlder = ref(false) // 是否正在生成 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 { messages, isGenerating, + hasMore, + loadingOlder, lastMessage, lastUserMessage, lastAssistantMessage, @@ -222,6 +245,8 @@ export function useMessages(options: UseMessagesOptions = {}): UseMessagesReturn getMessage, createUserMessage, createAssistantMessage, + prependMessages, + setHasMore, } } diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 59b4bbce..30be7604 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -131,6 +131,30 @@ export interface PendingApprovalMeta { 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 { currentPhase?: string toolCalls?: ToolCallMeta[] @@ -140,6 +164,8 @@ export interface MessageMetadata { runningToolName?: string /** 服务端警告列表 */ warnings?: string[] + /** 分段式展示数据(新版渲染用) */ + segments?: MessageSegment[] /** 浏览器执行操作记录 */ browserActions?: Array<{ action: string diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 002d50e1..79a3876b 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -1173,6 +1173,11 @@ function buildOutgoingParts(text: string, attachments: ChatAttachment[]): Messag function normalizeMessage(raw: Message): Message { 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 新增) if ((raw as any).promptTokens) msg.promptTokens = (raw as any).promptTokens if ((raw as any).completionTokens) msg.completionTokens = (raw as any).completionTokens