feat(chat): segmented message display, progressive loading, and real-time segment persistence

This commit is contained in:
matevip 2026-04-10 07:36:24 +08:00
parent ec7ea038e7
commit dafdcb4182
17 changed files with 915 additions and 24 deletions

View File

@ -169,11 +169,37 @@ public abstract class BaseAgent {
}
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();
}
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();
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));
}
/**
* 判断是否为审批占位消息委托给共享工具类
*/

View File

@ -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());
}
}
}
}

View File

@ -1219,6 +1219,9 @@ public class ChatController {
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 = "";
@ -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<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)) {
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<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 及其他元数据
*/
synchronized String toMetadataJson() {
// 确保所有 tool calls 都不是 running 状态
// 确保所有 tool calls segments 都不是 running 状态
finalizeToolCalls();
closeRunningSegments("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);
}
return objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
log.warn("Failed to serialize metadata: {}", e.getMessage());

View File

@ -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");
}

View File

@ -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<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) {
return listMessages(conversationId).stream()
.map(message -> MessageVO.from(message, parseMessageParts(message), renderMessageContent(message)))

View File

@ -42,16 +42,55 @@ public class ConversationController {
}
/**
* 获取指定会话的消息历史
* 获取指定会话的消息历史支持分页
* <p>
* 不传 limit 时返回全部消息向后兼容
* limit 时返回最新 limit + hasMore 标志
* beforeId + limit 时返回该 ID 之前的 limit 上拉加载更早消息
*/
@Operation(summary = "获取会话消息历史")
@Operation(summary = "获取会话消息历史(支持分页)")
@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";
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<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
));
}
/**

View File

@ -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) =>

View 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>

View 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>

View File

@ -18,6 +18,20 @@
<!-- 消息体 -->
<div class="msg-body" :class="`${role}-body`">
<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">
<button class="thinking-toggle" type="button" @click="toggleThinking">
@ -178,6 +192,8 @@
</div>
</div>
</template><!-- /传统合并渲染模式 -->
<!-- 附件列表 -->
<div v-if="attachments?.length" class="message-attachments">
<div
@ -292,8 +308,11 @@ import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttach
import { http } from '@/api'
import TypingCursor from './TypingCursor.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 { Message, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
import type { Message, MessageSegment, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
import type { ChatErrorInfo } from '@/types/chatError'
const { renderMarkdown } = useMarkdownRenderer()
@ -554,6 +573,56 @@ const formatFileSize = (size: number) => {
// --- ---
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[]>(() => {
return props.message.metadata?.toolCalls || []
})
@ -646,6 +715,14 @@ watch(isGenerating, (generating) => {
</script>
<style scoped>
/* 分段式渲染容器 */
.segments-view {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0;
}
.message-wrapper {
display: flex;
gap: 12px;

View File

@ -38,19 +38,37 @@
<!-- 消息列表 -->
<template v-else>
<MessageBubble
v-for="(msg, index) in messages"
:key="msg.id || index"
:message="msg"
:is-last="index === messages.length - 1"
:assistant-icon="assistantIcon"
:user-icon="userIcon"
:show-cursor="showCursorForMessage(msg)"
@regenerate="$emit('regenerate', msg)"
@toggle-thinking="(expanded) => $emit('toggle-thinking', msg, expanded)"
@approve="(pendingId) => $emit('approve', pendingId)"
@deny="(pendingId) => $emit('deny', pendingId)"
/>
<!-- 上拉加载更早消息触发器 -->
<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
v-else
:message="msg"
:is-last="index === messages.length - 1"
:assistant-icon="assistantIcon"
:user-icon="userIcon"
:show-cursor="showCursorForMessage(msg)"
@regenerate="$emit('regenerate', msg)"
@toggle-thinking="(expanded) => $emit('toggle-thinking', msg, expanded)"
@approve="(pendingId) => $emit('approve', pendingId)"
@deny="(pendingId) => $emit('deny', pendingId)"
/>
</template>
</template>
<!-- 加载指示器只在无消息时显示有消息时由输入框显示停止按钮 -->
@ -69,6 +87,7 @@
import { computed, watch, nextTick } from 'vue'
import { ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
import MessageBubble from './MessageBubble.vue'
import CompressionSummary from './CompressionSummary.vue'
import { useStickToBottom } from '@/composables/chat/useStickToBottom'
import type { Message } from '@/types'
@ -89,6 +108,10 @@ interface Props {
suggestions?: string[]
/** 是否自动滚动到底部 */
autoScroll?: boolean
/** 是否还有更早的消息可加载 */
hasMore?: boolean
/** 是否正在加载更早消息 */
loadingOlder?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -99,6 +122,8 @@ const props = withDefaults(defineProps<Props>(), {
subtitle: '',
suggestions: () => [],
autoScroll: true,
hasMore: false,
loadingOlder: false,
})
const emit = defineEmits<{
@ -108,8 +133,20 @@ const emit = defineEmits<{
scroll: [event: Event]
approve: [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({
enabled: props.autoScroll,

View 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>

View 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>

View File

@ -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<typeof setTimeout> | null = null
const streamPhase = ref<StreamPhase>('idle')
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)
/** 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)
}
}
// === 自动 TTSmessage_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()
}
})

View File

@ -21,6 +21,10 @@ export interface UseMessagesReturn {
messages: import('vue').Ref<Message[]>
/** 是否正在生成 */
isGenerating: import('vue').ComputedRef<boolean>
/** 是否还有更早的消息可加载 */
hasMore: import('vue').Ref<boolean>
/** 是否正在加载更早消息 */
loadingOlder: import('vue').Ref<boolean>
/** 最后一条消息 */
lastMessage: import('vue').ComputedRef<Message | undefined>
/** 最后一条用户消息 */
@ -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<Message[]>([...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,
}
}

View File

@ -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

View File

@ -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