|
1 | 1 | <script lang="ts" setup>
|
2 |
| -import { computed, ref } from 'vue' |
| 2 | +import { computed, getCurrentInstance, ref } from 'vue' |
3 | 3 | import { useBasicLayout } from '@/hooks/useBasicLayout'
|
| 4 | +import { t } from '@/locales' |
4 | 5 |
|
5 | 6 | interface Props {
|
6 | 7 | reasoning?: string
|
| 8 | + reasonEnd?: boolean |
7 | 9 | loading?: boolean
|
8 | 10 | }
|
9 | 11 |
|
10 |
| -defineProps<Props>() |
| 12 | +const props = defineProps<Props>() |
11 | 13 |
|
12 | 14 | const { isMobile } = useBasicLayout()
|
| 15 | +const instance = getCurrentInstance() |
| 16 | +const uid = instance?.uid || Date.now() + Math.random().toString(36).substring(2) |
13 | 17 |
|
14 | 18 | const textRef = ref<HTMLElement>()
|
| 19 | +const isCollapsed = ref(false) |
15 | 20 |
|
16 |
| -const wrapClass = computed(() => { |
| 21 | +const reasoningBtnTitle = computed(() => { |
| 22 | + return t('chat.expandCollapseReasoningProcess') |
| 23 | +}) |
| 24 | +
|
| 25 | +const shouldShowThinkingIndicator = computed(() => { |
| 26 | + return props.loading && !props.reasonEnd |
| 27 | +}) |
| 28 | +
|
| 29 | +const hasReasoningText = computed(() => { |
| 30 | + return props.reasoning && props.reasoning.trim() !== '' |
| 31 | +}) |
| 32 | +
|
| 33 | +const headerComputedClass = computed(() => { |
| 34 | + return [ |
| 35 | + 'flex items-center justify-between', |
| 36 | + 'p-2 pl-3 w-48', |
| 37 | + 'bg-gray-200 dark:bg-slate-700', |
| 38 | + 'text-xs select-none font-medium', |
| 39 | + 'transition-all duration-200 ease-in-out', |
| 40 | + hasReasoningText.value ? 'cursor-pointer hover:bg-gray-300 dark:hover:bg-slate-600' : 'cursor-default', |
| 41 | + (isCollapsed.value || !hasReasoningText.value) ? 'rounded-md' : 'rounded-t-md', |
| 42 | + isMobile.value ? 'max-w-full' : 'max-w-md', |
| 43 | + 'shadow-sm', |
| 44 | + ] |
| 45 | +}) |
| 46 | +
|
| 47 | +const contentWrapperComputedClass = computed(() => { |
| 48 | + return [ |
| 49 | + 'overflow-hidden', |
| 50 | + 'transition-all duration-300 ease-in-out', |
| 51 | + (isCollapsed.value || !hasReasoningText.value) ? 'max-h-0 opacity-0' : 'max-h-[500px] opacity-100', |
| 52 | + ] |
| 53 | +}) |
| 54 | +
|
| 55 | +const actualContentComputedClass = computed(() => { |
17 | 56 | return [
|
18 |
| - 'text-wrap', |
19 |
| - 'min-w-[20px]', |
20 |
| - 'rounded-md', |
21 |
| - isMobile.value ? 'p-2' : 'px-3 py-2', |
| 57 | + 'p-3', |
| 58 | + 'bg-gray-50 dark:bg-slate-800', |
| 59 | + 'rounded-b-md shadow-sm', |
| 60 | + 'text-xs leading-relaxed break-words', |
| 61 | + 'prose prose-sm dark:prose-invert max-w-none', |
22 | 62 | ]
|
23 | 63 | })
|
| 64 | +
|
| 65 | +function toggleCollapse() { |
| 66 | + if (hasReasoningText.value) |
| 67 | + isCollapsed.value = !isCollapsed.value |
| 68 | +} |
24 | 69 | </script>
|
25 | 70 |
|
26 | 71 | <template>
|
27 |
| - <div class="text-black" :class="wrapClass"> |
28 |
| - <div ref="textRef" class="leading-relaxed break-words"> |
29 |
| - <div class="flex items-end"> |
30 |
| - <div class="w-full dark:text-gray-50 text-xs" v-text="reasoning" /> |
| 72 | + <div class="my-2"> |
| 73 | + <div |
| 74 | + :class="headerComputedClass" |
| 75 | + :role="hasReasoningText ? 'button' : undefined" |
| 76 | + :tabindex="hasReasoningText ? 0 : -1" |
| 77 | + :aria-expanded="hasReasoningText ? !isCollapsed : undefined" |
| 78 | + :aria-controls="hasReasoningText ? `reasoning-details-${uid}` : undefined" |
| 79 | + @click="hasReasoningText ? toggleCollapse() : null" |
| 80 | + @keydown.enter="hasReasoningText ? toggleCollapse() : null" |
| 81 | + @keydown.space="hasReasoningText ? toggleCollapse() : null" |
| 82 | + > |
| 83 | + <div class="flex items-center pr-2"> |
| 84 | + <template v-if="shouldShowThinkingIndicator"> |
| 85 | + <svg |
| 86 | + class="animate-spin mr-2 h-4 w-4 text-blue-500 dark:text-blue-400 shrink-0" |
| 87 | + xmlns="http://www.w3.org/2000/svg" |
| 88 | + fill="none" |
| 89 | + viewBox="0 0 24 24" |
| 90 | + > |
| 91 | + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> |
| 92 | + <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" /> |
| 93 | + </svg> |
| 94 | + <span class="text-gray-700 dark:text-gray-200 truncate">{{ $t('chat.thinking') }}</span> |
| 95 | + <span v-if="hasReasoningText" class="mx-1.5 text-gray-400 dark:text-gray-500">|</span> |
| 96 | + </template> |
| 97 | + <span v-if="hasReasoningText" class="text-gray-800 dark:text-gray-100 truncate">{{ $t('chat.reasoningProcess') }}</span> |
| 98 | + <span v-else-if="!shouldShowThinkingIndicator && !hasReasoningText" class="text-gray-500 dark:text-gray-400">({{ $t('chat.noReasoningProcess') }})</span> |
| 99 | + </div> |
| 100 | + <button |
| 101 | + v-if="hasReasoningText" |
| 102 | + type="button" |
| 103 | + 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" |
| 104 | + :aria-expanded="!isCollapsed" |
| 105 | + :aria-controls="`reasoning-details-${uid}`" |
| 106 | + :title="reasoningBtnTitle" |
| 107 | + @click.stop="toggleCollapse" |
| 108 | + @keydown.enter.stop="toggleCollapse" |
| 109 | + @keydown.space.stop="toggleCollapse" |
| 110 | + > |
| 111 | + <svg |
| 112 | + class="w-4 h-4 transform transition-transform duration-200" |
| 113 | + :class="{ 'rotate-180': !isCollapsed }" |
| 114 | + fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" |
| 115 | + > |
| 116 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> |
| 117 | + </svg> |
| 118 | + </button> |
| 119 | + </div> |
| 120 | + |
| 121 | + <div :class="contentWrapperComputedClass"> |
| 122 | + <div |
| 123 | + v-if="hasReasoningText" |
| 124 | + :id="`reasoning-details-${uid}`" |
| 125 | + ref="textRef" |
| 126 | + :class="actualContentComputedClass" |
| 127 | + role="region" |
| 128 | + :aria-hidden="isCollapsed" |
| 129 | + > |
| 130 | + <div class="w-full" v-text="props.reasoning" /> |
31 | 131 | </div>
|
32 | 132 | </div>
|
33 | 133 | </div>
|
34 | 134 | </template>
|
35 | 135 |
|
36 | 136 | <style lang="less">
|
37 | 137 | @import url(./style.less);
|
| 138 | +
|
| 139 | +.prose { |
| 140 | + code { |
| 141 | + background-color: rgba(209,213,219,0.3); |
| 142 | + padding: .2em .4em; |
| 143 | + margin: 0; |
| 144 | + font-size: 85%; |
| 145 | + border-radius: 3px; |
| 146 | + } |
| 147 | + pre { |
| 148 | + background-color: rgba(229,231,235,1); |
| 149 | + color: rgba(55,65,81,1); |
| 150 | + padding: 0.75rem; |
| 151 | + border-radius: 0.25rem; |
| 152 | + overflow-x: auto; |
| 153 | + code { |
| 154 | + background-color: transparent; |
| 155 | + padding: 0; |
| 156 | + margin: 0; |
| 157 | + font-size: inherit; |
| 158 | + border-radius: 0; |
| 159 | + color: inherit; |
| 160 | + } |
| 161 | + } |
| 162 | +} |
| 163 | +.dark .prose { |
| 164 | + color: rgba(209,213,219,1); |
| 165 | + code { |
| 166 | + background-color: rgba(55,65,81,0.5); |
| 167 | + color: rgba(229,231,235,1); |
| 168 | + } |
| 169 | + pre { |
| 170 | + background-color: rgba(31,41,55,1); |
| 171 | + color: rgba(229,231,235,1); |
| 172 | + } |
| 173 | +} |
| 174 | +
|
| 175 | +.whitespace-pre-wrap { |
| 176 | + white-space: normal; |
| 177 | +} |
38 | 178 | </style>
|
0 commit comments