Skip to content

perf: implement the expand/collapse function for reasoning process #602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export default {
deleteKeyConfirm: 'Are you sure to delete this key?',
disable2FA: 'Disable 2FA',
disable2FAConfirm: 'Are you sure to disable 2FA for this user?',
thinking: 'Thinking',
reasoningProcess: 'Reasoning Process',
noReasoningProcess: 'No Reasoning Process',
expandCollapseReasoningProcess: 'Expand/Collapse Reasoning Process',
},
setting: {
overRide: 'Enable Override',
Expand Down
4 changes: 4 additions & 0 deletions src/locales/ko-KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export default {
deleteKeyConfirm: 'Are you sure to delete this key?',
disable2FA: 'Disable 2FA',
disable2FAConfirm: 'Are you sure to disable 2FA for this user?',
thinking: '생각 중',
reasoningProcess: '추론 과정',
noReasoningProcess: '추론 과정 없음',
expandCollapseReasoningProcess: '추론 과정 펼치기/접기',
},
setting: {
overRide: '덮어쓰기 활성화',
Expand Down
4 changes: 4 additions & 0 deletions src/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export default {
deleteKeyConfirm: '你确定要删除这个 key 吗?',
disable2FA: '禁用 2FA',
disable2FAConfirm: '您确定要为此用户禁用两步验证吗??',
thinking: '思考中',
reasoningProcess: '推理过程',
noReasoningProcess: '无推理过程',
expandCollapseReasoningProcess: '展开/折叠推理过程',
},
setting: {
overRide: '开启覆写',
Expand Down
4 changes: 4 additions & 0 deletions src/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export default {
deleteKeyConfirm: '你確定要刪除這個 key 嗎?',
disable2FA: '禁用 2FA',
disable2FAConfirm: '您确定要为此用户禁用两步验证吗??',
thinking: '思考中',
reasoningProcess: '推理過程',
noReasoningProcess: '無推理過程',
expandCollapseReasoningProcess: '展開/收合推理過程',
},
setting: {
overRide: '開啟覆寫',
Expand Down
1 change: 1 addition & 0 deletions src/typings/chat.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ declare namespace Chat {
uuid?: number
dateTime: string
reasoning?: string
finish_reason?: string
text: string
images?: string[]
inversion?: boolean
Expand Down
162 changes: 151 additions & 11 deletions src/views/chat/components/Message/Reasoning.vue
Original file line number Diff line number Diff line change
@@ -1,38 +1,178 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed, getCurrentInstance, ref } from 'vue'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { t } from '@/locales'

interface Props {
reasoning?: string
reasonEnd?: boolean
loading?: boolean
}

defineProps<Props>()
const props = defineProps<Props>()

const { isMobile } = useBasicLayout()
const instance = getCurrentInstance()
const uid = instance?.uid || Date.now() + Math.random().toString(36).substring(2)

const textRef = ref<HTMLElement>()
const isCollapsed = ref(false)

const wrapClass = computed(() => {
const reasoningBtnTitle = computed(() => {
return t('chat.expandCollapseReasoningProcess')
})

const shouldShowThinkingIndicator = computed(() => {
return props.loading && !props.reasonEnd
})

const hasReasoningText = computed(() => {
return props.reasoning && props.reasoning.trim() !== ''
})

const headerComputedClass = computed(() => {
return [
'flex items-center justify-between',
'p-2 pl-3 w-48',
'bg-gray-200 dark:bg-slate-700',
'text-xs select-none font-medium',
'transition-all duration-200 ease-in-out',
hasReasoningText.value ? 'cursor-pointer hover:bg-gray-300 dark:hover:bg-slate-600' : 'cursor-default',
(isCollapsed.value || !hasReasoningText.value) ? 'rounded-md' : 'rounded-t-md',
isMobile.value ? 'max-w-full' : 'max-w-md',
'shadow-sm',
]
})

const contentWrapperComputedClass = computed(() => {
return [
'overflow-hidden',
'transition-all duration-300 ease-in-out',
(isCollapsed.value || !hasReasoningText.value) ? 'max-h-0 opacity-0' : 'max-h-[500px] opacity-100',
]
})

const actualContentComputedClass = computed(() => {
return [
'text-wrap',
'min-w-[20px]',
'rounded-md',
isMobile.value ? 'p-2' : 'px-3 py-2',
'p-3',
'bg-gray-50 dark:bg-slate-800',
'rounded-b-md shadow-sm',
'text-xs leading-relaxed break-words',
'prose prose-sm dark:prose-invert max-w-none',
]
})

function toggleCollapse() {
if (hasReasoningText.value)
isCollapsed.value = !isCollapsed.value
}
</script>

<template>
<div class="text-black" :class="wrapClass">
<div ref="textRef" class="leading-relaxed break-words">
<div class="flex items-end">
<div class="w-full dark:text-gray-50 text-xs" v-text="reasoning" />
<div class="my-2">
<div
:class="headerComputedClass"
:role="hasReasoningText ? 'button' : undefined"
:tabindex="hasReasoningText ? 0 : -1"
:aria-expanded="hasReasoningText ? !isCollapsed : undefined"
:aria-controls="hasReasoningText ? `reasoning-details-${uid}` : undefined"
@click="hasReasoningText ? toggleCollapse() : null"
@keydown.enter="hasReasoningText ? toggleCollapse() : null"
@keydown.space="hasReasoningText ? toggleCollapse() : null"
>
<div class="flex items-center pr-2">
<template v-if="shouldShowThinkingIndicator">
<svg
class="animate-spin mr-2 h-4 w-4 text-blue-500 dark:text-blue-400 shrink-0"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span class="text-gray-700 dark:text-gray-200 truncate">{{ $t('chat.thinking') }}</span>
<span v-if="hasReasoningText" class="mx-1.5 text-gray-400 dark:text-gray-500">|</span>
</template>
<span v-if="hasReasoningText" class="text-gray-800 dark:text-gray-100 truncate">{{ $t('chat.reasoningProcess') }}</span>
<span v-else-if="!shouldShowThinkingIndicator && !hasReasoningText" class="text-gray-500 dark:text-gray-400">({{ $t('chat.noReasoningProcess') }})</span>
</div>
<button
v-if="hasReasoningText"
type="button"
class="ml-auto flex items-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 focus:outline-none p-1 shrink-0 rounded-full hover:bg-gray-300 dark:hover:bg-slate-600"
:aria-expanded="!isCollapsed"
:aria-controls="`reasoning-details-${uid}`"
:title="reasoningBtnTitle"
@click.stop="toggleCollapse"
@keydown.enter.stop="toggleCollapse"
@keydown.space.stop="toggleCollapse"
>
<svg
class="w-4 h-4 transform transition-transform duration-200"
:class="{ 'rotate-180': !isCollapsed }"
fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>

<div :class="contentWrapperComputedClass">
<div
v-if="hasReasoningText"
:id="`reasoning-details-${uid}`"
ref="textRef"
:class="actualContentComputedClass"
role="region"
:aria-hidden="isCollapsed"
>
<div class="w-full" v-text="props.reasoning" />
</div>
</div>
</div>
</template>

<style lang="less">
@import url(./style.less);

.prose {
code {
background-color: rgba(209,213,219,0.3);
padding: .2em .4em;
margin: 0;
font-size: 85%;
border-radius: 3px;
}
pre {
background-color: rgba(229,231,235,1);
color: rgba(55,65,81,1);
padding: 0.75rem;
border-radius: 0.25rem;
overflow-x: auto;
code {
background-color: transparent;
padding: 0;
margin: 0;
font-size: inherit;
border-radius: 0;
color: inherit;
}
}
}
.dark .prose {
color: rgba(209,213,219,1);
code {
background-color: rgba(55,65,81,0.5);
color: rgba(229,231,235,1);
}
pre {
background-color: rgba(31,41,55,1);
color: rgba(229,231,235,1);
}
}

.whitespace-pre-wrap {
white-space: normal;
}
</style>
3 changes: 2 additions & 1 deletion src/views/chat/components/Message/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface Props {
dateTime?: string
model?: string
reasoning?: string
finishReason?: string
text?: string
images?: string[]
isRecord?: boolean
Expand Down Expand Up @@ -225,7 +226,7 @@ function isEventTargetValid(event: any) {
</template>
</NSpace>
</p>
<Reasoning v-if="reasoning" :reasoning="reasoning" :loading="loading" />
<Reasoning v-if="reasoning" :reasoning="reasoning" :reason-end="text ? text.length > 0 : false" :loading="loading" />
<div
class="flex items-end gap-1 mt-2"
:class="[inversion ? 'flex-row-reverse' : 'flex-row']"
Expand Down
2 changes: 2 additions & 0 deletions src/views/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ async function onRegenerate(index: number) {
{
dateTime: new Date().toLocaleString(),
reasoning: data?.reasoning,
finish_reason: data?.finish_reason,
text: lastText + (data.text ?? ''),
inversion: false,
responseCount,
Expand Down Expand Up @@ -687,6 +688,7 @@ onUnmounted(() => {
:current-nav-index="currentNavIndexRef"
:date-time="item.dateTime"
:reasoning="item?.reasoning"
:finish-reason="item?.finish_reason"
:text="item.text"
:images="item.images"
:inversion="item.inversion"
Expand Down