mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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) {
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为审批占位消息(委托给共享工具类)
|
||||
*/
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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) =>
|
||||
|
||||
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-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;
|
||||
|
||||
@ -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,
|
||||
|
||||
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 { 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)
|
||||
}
|
||||
}
|
||||
|
||||
// === 自动 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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user