ChatGPT.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. <script setup lang="ts">
  2. import type { ChatComplicationMessage } from '@/api/openai'
  3. import Icon, { SendOutlined } from '@ant-design/icons-vue'
  4. import hljs from 'highlight.js'
  5. import nginx from 'highlight.js/lib/languages/nginx'
  6. import { Marked } from 'marked'
  7. import { markedHighlight } from 'marked-highlight'
  8. import { storeToRefs } from 'pinia'
  9. import openai from '@/api/openai'
  10. import ChatGPT_logo from '@/assets/svg/ChatGPT_logo.svg?component'
  11. import { urlJoin } from '@/lib/helper'
  12. import { useSettingsStore, useUserStore } from '@/pinia'
  13. import 'highlight.js/styles/vs2015.css'
  14. const props = defineProps<{
  15. content: string
  16. path?: string
  17. }>()
  18. hljs.registerLanguage('nginx', nginx)
  19. const { language: current } = storeToRefs(useSettingsStore())
  20. const messages = defineModel<ChatComplicationMessage[]>('historyMessages', {
  21. type: Array,
  22. default: reactive([]),
  23. })
  24. const loading = ref(false)
  25. const askBuffer = ref('')
  26. // Global buffer for accumulation
  27. let buffer = ''
  28. // Track last chunk to avoid immediate repeated content
  29. let lastChunkStr = ''
  30. // define a type for tracking code block state
  31. interface CodeBlockState {
  32. isInCodeBlock: boolean
  33. backtickCount: number
  34. }
  35. const codeBlockState: CodeBlockState = reactive({
  36. isInCodeBlock: false, // if in ``` code block
  37. backtickCount: 0, // count of ```
  38. })
  39. /**
  40. * transformReasonerThink: if <think> appears but is not paired with </think>, it will be automatically supplemented, and the entire text will be converted to a Markdown quote
  41. */
  42. function transformReasonerThink(rawText: string): string {
  43. // 1. Count number of <think> vs </think>
  44. const openThinkRegex = /<think>/gi
  45. const closeThinkRegex = /<\/think>/gi
  46. const openCount = (rawText.match(openThinkRegex) || []).length
  47. const closeCount = (rawText.match(closeThinkRegex) || []).length
  48. // 2. If open tags exceed close tags, append missing </think> at the end
  49. if (openCount > closeCount) {
  50. const diff = openCount - closeCount
  51. rawText += '</think>'.repeat(diff)
  52. }
  53. // 3. Replace <think>...</think> blocks with Markdown blockquote ("> ...")
  54. return rawText.replace(/<think>([\s\S]*?)<\/think>/g, (match, p1) => {
  55. // Split the inner text by line, prefix each with "> "
  56. const lines = p1.trim().split('\n')
  57. const blockquoted = lines.map(line => `> ${line}`).join('\n')
  58. // Return the replaced Markdown quote
  59. return `\n${blockquoted}\n`
  60. })
  61. }
  62. /**
  63. * transformText: transform the text
  64. */
  65. function transformText(rawText: string): string {
  66. return transformReasonerThink(rawText)
  67. }
  68. /**
  69. * scrollToBottom: Scroll container to bottom
  70. */
  71. function scrollToBottom() {
  72. const container = document.querySelector('.right-settings .ant-card-body')
  73. if (container)
  74. container.scrollTop = container.scrollHeight
  75. }
  76. /**
  77. * updateCodeBlockState: The number of unnecessary scans is reduced by changing the scanning method of incremental content
  78. */
  79. function updateCodeBlockState(chunk: string) {
  80. // count all ``` in chunk
  81. // note to distinguish how many "backticks" are not paired
  82. const regex = /```/g
  83. while (regex.exec(chunk) !== null) {
  84. codeBlockState.backtickCount++
  85. // if backtickCount is even -> closed
  86. codeBlockState.isInCodeBlock = codeBlockState.backtickCount % 2 !== 0
  87. }
  88. }
  89. /**
  90. * applyChunk: Process one SSE chunk and type out content character by character
  91. * @param input A chunk of data (Uint8Array) from SSE
  92. * @param targetMsg The assistant-type message object being updated
  93. */
  94. async function applyChunk(input: Uint8Array, targetMsg: ChatComplicationMessage) {
  95. const decoder = new TextDecoder('utf-8')
  96. const raw = decoder.decode(input)
  97. // SSE default split by segment
  98. const lines = raw.split('\n\n')
  99. for (const line of lines) {
  100. if (!line.startsWith('event:message\ndata:'))
  101. continue
  102. const dataStr = line.slice('event:message\ndata:'.length)
  103. if (!dataStr)
  104. continue
  105. const content = JSON.parse(dataStr).content as string
  106. if (!content || content.trim() === '')
  107. continue
  108. if (content === lastChunkStr)
  109. continue
  110. lastChunkStr = content
  111. // Only detect substrings
  112. // 1. This can be processed in batches according to actual needs, reducing the number of character processing times
  113. updateCodeBlockState(content)
  114. for (const c of content) {
  115. buffer += c
  116. // codeBlockState.isInCodeBlock check if in code block
  117. targetMsg.content = buffer
  118. await nextTick()
  119. await new Promise(resolve => setTimeout(resolve, 20))
  120. scrollToBottom()
  121. }
  122. }
  123. }
  124. /**
  125. * request: Send messages to server, receive SSE, and process by typing out chunk by chunk
  126. */
  127. async function request() {
  128. loading.value = true
  129. // Add an "assistant" message object
  130. const t = ref<ChatComplicationMessage>({
  131. role: 'assistant',
  132. content: '',
  133. })
  134. messages.value.push(t.value)
  135. // Reset buffer flags each time
  136. buffer = ''
  137. lastChunkStr = ''
  138. await nextTick()
  139. scrollToBottom()
  140. const user = useUserStore()
  141. const { token } = storeToRefs(user)
  142. const res = await fetch(urlJoin(window.location.pathname, '/api/chatgpt'), {
  143. method: 'POST',
  144. headers: {
  145. Accept: 'text/event-stream',
  146. Authorization: token.value,
  147. },
  148. body: JSON.stringify({
  149. filepath: props.path,
  150. messages: messages.value.slice(0, messages.value.length - 1),
  151. }),
  152. })
  153. if (!res.body) {
  154. loading.value = false
  155. return
  156. }
  157. const reader = res.body.getReader()
  158. while (true) {
  159. try {
  160. const { done, value } = await reader.read()
  161. if (done) {
  162. // SSE stream ended
  163. setTimeout(() => {
  164. scrollToBottom()
  165. }, 300)
  166. break
  167. }
  168. if (value) {
  169. // Process each chunk
  170. await applyChunk(value, t.value)
  171. }
  172. }
  173. catch {
  174. // In case of error
  175. break
  176. }
  177. }
  178. loading.value = false
  179. storeRecord()
  180. }
  181. /**
  182. * send: Add user message into messages then call request
  183. */
  184. async function send() {
  185. if (!messages.value)
  186. messages.value = []
  187. if (messages.value.length === 0) {
  188. // The first message
  189. messages.value = [{
  190. role: 'user',
  191. content: `${props.content}\n\nCurrent Language Code: ${current.value}`,
  192. }]
  193. }
  194. else {
  195. // Append user's new message
  196. messages.value.push({
  197. role: 'user',
  198. content: askBuffer.value,
  199. })
  200. askBuffer.value = ''
  201. }
  202. await nextTick()
  203. await request()
  204. }
  205. // Markdown renderer
  206. const marked = new Marked(
  207. markedHighlight({
  208. langPrefix: 'hljs language-',
  209. highlight(code, lang) {
  210. const language = hljs.getLanguage(lang) ? lang : 'nginx'
  211. return hljs.highlight(code, { language }).value
  212. },
  213. }),
  214. )
  215. // Basic marked options
  216. marked.setOptions({
  217. pedantic: false,
  218. gfm: true,
  219. breaks: false,
  220. })
  221. /**
  222. * storeRecord: Save chat history
  223. */
  224. function storeRecord() {
  225. openai.store_record({
  226. file_name: props.path,
  227. messages: messages.value,
  228. })
  229. }
  230. /**
  231. * clearRecord: Clears all messages
  232. */
  233. function clearRecord() {
  234. openai.store_record({
  235. file_name: props.path,
  236. messages: [],
  237. })
  238. messages.value = []
  239. }
  240. // Manage editing
  241. const editingIdx = ref(-1)
  242. /**
  243. * regenerate: Removes messages after index and re-request the answer
  244. */
  245. async function regenerate(index: number) {
  246. editingIdx.value = -1
  247. messages.value = messages.value.slice(0, index)
  248. await nextTick()
  249. await request()
  250. }
  251. /**
  252. * show: If empty, display start button
  253. */
  254. const show = computed(() => !messages.value || messages.value.length === 0)
  255. </script>
  256. <template>
  257. <div
  258. v-if="show"
  259. class="chat-start mt-4"
  260. >
  261. <AButton
  262. :loading="loading"
  263. @click="send"
  264. >
  265. <Icon
  266. v-if="!loading"
  267. :component="ChatGPT_logo"
  268. />
  269. {{ $gettext('Ask ChatGPT for Help') }}
  270. </AButton>
  271. </div>
  272. <div
  273. v-else
  274. class="chatgpt-container"
  275. >
  276. <AList
  277. class="chatgpt-log"
  278. item-layout="horizontal"
  279. :data-source="messages"
  280. >
  281. <template #renderItem="{ item, index }">
  282. <AListItem>
  283. <AComment :author="item.role === 'assistant' ? $gettext('Assistant') : $gettext('User')">
  284. <template #content>
  285. <div
  286. v-if="item.role === 'assistant' || editingIdx !== index"
  287. v-dompurify-html="marked.parse(transformText(item.content))"
  288. class="content"
  289. />
  290. <AInput
  291. v-else
  292. v-model:value="item.content"
  293. class="pa-0"
  294. :bordered="false"
  295. />
  296. </template>
  297. <template #actions>
  298. <span
  299. v-if="item.role === 'user' && editingIdx !== index"
  300. @click="editingIdx = index"
  301. >
  302. {{ $gettext('Modify') }}
  303. </span>
  304. <template v-else-if="editingIdx === index">
  305. <span @click="regenerate(index + 1)">{{ $gettext('Save') }}</span>
  306. <span @click="editingIdx = -1">{{ $gettext('Cancel') }}</span>
  307. </template>
  308. <span
  309. v-else-if="!loading"
  310. @click="regenerate(index)"
  311. >
  312. {{ $gettext('Reload') }}
  313. </span>
  314. </template>
  315. </AComment>
  316. </AListItem>
  317. </template>
  318. </AList>
  319. <div class="input-msg">
  320. <div class="control-btn">
  321. <ASpace v-show="!loading">
  322. <APopconfirm
  323. :cancel-text="$gettext('No')"
  324. :ok-text="$gettext('OK')"
  325. :title="$gettext('Are you sure you want to clear the record of chat?')"
  326. @confirm="clearRecord"
  327. >
  328. <AButton type="text">
  329. {{ $gettext('Clear') }}
  330. </AButton>
  331. </APopconfirm>
  332. <AButton
  333. type="text"
  334. @click="regenerate((messages?.length ?? 1) - 1)"
  335. >
  336. {{ $gettext('Regenerate response') }}
  337. </AButton>
  338. </ASpace>
  339. </div>
  340. <ATextarea
  341. v-model:value="askBuffer"
  342. auto-size
  343. />
  344. <div class="send-btn">
  345. <AButton
  346. size="small"
  347. type="text"
  348. :loading="loading"
  349. @click="send"
  350. >
  351. <SendOutlined />
  352. </AButton>
  353. </div>
  354. </div>
  355. </div>
  356. </template>
  357. <style lang="less" scoped>
  358. .chatgpt-container {
  359. margin: 0 auto;
  360. max-width: 800px;
  361. .chatgpt-log {
  362. .content {
  363. width: 100%;
  364. :deep(.hljs) {
  365. border-radius: 5px;
  366. }
  367. :deep(blockquote) {
  368. display: block;
  369. opacity: 0.6;
  370. margin: 0.5em 0;
  371. padding-left: 1em;
  372. border-left: 3px solid #ccc;
  373. }
  374. }
  375. :deep(.ant-list-item) {
  376. padding: 0;
  377. }
  378. :deep(.ant-comment-content) {
  379. width: 100%;
  380. }
  381. :deep(.ant-comment) {
  382. width: 100%;
  383. }
  384. :deep(.ant-comment-content-detail) {
  385. width: 100%;
  386. p {
  387. margin-bottom: 10px;
  388. }
  389. }
  390. :deep(.ant-list-item:first-child) {
  391. display: none;
  392. }
  393. }
  394. .input-msg {
  395. position: relative;
  396. .control-btn {
  397. display: flex;
  398. justify-content: center;
  399. }
  400. .send-btn {
  401. position: absolute;
  402. right: 0;
  403. bottom: 3px;
  404. }
  405. }
  406. }
  407. </style>