| 1 | <template> |
| 2 | <div |
| 3 | class="group flex flex-col gap-0.5" |
| 4 | :class="{ |
| 5 | 'items-end text-right': entity.sender === 'user' |
| 6 | }" |
| 7 | > |
| 8 | <template v-if="entity.sender === 'server'"> |
| 9 | <div class="text-secondary inline-flex items-center gap-1 text-sm font-semibold"> |
| 10 | <span>{{ entity.server }}:</span> |
| 11 | |
| 12 | <span |
| 13 | v-if="thought && isThoughtVisible" |
| 14 | class="text-tertiary inline-flex cursor-pointer items-center gap-1" |
| 15 | @click="isThoughtCollapsed = !isThoughtCollapsed" |
| 16 | > |
| 17 | {{ isThinking ? "thinking..." : "thought" }} |
| 18 | |
| 19 | <Icon |
| 20 | name="carbon:chevron-right" |
| 21 | :size="14" |
| 22 | class="transition-transform" |
| 23 | :class="{ 'rotate-90': !isThoughtCollapsed }" |
| 24 | /> |
| 25 | </span> |
| 26 | |
| 27 | <div |
| 28 | v-if="isCopySupported" |
| 29 | class="pointer-events-none ml-1 flex items-center opacity-0 transition-opacity duration-300 group-hover:pointer-events-auto group-hover:opacity-100" |
| 30 | > |
| 31 | <n-tooltip> |
| 32 | <template #trigger> |
| 33 | <n-button size="tiny" text @click="copyLink()"> |
| 34 | <template #icon> |
| 35 | <Icon name="carbon:copy" :size="13" /> |
| 36 | </template> |
| 37 | </n-button> |
| 38 | </template> |
| 39 | <div class="text-xs">{{ showCopyTooltip ? "Copied!" : "Copy" }}</div> |
| 40 | </n-tooltip> |
| 41 | </div> |
| 42 | </div> |
| 43 | |
| 44 | <div v-if="thought && isThoughtVisible"> |
| 45 | <CollapseKeepAlive :show="!isThoughtCollapsed"> |
| 46 | <div class="text-secondary bg-secondary mb-2 rounded-md p-2 **:text-[10px]"> |
| 47 | <Markdown :source="thought" class="animate-fade overflow-hidden" /> |
| 48 | </div> |
| 49 | </CollapseKeepAlive> |
| 50 | </div> |
| 51 | <div v-if="body && isBodyVisible" class="**:text-default **:text-sm [&_*:last-child]:mb-0!"> |
| 52 | <Markdown :source="body" class="animate-fade overflow-hidden" /> |
| 53 | </div> |
| 54 | </template> |
| 55 | |
| 56 | <template v-if="entity.sender === 'user'"> |
| 57 | <div class="flex items-end justify-end gap-3 pl-6"> |
| 58 | <div |
| 59 | class="pointer-events-none mb-1 flex items-center gap-3 opacity-0 transition-opacity duration-300 group-hover:pointer-events-auto group-hover:opacity-100" |
| 60 | > |
| 61 | <div v-if="isCopySupported" class="flex items-center"> |
| 62 | <n-tooltip> |
| 63 | <template #trigger> |
| 64 | <n-button size="tiny" text @click="copyLink()"> |
| 65 | <template #icon> |
| 66 | <Icon name="carbon:copy" :size="13" /> |
| 67 | </template> |
| 68 | </n-button> |
| 69 | </template> |
| 70 | <div class="text-xs">{{ showCopyTooltip ? "Copied!" : "Copy" }}</div> |
| 71 | </n-tooltip> |
| 72 | </div> |
| 73 | <div class="flex items-center"> |
| 74 | <n-tooltip> |
| 75 | <template #trigger> |
| 76 | <n-button size="tiny" text @click="handleEdit()"> |
| 77 | <template #icon> |
| 78 | <Icon name="carbon:edit" :size="13" /> |
| 79 | </template> |
| 80 | </n-button> |
| 81 | </template> |
| 82 | <div class="text-xs">Edit</div> |
| 83 | </n-tooltip> |
| 84 | </div> |
| 85 | </div> |
| 86 | <div class="bg-secondary **:text-default rounded-lg px-2 py-1 text-sm [&_*:last-child]:mb-0!"> |
| 87 | <Markdown :source="entity.body" class="animate-fade overflow-hidden" /> |
| 88 | </div> |
| 89 | </div> |
| 90 | </template> |
| 91 | |
| 92 | <div class="text-tertiary mt-1 text-xs"> |
| 93 | {{ formatDate(entity.datetime, dFormats.datetime) }} |
| 94 | </div> |
| 95 | </div> |
| 96 | </template> |
| 97 | |
| 98 | <script setup lang="ts"> |
| 99 | import type { Ref } from "vue" |
| 100 | import { useClipboard } from "@vueuse/core" |
| 101 | import { NButton, NTooltip } from "naive-ui" |
| 102 | import { onMounted, ref } from "vue" |
| 103 | import CollapseKeepAlive from "@/components/common/CollapseKeepAlive.vue" |
| 104 | import Icon from "@/components/common/Icon.vue" |
| 105 | import Markdown from "@/components/common/Markdown.vue" |
| 106 | import { useSettingsStore } from "@/stores/settings" |
| 107 | import { formatDate } from "@/utils/format" |
| 108 | |
| 109 | export interface ChatBubble { |
| 110 | id: string |
| 111 | datetime: Date |
| 112 | body: string |
| 113 | thought?: string |
| 114 | server: string |
| 115 | new?: boolean |
| 116 | sender: "user" | "server" |
| 117 | } |
| 118 | |
| 119 | const { entity } = defineProps<{ entity: ChatBubble }>() |
| 120 | |
| 121 | const emit = defineEmits<{ |
| 122 | (e: "update"): void |
| 123 | (e: "edit"): void |
| 124 | }>() |
| 125 | |
| 126 | const dFormats = useSettingsStore().dateFormat |
| 127 | const { copy: copyLink, copied: showCopyTooltip, isSupported: isCopySupported } = useClipboard({ source: entity.body }) |
| 128 | |
| 129 | const isThinking = ref(false) |
| 130 | const isThoughtCollapsed = ref(false) |
| 131 | const isThoughtVisible = ref(false) |
| 132 | const isBodyVisible = ref(false) |
| 133 | |
| 134 | const thought = ref("") |
| 135 | const body = ref("") |
| 136 | |
| 137 | function startTypingEffect(text: string, variable: Ref<string>, duration = 2000) { |
| 138 | variable.value = "" |
| 139 | |
| 140 | const totalChars = text.length |
| 141 | if (totalChars === 0) return |
| 142 | |
| 143 | const delay = duration / totalChars |
| 144 | let currentIndex = 0 |
| 145 | |
| 146 | const interval = setInterval(() => { |
| 147 | variable.value += text[currentIndex] |
| 148 | currentIndex++ |
| 149 | |
| 150 | if (currentIndex >= totalChars) { |
| 151 | clearInterval(interval) |
| 152 | emit("update") |
| 153 | } |
| 154 | }, delay) |
| 155 | } |
| 156 | |
| 157 | function setAnimation() { |
| 158 | if (entity.new) { |
| 159 | if (entity.thought) { |
| 160 | isThinking.value = true |
| 161 | isThoughtVisible.value = true |
| 162 | startTypingEffect(entity.thought, thought, 2000) |
| 163 | setTimeout(() => { |
| 164 | isThinking.value = false |
| 165 | isBodyVisible.value = true |
| 166 | startTypingEffect(entity.body, body, 4000) |
| 167 | }, 2000) |
| 168 | } else { |
| 169 | isBodyVisible.value = true |
| 170 | startTypingEffect(entity.body, body, 4000) |
| 171 | } |
| 172 | } else { |
| 173 | if (entity.thought) { |
| 174 | isThoughtCollapsed.value = true |
| 175 | isThoughtVisible.value = true |
| 176 | thought.value = entity.thought |
| 177 | } |
| 178 | |
| 179 | isBodyVisible.value = true |
| 180 | body.value = entity.body |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | function handleEdit() { |
| 185 | emit("edit") |
| 186 | } |
| 187 | |
| 188 | onMounted(() => { |
| 189 | setAnimation() |
| 190 | }) |
| 191 | </script> |