Spaces:
Runtime error
Runtime error
| <script setup lang='ts'> | |
| import { computed, ref } from 'vue' | |
| import { NButtonGroup, NDropdown, NPopover, NSpace, useMessage } from 'naive-ui' | |
| import AvatarComponent from './Avatar.vue' | |
| import TextComponent from './Text.vue' | |
| import { SvgIcon } from '@/components/common' | |
| import { useIconRender } from '@/hooks/useIconRender' | |
| import { t } from '@/locales' | |
| import { useBasicLayout } from '@/hooks/useBasicLayout' | |
| import { copyToClip } from '@/utils/copy' | |
| interface Props { | |
| dateTime?: string | |
| text?: string | |
| inversion?: boolean | |
| error?: boolean | |
| loading?: boolean | |
| responseCount?: number | |
| usage?: { | |
| completion_tokens: number | |
| prompt_tokens: number | |
| total_tokens: number | |
| estimated: boolean | |
| } | |
| } | |
| const props = defineProps<Props>() | |
| const emit = defineEmits<Emit>() | |
| interface Emit { | |
| (ev: 'regenerate'): void | |
| (ev: 'delete'): void | |
| (ev: 'responseHistory', historyIndex: number): void | |
| } | |
| const { isMobile } = useBasicLayout() | |
| const { iconRender } = useIconRender() | |
| const message = useMessage() | |
| const textRef = ref<HTMLElement>() | |
| const asRawText = ref(props.inversion) | |
| const messageRef = ref<HTMLElement>() | |
| const indexRef = ref<number>(0) | |
| indexRef.value = props.responseCount ?? 0 | |
| const url_openai_token = 'https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them' | |
| const options = computed(() => { | |
| const common = [ | |
| { | |
| label: t('chat.copy'), | |
| key: 'copyText', | |
| icon: iconRender({ icon: 'ri:file-copy-2-line' }), | |
| }, | |
| { | |
| label: t('common.delete'), | |
| key: 'delete', | |
| icon: iconRender({ icon: 'ri:delete-bin-line' }), | |
| }, | |
| ] | |
| if (!props.inversion) { | |
| common.unshift({ | |
| label: asRawText.value ? t('chat.preview') : t('chat.showRawText'), | |
| key: 'toggleRenderType', | |
| icon: iconRender({ icon: asRawText.value ? 'ic:outline-code-off' : 'ic:outline-code' }), | |
| }) | |
| } | |
| return common | |
| }) | |
| function handleSelect(key: 'copyText' | 'delete' | 'toggleRenderType') { | |
| switch (key) { | |
| case 'copyText': | |
| handleCopy() | |
| return | |
| case 'toggleRenderType': | |
| asRawText.value = !asRawText.value | |
| return | |
| case 'delete': | |
| emit('delete') | |
| } | |
| } | |
| function handleRegenerate() { | |
| messageRef.value?.scrollIntoView() | |
| emit('regenerate') | |
| } | |
| async function handleCopy() { | |
| try { | |
| await copyToClip(props.text || '') | |
| message.success('复制成功') | |
| } | |
| catch { | |
| message.error('复制失败') | |
| } | |
| } | |
| async function handlePreviousResponse(next: number) { | |
| if (indexRef.value + next < 1 || indexRef.value + next > props.responseCount!) | |
| return | |
| indexRef.value += next | |
| emit('responseHistory', indexRef.value - 1) | |
| } | |
| </script> | |
| <template> | |
| <div | |
| ref="messageRef" | |
| class="flex w-full mb-6 overflow-hidden" | |
| :class="[{ 'flex-row-reverse': inversion }]" | |
| > | |
| <div | |
| class="flex items-center justify-center flex-shrink-0 h-8 overflow-hidden rounded-full basis-8" | |
| :class="[inversion ? 'ml-2' : 'mr-2']" | |
| > | |
| <AvatarComponent :image="inversion" /> | |
| </div> | |
| <div class="overflow-hidden text-sm " :class="[inversion ? 'items-end' : 'items-start']"> | |
| <p v-if="inversion" class="text-xs text-[#b4bbc4]" :class="[inversion ? 'text-right' : 'text-left']"> | |
| {{ new Date(dateTime as string).toLocaleString() }} | |
| </p> | |
| <p v-else class="text-xs text-[#b4bbc4]" :class="[inversion ? 'text-right' : 'text-left']"> | |
| <NSpace> | |
| {{ new Date(dateTime as string).toLocaleString() }} | |
| <NButtonGroup v-if="!inversion && responseCount && responseCount > 1"> | |
| <NButton | |
| style="cursor: pointer;" | |
| :disabled="indexRef === 1" | |
| @click="handlePreviousResponse(-1)" | |
| > | |
| <svg stroke="currentColor" fill="none" stroke-width="1.5" viewBox="5 -5 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><polyline points="15 18 9 12 15 6" /></svg> | |
| </NButton> | |
| <span class="text-xs text-[#b4bbc4]"> {{ indexRef }} / {{ responseCount }}</span> | |
| <NButton | |
| style="cursor: pointer;" | |
| :disabled="indexRef === responseCount" | |
| @click="handlePreviousResponse(1)" | |
| > | |
| <svg stroke="currentColor" fill="none" stroke-width="1.5" viewBox="-5 -5 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><polyline points="9 18 15 12 9 6" /></svg> | |
| </NButton> | |
| </NButtonGroup> | |
| <template v-if="usage"> | |
| <NPopover trigger="hover"> | |
| <template #trigger> | |
| <span> | |
| <span>[</span> | |
| <span>{{ usage.estimated ? '~' : '' }}</span> | |
| <span>{{ usage.prompt_tokens }}+{{ usage.completion_tokens }}={{ usage.total_tokens }}</span> | |
| <span>]</span> | |
| </span> | |
| </template> | |
| <span class="text-xs"> | |
| {{ usage.estimated ? t('chat.usageEstimate') : '' }} | |
| {{ t('chat.usagePrompt') }} {{ usage.prompt_tokens }} | |
| + {{ t('chat.usageResponse') }} {{ usage.completion_tokens }} | |
| = {{ t('chat.usageTotal') }}<a :href="url_openai_token" target="_blank">(?)</a> | |
| {{ usage.total_tokens }} | |
| </span> | |
| </NPopover> | |
| </template> | |
| </NSpace> | |
| </p> | |
| <div | |
| class="flex items-end gap-1 mt-2" | |
| :class="[inversion ? 'flex-row-reverse' : 'flex-row']" | |
| > | |
| <TextComponent | |
| ref="textRef" | |
| :inversion="inversion" | |
| :error="error" | |
| :text="text" | |
| :loading="loading" | |
| :as-raw-text="asRawText" | |
| /> | |
| <div class="flex flex-col"> | |
| <button | |
| v-if="!inversion" | |
| class="mb-2 transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-300" | |
| @click="handleRegenerate" | |
| > | |
| <SvgIcon icon="ri:restart-line" /> | |
| </button> | |
| <NDropdown | |
| :trigger="isMobile ? 'click' : 'hover'" | |
| :placement="!inversion ? 'right' : 'left'" | |
| :options="options" | |
| @select="handleSelect" | |
| > | |
| <button class="transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-200"> | |
| <SvgIcon icon="ri:more-2-fill" /> | |
| </button> | |
| </NDropdown> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |