feat(ui): 实现深度思考折叠功能,提升用户界面信息密度与交互体验 [优化:支持用户展开/收起思考过程,减少信息干扰]
This commit is contained in:
parent
a289db56c7
commit
d80b17050d
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue