liushitest.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. <template>
  2. <div class="stream-test-container">
  3. <div class="test-form">
  4. <div class="input-group">
  5. <label for="message">消息内容:</label>
  6. <textarea
  7. id="message"
  8. v-model="message"
  9. placeholder="请输入要发送的消息...(按空格键发送)"
  10. rows="3"
  11. @keydown="handleKeyDown"
  12. ></textarea>
  13. </div>
  14. <div class="input-group">
  15. <label for="model">模型(可选):</label>
  16. <input
  17. id="model"
  18. v-model="model"
  19. placeholder="模型名称,留空使用默认"
  20. type="text"
  21. />
  22. </div>
  23. <div class="button-group">
  24. <button
  25. @click="startStream"
  26. :disabled="isStreaming || !message.trim()"
  27. class="start-btn"
  28. >
  29. {{ isStreaming ? '发送中...' : '开始流式+数据库测试' }}
  30. </button>
  31. <button
  32. @click="stopStream"
  33. :disabled="!isStreaming"
  34. class="stop-btn"
  35. >
  36. 停止
  37. </button>
  38. <button
  39. @click="clearResponse"
  40. class="clear-btn"
  41. >
  42. 清空响应
  43. </button>
  44. </div>
  45. </div>
  46. <div class="response-section">
  47. <div class="response-header">
  48. <h3>流式+数据库响应:</h3>
  49. <div class="status-indicator" :class="{ active: isStreaming }">
  50. {{ isStreaming ? '连接中...' : '已断开' }}
  51. </div>
  52. </div>
  53. <div class="response-content" ref="responseContainer">
  54. <div v-if="!responseContent && !isStreaming" class="empty-state">
  55. 暂无响应内容
  56. </div>
  57. <div v-else class="stream-content">
  58. <!-- 预览模式 -->
  59. <div class="formatted-content vditor-reset" v-html="formattedHtml"></div>
  60. <div v-if="isStreaming" class="typing-indicator">
  61. <span class="dot"></span>
  62. <span class="dot"></span>
  63. <span class="dot"></span>
  64. </div>
  65. </div>
  66. </div>
  67. <div v-if="dbInfo" class="db-info">
  68. <strong>数据库信息:</strong>
  69. <div>对话ID: {{ dbInfo.ai_conversation_id }}</div>
  70. <div>消息ID: {{ dbInfo.ai_message_id }}</div>
  71. </div>
  72. <div v-if="errorMessage" class="error-message">
  73. <strong>错误:</strong>{{ errorMessage }}
  74. </div>
  75. </div>
  76. </div>
  77. </template>
  78. <script>
  79. import request from '../request/axios.js'
  80. import { apis } from '../request/apis.js'
  81. import Vditor from 'vditor'
  82. import 'vditor/dist/index.css'
  83. export default {
  84. name: 'LiuShiTest',
  85. data() {
  86. return {
  87. message: '',
  88. model: '',
  89. isStreaming: false,
  90. responseContent: '',
  91. responseChunks: [],
  92. errorMessage: '',
  93. eventSource: null,
  94. buffer: '',
  95. formattedHtml: '',
  96. dbInfo: null
  97. }
  98. },
  99. watch: {
  100. responseContent: {
  101. handler(newContent) {
  102. if (newContent) {
  103. this.renderWithVditor(newContent)
  104. } else {
  105. this.formattedHtml = ''
  106. }
  107. },
  108. immediate: true
  109. }
  110. },
  111. methods: {
  112. // 开始流式请求
  113. async startStream() {
  114. if (!this.message.trim()) {
  115. this.errorMessage = '请输入消息内容'
  116. return
  117. }
  118. this.isStreaming = true
  119. this.responseContent = ''
  120. this.responseChunks = []
  121. this.errorMessage = ''
  122. try {
  123. // 使用流式+数据库集成接口
  124. const response = await fetch('http://127.0.0.1:22000/apiv1/stream/chat-with-db', {
  125. method: 'POST',
  126. headers: {
  127. 'Content-Type': 'application/json',
  128. },
  129. body: JSON.stringify({
  130. message: this.message,
  131. user_id: 598,
  132. ai_conversation_id: 0, // 0表示新建对话
  133. business_type: 0,
  134. exam_name: '流式测试',
  135. ai_message_id: 0
  136. })
  137. })
  138. if (!response.ok) {
  139. throw new Error(`HTTP错误: ${response.status}`)
  140. }
  141. // 使用ReadableStream处理流式响应
  142. const reader = response.body.getReader()
  143. const decoder = new TextDecoder('utf-8')
  144. while (true) {
  145. const { done, value } = await reader.read()
  146. if (done) {
  147. break
  148. }
  149. const chunk = decoder.decode(value, { stream: true })
  150. this.processStreamChunk(chunk)
  151. }
  152. } catch (error) {
  153. console.error('流式请求错误:', error)
  154. this.errorMessage = `请求失败: ${error.message}`
  155. } finally {
  156. this.isStreaming = false
  157. }
  158. },
  159. processStreamChunk(chunk) {
  160. // 调试:打印原始数据块
  161. console.log('原始数据块:', chunk)
  162. // 使用缓冲区处理跨chunk的数据
  163. if (!this.buffer) {
  164. this.buffer = ''
  165. }
  166. this.buffer += chunk
  167. // 处理完整的数据行(按换行符分割)
  168. const lines = this.buffer.split('\n')
  169. this.buffer = lines.pop() || '' // 保留最后一个不完整的行
  170. for (const line of lines) {
  171. if (line.trim() === '') continue
  172. console.log('收到数据行:', line)
  173. if (line.startsWith('data: ')) {
  174. const data = line.substring(6)
  175. if (data === '[DONE]') {
  176. console.log('收到结束信号 [DONE]')
  177. this.handleStreamEnd()
  178. return
  179. }
  180. try {
  181. // 尝试解析JSON数据
  182. const parsed = JSON.parse(data)
  183. // 处理初始响应(包含数据库ID)
  184. if (parsed.type === 'initial') {
  185. console.log('收到初始响应:', parsed)
  186. console.log('对话ID:', parsed.ai_conversation_id)
  187. console.log('消息ID:', parsed.ai_message_id)
  188. // 保存数据库信息到界面
  189. this.dbInfo = {
  190. ai_conversation_id: parsed.ai_conversation_id,
  191. ai_message_id: parsed.ai_message_id
  192. }
  193. continue
  194. }
  195. if (parsed.error) {
  196. this.errorMessage = parsed.error
  197. return
  198. }
  199. // 处理流式响应数据
  200. if (parsed.choices && parsed.choices.length > 0) {
  201. const choice = parsed.choices[0]
  202. console.log('解析的choice:', choice)
  203. if (choice.delta && choice.delta.content) {
  204. console.log('添加内容:', choice.delta.content)
  205. this.responseChunks.push(choice.delta.content)
  206. this.responseContent += choice.delta.content
  207. }
  208. // 检查是否完成
  209. if (choice.finish_reason) {
  210. console.log('收到完成信号:', choice.finish_reason)
  211. this.handleStreamEnd()
  212. break
  213. }
  214. } else {
  215. // JSON解析成功但没有choices,可能是纯数字或其他简单JSON值
  216. console.log('JSON解析成功但无choices,数据:', parsed)
  217. console.log('将作为文本内容处理:', String(parsed))
  218. // 将解析结果转换为字符串并添加
  219. const textContent = String(parsed)
  220. this.responseChunks.push(textContent)
  221. this.responseContent += textContent
  222. }
  223. } catch (e) {
  224. // 如果不是JSON格式,直接作为文本内容处理
  225. console.log('收到文本内容:', data)
  226. console.log('JSON解析失败,原因:', e.message)
  227. // 处理转义的换行符,将\n转换回真正的换行符
  228. const processedData = data.replace(/\\n/g, '\n')
  229. this.responseChunks.push(processedData)
  230. this.responseContent += processedData
  231. }
  232. }
  233. }
  234. },
  235. // 处理流式结束
  236. handleStreamEnd() {
  237. // 强制最终渲染,确保所有内容都被正确解析
  238. this.renderWithVditor(this.responseContent)
  239. console.log('流式响应结束,执行最终渲染')
  240. },
  241. renderWithVditor(content) {
  242. try {
  243. console.log('开始使用Vditor渲染,内容长度:', content.length)
  244. console.log('原始内容:', content)
  245. // 创建一个临时的DOM元素
  246. const tempDiv = document.createElement('div')
  247. tempDiv.style.display = 'none'
  248. document.body.appendChild(tempDiv)
  249. // 使用Vditor.preview方法渲染 - 简化配置
  250. Vditor.preview(tempDiv, content, {
  251. mode: 'light',
  252. markdown: {
  253. toc: false,
  254. mark: false,
  255. footnotes: false,
  256. autoSpace: false,
  257. fixTermTypo: false,
  258. chinesePunct: false,
  259. linkBase: '',
  260. linkPrefix: '',
  261. listStyle: false,
  262. paragraphBeginningSpace: false
  263. },
  264. theme: {
  265. current: 'light',
  266. path: 'https://cdn.jsdelivr.net/npm/vditor@3.10.9/dist/css/content-theme'
  267. },
  268. after: () => {
  269. // 获取Vditor渲染的结果并进行规范引用处理
  270. let html = tempDiv.innerHTML
  271. // 处理规范引用 - 将中括号内容转换为可点击的规范引用
  272. html = this.processStandardReferences(html)
  273. this.formattedHtml = html
  274. console.log('Vditor渲染完成,HTML长度:', this.formattedHtml.length)
  275. console.log('HTML预览:', this.formattedHtml.substring(0, 200) + '...')
  276. // 清理临时元素
  277. document.body.removeChild(tempDiv)
  278. // 等待DOM更新后绑定点击事件
  279. this.$nextTick(() => {
  280. this.bindStandardReferenceEvents()
  281. })
  282. }
  283. })
  284. } catch (error) {
  285. console.error('Vditor渲染错误:', error)
  286. // 渲染失败时使用简单HTML转换
  287. this.formattedHtml = content.replace(/\n/g, '<br>')
  288. }
  289. },
  290. // 处理规范引用 - 将中括号内容转换为可点击的规范引用
  291. processStandardReferences(html) {
  292. if (!html) return html
  293. console.log('开始处理规范引用,HTML长度:', html.length)
  294. // 处理中括号为可点击的标准引用/普通引用
  295. const processedHtml = html.replace(/\[([^\[\]]+)\]/g, (match, content) => {
  296. console.log('发现规范引用:', content)
  297. // 检查是否已经是处理过的规范引用
  298. if (/^<span\s+class="standard-reference"/i.test(content)) {
  299. return match
  300. }
  301. // 检查是否是标准格式:书名号+内容+括号+编号
  302. const standardMatch = content.match(/^([《「『【]?[\s\S]*?[》」』】]?)[\s]*\(([^)]+)\)$/)
  303. if (standardMatch) {
  304. const standardName = standardMatch[1]
  305. const standardNumber = standardMatch[2]
  306. console.log('标准格式规范:', { standardName, standardNumber })
  307. return `<span class="standard-reference" data-standard="${content}" data-name="${standardName}" data-number="${standardNumber}" title="点击查看标准详情" style="background-color: #EAEAEE; color: #616161; font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; cursor: pointer; display: inline-block; margin: 4px 2px; border: 1px solid #EAEAEE; font-weight: 500; transition: all 0.2s ease; line-height: 1.4;">${content}</span>`
  308. }
  309. // 普通引用格式
  310. console.log('普通格式规范:', content)
  311. return `<span class="standard-reference" data-reference="${content}" title="点击查看详情" style="background-color: #EAEAEE; color: #616161; font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; cursor: pointer; display: inline-block; margin: 4px 2px; border: 1px solid #EAEAEE; font-weight: 500; transition: all 0.2s ease; line-height: 1.4;">${content}</span>`
  312. })
  313. console.log('规范引用处理完成')
  314. return processedHtml
  315. },
  316. // 绑定规范引用点击事件
  317. bindStandardReferenceEvents() {
  318. const references = document.querySelectorAll('.standard-reference')
  319. console.log('找到规范引用元素数量:', references.length)
  320. references.forEach((ref, index) => {
  321. // 移除之前的事件监听器
  322. ref.removeEventListener('click', this.handleStandardReferenceClick)
  323. // 添加新的点击事件监听器
  324. ref.addEventListener('click', this.handleStandardReferenceClick)
  325. console.log(`绑定规范引用 ${index + 1}:`, ref.textContent)
  326. })
  327. },
  328. // 处理规范引用点击事件
  329. async handleStandardReferenceClick(event) {
  330. event.preventDefault()
  331. event.stopPropagation()
  332. const element = event.currentTarget
  333. const content = element.textContent
  334. const standardName = element.getAttribute('data-name')
  335. const standardNumber = element.getAttribute('data-number')
  336. const standardData = element.getAttribute('data-standard')
  337. const referenceData = element.getAttribute('data-reference')
  338. console.log('点击规范引用:', {
  339. content,
  340. standardName,
  341. standardNumber,
  342. standardData,
  343. referenceData
  344. })
  345. // 确定要查询的文件名
  346. let fileName = ''
  347. if (standardData) {
  348. fileName = standardData
  349. } else if (referenceData) {
  350. fileName = referenceData
  351. }
  352. if (fileName) {
  353. try {
  354. console.log('正在获取文件链接,文件名:', fileName)
  355. // 调用后端接口获取文件链接
  356. const response = await apis.getFileLink({ fileName })
  357. console.log('获取文件链接响应:', response)
  358. if (response.statusCode === 200 && response.data) {
  359. const fileLink = response.data
  360. console.log('获取到文件链接:', fileLink)
  361. // 如果有文件链接,打开预览
  362. if (fileLink) {
  363. // 在新窗口中打开文件链接
  364. window.open(fileLink, '_blank')
  365. console.log('文件已在新窗口中打开')
  366. } else {
  367. console.log('暂无文件')
  368. alert('暂无文件')
  369. }
  370. } else {
  371. console.log('暂无文件')
  372. alert('暂无文件')
  373. }
  374. } catch (error) {
  375. console.error('获取文件链接失败:', error)
  376. alert('获取文件失败,请稍后重试')
  377. }
  378. }
  379. },
  380. // 停止流式请求
  381. stopStream() {
  382. this.isStreaming = false
  383. if (this.eventSource) {
  384. this.eventSource.close()
  385. this.eventSource = null
  386. }
  387. },
  388. // 清空响应
  389. clearResponse() {
  390. this.responseContent = ''
  391. this.responseChunks = []
  392. this.formattedHtml = ''
  393. this.errorMessage = ''
  394. this.dbInfo = null
  395. },
  396. // 处理键盘事件
  397. handleKeyDown(event) {
  398. // 空格键发送
  399. if (event.code === 'Space' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
  400. // 阻止默认的空格输入行为
  401. event.preventDefault()
  402. // 检查是否可以发送
  403. if (!this.isStreaming && this.message.trim()) {
  404. this.startStream()
  405. }
  406. }
  407. }
  408. },
  409. async mounted() {
  410. // 组件挂载后初始化
  411. await this.$nextTick()
  412. },
  413. beforeUnmount() {
  414. // 清理资源
  415. if (this.eventSource) {
  416. this.eventSource.close()
  417. this.eventSource = null
  418. }
  419. }
  420. }
  421. </script>
  422. <style scoped>
  423. .stream-test-container {
  424. max-width: 1200px;
  425. margin: 0 auto;
  426. padding: 20px;
  427. font-family: Arial, sans-serif;
  428. }
  429. .test-form {
  430. background: #f8f9fa;
  431. padding: 20px;
  432. border-radius: 8px;
  433. margin-bottom: 20px;
  434. }
  435. .input-group {
  436. margin-bottom: 15px;
  437. }
  438. .input-group label {
  439. display: block;
  440. margin-bottom: 5px;
  441. font-weight: bold;
  442. color: #333;
  443. }
  444. .input-group textarea,
  445. .input-group input {
  446. width: 100%;
  447. padding: 10px;
  448. border: 1px solid #ddd;
  449. border-radius: 4px;
  450. font-size: 14px;
  451. box-sizing: border-box;
  452. }
  453. .input-group textarea {
  454. resize: vertical;
  455. min-height: 80px;
  456. }
  457. .button-group {
  458. display: flex;
  459. gap: 10px;
  460. flex-wrap: wrap;
  461. }
  462. .button-group button {
  463. padding: 10px 20px;
  464. border: none;
  465. border-radius: 4px;
  466. cursor: pointer;
  467. font-size: 14px;
  468. transition: background-color 0.3s;
  469. }
  470. .start-btn {
  471. background: #007bff;
  472. color: white;
  473. }
  474. .start-btn:hover:not(:disabled) {
  475. background: #0056b3;
  476. }
  477. .start-btn:disabled {
  478. background: #6c757d;
  479. cursor: not-allowed;
  480. }
  481. .stop-btn {
  482. background: #dc3545;
  483. color: white;
  484. }
  485. .stop-btn:hover:not(:disabled) {
  486. background: #c82333;
  487. }
  488. .stop-btn:disabled {
  489. background: #6c757d;
  490. cursor: not-allowed;
  491. }
  492. .clear-btn {
  493. background: #6c757d;
  494. color: white;
  495. }
  496. .clear-btn:hover {
  497. background: #545b62;
  498. }
  499. .response-section {
  500. background: white;
  501. border: 1px solid #ddd;
  502. border-radius: 8px;
  503. overflow: hidden;
  504. }
  505. .response-header {
  506. background: #f8f9fa;
  507. padding: 15px 20px;
  508. border-bottom: 1px solid #ddd;
  509. display: flex;
  510. justify-content: space-between;
  511. align-items: center;
  512. }
  513. .response-header h3 {
  514. margin: 0;
  515. color: #333;
  516. }
  517. .status-indicator {
  518. padding: 5px 10px;
  519. border-radius: 4px;
  520. font-size: 12px;
  521. background: #6c757d;
  522. color: white;
  523. }
  524. .status-indicator.active {
  525. background: #28a745;
  526. }
  527. .response-content {
  528. padding: 20px;
  529. min-height: 200px;
  530. max-height: 600px;
  531. overflow-y: auto;
  532. }
  533. .empty-state {
  534. text-align: center;
  535. color: #6c757d;
  536. font-style: italic;
  537. }
  538. .stream-content {
  539. line-height: 1.6;
  540. }
  541. .formatted-content {
  542. margin-bottom: 20px;
  543. }
  544. .typing-indicator {
  545. display: flex;
  546. align-items: center;
  547. gap: 4px;
  548. color: #6c757d;
  549. font-style: italic;
  550. }
  551. .dot {
  552. width: 6px;
  553. height: 6px;
  554. background: #6c757d;
  555. border-radius: 50%;
  556. animation: typing 1.4s infinite ease-in-out;
  557. }
  558. .dot:nth-child(2) {
  559. animation-delay: 0.2s;
  560. }
  561. .dot:nth-child(3) {
  562. animation-delay: 0.4s;
  563. }
  564. @keyframes typing {
  565. 0%, 60%, 100% {
  566. transform: translateY(0);
  567. opacity: 0.5;
  568. }
  569. 30% {
  570. transform: translateY(-8px);
  571. opacity: 1;
  572. }
  573. }
  574. .db-info {
  575. background: #d1ecf1;
  576. color: #0c5460;
  577. padding: 10px;
  578. border-radius: 4px;
  579. margin-top: 10px;
  580. border: 1px solid #bee5eb;
  581. }
  582. .db-info div {
  583. margin: 2px 0;
  584. }
  585. .error-message {
  586. background: #f8d7da;
  587. color: #721c24;
  588. padding: 10px;
  589. border-radius: 4px;
  590. margin-top: 10px;
  591. border: 1px solid #f5c6cb;
  592. }
  593. /* 规范引用样式 */
  594. :deep(.standard-reference) {
  595. background-color: #EAEAEE !important;
  596. color: #616161 !important;
  597. font-size: 0.75rem !important;
  598. padding: 3px 8px !important;
  599. border-radius: 6px !important;
  600. cursor: pointer !important;
  601. display: inline-block !important;
  602. margin: 4px 2px !important;
  603. border: 1px solid #EAEAEE !important;
  604. font-weight: 500 !important;
  605. transition: all 0.2s ease !important;
  606. line-height: 1.4 !important;
  607. }
  608. :deep(.standard-reference:hover) {
  609. background-color: #d1d5db !important;
  610. border-color: #d1d5db !important;
  611. }
  612. </style>