feat(ui): 实现深度思考折叠功能,提升用户界面信息密度与交互体验 [优化:支持用户展开/收起思考过程,减少信息干扰]

This commit is contained in:
肖应宇 2026-03-05 11:43:27 +08:00
parent a289db56c7
commit d80b17050d
3 changed files with 135 additions and 113 deletions

View File

@ -442,21 +442,16 @@ async def chat_endpoint_handler(body: dict):
# 处理思考过程片段
if reasoning_content:
if not full_reasoning_content:
# 第一个思考片段,加标题前缀
delta_str += (
"> **💭 深度思考过程:**\n> \n> "
)
# 第一个思考片段,添加 <think> 开始标签
delta_str += "<think>"
full_reasoning_content += reasoning_content
# markdown 引用块内换行需加 >
delta_str += reasoning_content.replace(
"\n", "\n> "
)
delta_str += reasoning_content
# 处理正式回复片段
if content:
if not full_content and full_reasoning_content:
# 思考结束后首个正式回复,加分隔线
delta_str += "\n\n---\n\n"
# 思考结束后首个正式回复,关闭 </think> 标签
delta_str += "</think>\n\n"
full_content += content
delta_str += content
@ -581,8 +576,7 @@ async def chat_endpoint_handler(body: dict):
content = msg_dict.get("content", "")
rc = msg_dict.get("reasoning_content", "")
if rc:
rc_formatted = rc.replace("\n", "\n> ")
content = f"> **💭 深度思考过程:**\n> \n> {rc_formatted}\n\n---\n\n{content}"
content = f"<think>{rc}</think>\n\n{content}"
# 否则尝试从 output.text 获取内容DashScope特定格式
elif (
hasattr(response, "output")

View File

@ -364,17 +364,16 @@ async def glm_stream_generator(
# ── 思考过程reasoning_content────────────────────────
if reasoning:
if not full_reasoning:
# 首个思考片段:加 Markdown 引用块标题
delta_str += "> **💭 深度思考过程:**\n> \n> "
# 首个思考片段:添加 <think> 开始标签
delta_str += "<think>"
full_reasoning += reasoning
# 引用块内换行需在每行前加 `> `
delta_str += reasoning.replace("\n", "\n> ")
delta_str += reasoning
# ── 正式回答content──────────────────────────────────
if text:
if not full_content and full_reasoning:
# 思考结束后首次出现正式回答:加分隔线
delta_str += "\n\n---\n\n"
# 思考结束后首次出现正式回答:关闭 </think> 标签
delta_str += "</think>\n\n"
full_content += text
delta_str += text

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { MarkdownRender } from "markstream-vue";
import { useClipboard } from "@vueuse/core";
import { ref, watch } from "vue";
defineProps<{
const props = defineProps<{
node: {
type: "think";
content: string;
@ -12,6 +13,21 @@ defineProps<{
}>();
const { copy } = useClipboard({ legacy: true });
const collapsed = ref(false);
// loading true false
watch(
() => props.node.loading,
(newVal, oldVal) => {
if (oldVal === true && newVal === false) {
collapsed.value = true;
}
},
);
function toggleCollapse() {
collapsed.value = !collapsed.value;
}
async function textCopy(data: any) {
if (typeof data === "string") {
@ -22,64 +38,67 @@ async function textCopy(data: any) {
<template>
<div
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400 flex items-start gap-3"
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400"
>
<div class="flex-shrink-0 mt-1">
<!-- decorative thinking SVG icon -->
<div
class="w-9 h-9 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
>
<!-- 可点击的标题栏 -->
<div class="thinking-header" @click="toggleCollapse">
<div class="flex-shrink-0">
<!-- 思考图标 -->
<div
class="w-8 h-8 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
stroke="currentColor"
stroke-width="0.8"
fill="currentColor"
opacity="0.9"
/>
</svg>
</div>
</div>
<div class="thinking-title">
<strong class="text-sm">💭 深度思考</strong>
<!-- 加载动画 -->
<span v-if="node.loading" class="thinking-dots visible" aria-hidden="true">
<span class="dot dot-1" />
<span class="dot dot-2" />
<span class="dot dot-3" />
</span>
<span v-else class="thinking-status text-xs text-slate-500 dark:text-slate-300">
已完成
</span>
</div>
<!-- 折叠/展开箭头 -->
<div class="collapse-arrow" :class="{ collapsed }">
<svg
class="w-5 h-5"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
stroke="currentColor"
stroke-width="0.8"
fill="currentColor"
opacity="0.9"
/>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
<div class="flex-1">
<div class="flex items-baseline gap-3">
<strong class="text-sm">Thinking</strong>
<span class="text-xs text-slate-500 dark:text-slate-300"
>(assistant)</span
>
<!-- keep dots in DOM to avoid layout shift; toggle visibility with classes -->
<span class="ml-2" aria-hidden="true">
<span
class="thinking-dots"
:class="[node.loading ? 'visible' : 'hidden']"
aria-hidden="true"
>
<span class="dot dot-1" />
<span class="dot dot-2" />
<span class="dot dot-3" />
</span>
</span>
</div>
<!-- 可折叠的内容区域 -->
<div class="thinking-content" :class="{ collapsed }">
<div
class="mt-1 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
class="mt-3 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
>
<!-- sr-only live region only present when loading to announce change -->
<span v-if="node.loading" class="sr-only" aria-live="polite"
>Thinking</span
>
<transition name="fade" mode="out-in">
<div
key="{{ node.loading ? 'loading' : 'ready' }}"
class="content-area"
>
<MarkdownRender :content="node.content" @copy="textCopy" />
</div>
</transition>
<MarkdownRender :content="node.content" @copy="textCopy" />
</div>
</div>
</div>
@ -93,33 +112,72 @@ async function textCopy(data: any) {
color: #e6f0ff;
}
/* Animated dots for thinking state */
/* 标题栏 */
.thinking-header {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.thinking-title {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.thinking-status {
font-style: italic;
}
/* 折叠箭头 */
.collapse-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: #64748b;
transition: transform 0.25s ease;
border-radius: 4px;
}
.collapse-arrow:hover {
background: rgba(0, 0, 0, 0.06);
}
.dark .collapse-arrow:hover {
background: rgba(255, 255, 255, 0.08);
}
.collapse-arrow.collapsed {
transform: rotate(-90deg);
}
/* 可折叠内容 */
.thinking-content {
max-height: 2000px;
overflow: hidden;
transition: max-height 0.35s ease, opacity 0.25s ease;
opacity: 1;
}
.thinking-content.collapsed {
max-height: 0;
opacity: 0;
}
/* 加载动画 */
.thinking-dots {
display: inline-flex;
align-items: center;
gap: 6px;
width: 36px;
justify-content: flex-start;
height: 12px; /* reserve vertical space so toggling doesn't collapse layout */
transition:
opacity 160ms linear,
transform 160ms linear;
opacity: 0;
height: 12px;
}
.thinking-dots .dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: #1e3a8a; /* blue-800 */
background: #1e3a8a;
opacity: 0.25;
transform: translateY(0);
}
.thinking-dots.visible {
opacity: 1;
}
.thinking-dots.hidden {
opacity: 0;
transform: translateY(0);
}
.thinking-dots.visible .dot-1 {
animation: think-bounce 1s infinite ease-in-out;
@ -150,33 +208,4 @@ async function textCopy(data: any) {
opacity: 1;
}
}
/* ensure content area doesn't shift when dots appear */
.content-area {
min-height: 1.25rem;
}
.partial-content,
.full-content {
transition: opacity 140ms ease;
}
.partial-content {
opacity: 0.9;
}
.full-content {
opacity: 1;
}
/* Vue transition classes for fade */
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 160ms ease;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>