fix(chat): preserve intermediate reasoning in segments via phase-based splitting

This commit is contained in:
matevip 2026-04-10 07:58:19 +08:00
parent dafdcb4182
commit ba34f1eed3
3 changed files with 89 additions and 16 deletions

View File

@ -1248,6 +1248,9 @@ public class ChatController {
String phase = String.valueOf(delta.eventData().getOrDefault("phase", "")); String phase = String.valueOf(delta.eventData().getOrDefault("phase", ""));
if (!phase.isBlank()) { if (!phase.isBlank()) {
streamTracker.updatePhase(conversationId, phase); streamTracker.updatePhase(conversationId, phase);
// 关键phase 变化时关闭 running content/thinking segments
// 让每个阶段reasoning tool summarize final answer的文字成为独立 segment
closeRunningSegments("content", "thinking");
} }
} }
// 累积工具调用事件用于持久化到消息历史 // 累积工具调用事件用于持久化到消息历史

View File

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

View File

@ -21,10 +21,10 @@
<!-- ===== 分段式渲染模式Claude Code 风格===== --> <!-- ===== 分段式渲染模式Claude Code 风格===== -->
<template v-if="useSegmentedView"> <template v-if="useSegmentedView">
<div class="segments-view"> <div class="segments-view">
<template v-for="seg in segments" :key="seg.id"> <template v-for="(seg, index) in segments" :key="seg.id">
<ThinkingSegment v-if="seg.type === 'thinking'" :segment="seg" /> <ThinkingSegment v-if="seg.type === 'thinking'" :segment="seg" />
<ToolCallSegment v-if="seg.type === 'tool_call'" :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'" /> <ContentSegment v-if="seg.type === 'content'" :segment="seg" :is-last="index === segments.length - 1" :show-cursor="showCursor && seg.status === 'running'" />
</template> </template>
</div> </div>
</template> </template>