ChatMessage.vue 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. <script setup lang="ts">
  2. import type { ChatComplicationMessage } from '@/api/openai'
  3. import { useChatGPTStore } from './chatgpt'
  4. import { marked } from './markdown'
  5. import { transformText } from './utils'
  6. interface Props {
  7. message: ChatComplicationMessage
  8. index: number
  9. isEditing: boolean
  10. loading: boolean
  11. editValue: string
  12. }
  13. const props = defineProps<Props>()
  14. defineEmits<{
  15. edit: [index: number]
  16. save: [index: number]
  17. cancel: []
  18. regenerate: [index: number]
  19. }>()
  20. const chatGPTStore = useChatGPTStore()
  21. const { streamingMessageIndex } = storeToRefs(chatGPTStore)
  22. function updateEditValue(value: string) {
  23. chatGPTStore.editValue = value
  24. }
  25. // Typewriter effect state
  26. const displayText = ref('')
  27. const isTyping = ref(false)
  28. const animationFrame = ref<number | null>(null)
  29. // Cache for transformed content to avoid re-processing
  30. let lastRawContent = ''
  31. let lastTransformedContent = ''
  32. // Get transformed content with caching
  33. function getTransformedContent(content: string): string {
  34. if (content === lastRawContent) {
  35. return lastTransformedContent
  36. }
  37. lastRawContent = content
  38. lastTransformedContent = transformText(content)
  39. return lastTransformedContent
  40. }
  41. // Check if current message should use typewriter effect
  42. const shouldUseTypewriter = computed(() => {
  43. return props.message.role === 'assistant'
  44. && !props.isEditing
  45. && streamingMessageIndex.value === props.index
  46. })
  47. // High-performance typewriter animation using RAF
  48. function startTypewriterAnimation(targetContent: string) {
  49. if (animationFrame.value) {
  50. cancelAnimationFrame(animationFrame.value)
  51. }
  52. const transformedContent = getTransformedContent(targetContent)
  53. // Skip if content hasn't changed
  54. if (displayText.value === transformedContent) {
  55. isTyping.value = false
  56. return
  57. }
  58. // Start from current display text length
  59. const startLength = displayText.value.length
  60. const targetLength = transformedContent.length
  61. // If content is shorter (like editing), immediately set to target
  62. if (targetLength < startLength) {
  63. displayText.value = transformedContent
  64. isTyping.value = false
  65. return
  66. }
  67. isTyping.value = true
  68. let currentIndex = startLength
  69. let lastTime = performance.now()
  70. // Characters per second (adjustable for speed)
  71. const charactersPerSecond = 120 // Similar to VScode speed
  72. const msPerCharacter = 1000 / charactersPerSecond
  73. function animate(currentTime: number) {
  74. const deltaTime = currentTime - lastTime
  75. // Check if enough time has passed to show next character(s)
  76. if (deltaTime >= msPerCharacter) {
  77. // Calculate how many characters to show based on elapsed time
  78. const charactersToAdd = Math.floor(deltaTime / msPerCharacter)
  79. currentIndex = Math.min(currentIndex + charactersToAdd, targetLength)
  80. displayText.value = transformedContent.substring(0, currentIndex)
  81. lastTime = currentTime
  82. // Check if we've reached the end
  83. if (currentIndex >= targetLength) {
  84. isTyping.value = false
  85. animationFrame.value = null
  86. return
  87. }
  88. }
  89. // Continue animation
  90. animationFrame.value = requestAnimationFrame(animate)
  91. }
  92. // Start the animation
  93. animationFrame.value = requestAnimationFrame(animate)
  94. }
  95. // Stop animation when component unmounts
  96. onUnmounted(() => {
  97. if (animationFrame.value) {
  98. cancelAnimationFrame(animationFrame.value)
  99. }
  100. })
  101. // Watch for content changes
  102. watch(
  103. () => props.message.content,
  104. newContent => {
  105. if (shouldUseTypewriter.value) {
  106. // Only use typewriter effect for streaming messages
  107. startTypewriterAnimation(newContent)
  108. }
  109. else {
  110. // For user messages, non-streaming messages, or when editing, show immediately
  111. displayText.value = getTransformedContent(newContent)
  112. isTyping.value = false
  113. }
  114. },
  115. { immediate: true },
  116. )
  117. // Watch for streaming state changes
  118. watch(
  119. shouldUseTypewriter,
  120. newValue => {
  121. if (!newValue) {
  122. // If no longer streaming, immediately show full content
  123. displayText.value = getTransformedContent(props.message.content)
  124. isTyping.value = false
  125. if (animationFrame.value) {
  126. cancelAnimationFrame(animationFrame.value)
  127. animationFrame.value = null
  128. }
  129. }
  130. },
  131. )
  132. // Reset when switching between messages
  133. watch(
  134. () => [props.index, props.isEditing],
  135. () => {
  136. if (!shouldUseTypewriter.value) {
  137. displayText.value = getTransformedContent(props.message.content)
  138. isTyping.value = false
  139. if (animationFrame.value) {
  140. cancelAnimationFrame(animationFrame.value)
  141. animationFrame.value = null
  142. }
  143. }
  144. },
  145. )
  146. // Initialize display text
  147. onMounted(() => {
  148. if (shouldUseTypewriter.value) {
  149. displayText.value = ''
  150. startTypewriterAnimation(props.message.content)
  151. }
  152. else {
  153. displayText.value = getTransformedContent(props.message.content)
  154. }
  155. })
  156. </script>
  157. <template>
  158. <AListItem>
  159. <AComment :author="message.role === 'assistant' ? $gettext('Assistant') : $gettext('User')">
  160. <template #content>
  161. <div
  162. v-if="message.role === 'assistant' || !isEditing"
  163. class="content"
  164. :class="{ typing: isTyping }"
  165. >
  166. <div
  167. v-dompurify-html="marked.parse(displayText)"
  168. class="message-content"
  169. />
  170. </div>
  171. <AInput
  172. v-else
  173. :value="editValue"
  174. class="pa-0"
  175. :bordered="false"
  176. @update:value="updateEditValue"
  177. />
  178. </template>
  179. <template #actions>
  180. <span
  181. v-if="message.role === 'user' && !isEditing"
  182. @click="$emit('edit', index)"
  183. >
  184. {{ $gettext('Modify') }}
  185. </span>
  186. <template v-else-if="isEditing">
  187. <span @click="$emit('save', index + 1)">{{ $gettext('Save') }}</span>
  188. <span @click="$emit('cancel')">{{ $gettext('Cancel') }}</span>
  189. </template>
  190. <span
  191. v-else-if="!loading"
  192. @click="$emit('regenerate', index)"
  193. >
  194. {{ $gettext('Reload') }}
  195. </span>
  196. </template>
  197. </AComment>
  198. </AListItem>
  199. </template>
  200. <style lang="less" scoped>
  201. .content {
  202. width: 100%;
  203. position: relative;
  204. .message-content {
  205. width: 100%;
  206. }
  207. &.typing {
  208. .message-content {
  209. // Very subtle glow during typing
  210. animation: typing-glow 3s ease-in-out infinite;
  211. }
  212. }
  213. :deep(code) {
  214. font-size: 12px;
  215. }
  216. :deep(.hljs) {
  217. border-radius: 5px;
  218. }
  219. :deep(blockquote) {
  220. display: block;
  221. opacity: 0.6;
  222. margin: 0.5em 0;
  223. padding-left: 1em;
  224. border-left: 3px solid #ccc;
  225. }
  226. }
  227. @keyframes typing-glow {
  228. 0%, 100% {
  229. filter: brightness(1) contrast(1);
  230. }
  231. 50% {
  232. filter: brightness(1.01) contrast(1.01);
  233. }
  234. }
  235. // Dark mode adjustments (if applicable)
  236. @media (prefers-color-scheme: dark) {
  237. .content {
  238. .typing-indicator {
  239. background-color: #40a9ff;
  240. }
  241. &.typing .message-content {
  242. animation: typing-glow-dark 3s ease-in-out infinite;
  243. }
  244. }
  245. }
  246. @keyframes typing-glow-dark {
  247. 0%, 100% {
  248. filter: brightness(1) contrast(1);
  249. }
  250. 50% {
  251. filter: brightness(1.05) contrast(1.02);
  252. }
  253. }
  254. </style>