| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714 |
- <template>
- <div class="stream-test-container">
- <div class="test-form">
- <div class="input-group">
- <label for="message">消息内容:</label>
- <textarea
- id="message"
- v-model="message"
- placeholder="请输入要发送的消息...(按空格键发送)"
- rows="3"
- @keydown="handleKeyDown"
- ></textarea>
- </div>
-
- <div class="input-group">
- <label for="model">模型(可选):</label>
- <input
- id="model"
- v-model="model"
- placeholder="模型名称,留空使用默认"
- type="text"
- />
- </div>
-
- <div class="button-group">
- <button
- @click="startStream"
- :disabled="isStreaming || !message.trim()"
- class="start-btn"
- >
- {{ isStreaming ? '发送中...' : '开始流式+数据库测试' }}
- </button>
- <button
- @click="stopStream"
- :disabled="!isStreaming"
- class="stop-btn"
- >
- 停止
- </button>
- <button
- @click="clearResponse"
- class="clear-btn"
- >
- 清空响应
- </button>
- </div>
- </div>
-
- <div class="response-section">
- <div class="response-header">
- <h3>流式+数据库响应:</h3>
- <div class="status-indicator" :class="{ active: isStreaming }">
- {{ isStreaming ? '连接中...' : '已断开' }}
- </div>
- </div>
-
- <div class="response-content" ref="responseContainer">
- <div v-if="!responseContent && !isStreaming" class="empty-state">
- 暂无响应内容
- </div>
- <div v-else class="stream-content">
- <!-- 预览模式 -->
- <div class="formatted-content vditor-reset" v-html="formattedHtml"></div>
-
- <div v-if="isStreaming" class="typing-indicator">
- <span class="dot"></span>
- <span class="dot"></span>
- <span class="dot"></span>
- </div>
- </div>
- </div>
-
- <div v-if="dbInfo" class="db-info">
- <strong>数据库信息:</strong>
- <div>对话ID: {{ dbInfo.ai_conversation_id }}</div>
- <div>消息ID: {{ dbInfo.ai_message_id }}</div>
- </div>
-
- <div v-if="errorMessage" class="error-message">
- <strong>错误:</strong>{{ errorMessage }}
- </div>
- </div>
- </div>
- </template>
- <script>
- import request from '../request/axios.js'
- import { apis } from '../request/apis.js'
- import Vditor from 'vditor'
- import 'vditor/dist/index.css'
- export default {
- name: 'LiuShiTest',
- data() {
- return {
- message: '',
- model: '',
- isStreaming: false,
- responseContent: '',
- responseChunks: [],
- errorMessage: '',
- eventSource: null,
- buffer: '',
- formattedHtml: '',
- dbInfo: null
- }
- },
- watch: {
- responseContent: {
- handler(newContent) {
- if (newContent) {
- this.renderWithVditor(newContent)
- } else {
- this.formattedHtml = ''
- }
- },
- immediate: true
- }
- },
- methods: {
- // 开始流式请求
- async startStream() {
- if (!this.message.trim()) {
- this.errorMessage = '请输入消息内容'
- return
- }
-
- this.isStreaming = true
- this.responseContent = ''
- this.responseChunks = []
- this.errorMessage = ''
-
- try {
- // 使用流式+数据库集成接口
- const response = await fetch('http://127.0.0.1:22000/apiv1/stream/chat-with-db', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- message: this.message,
- user_id: 598,
- ai_conversation_id: 0, // 0表示新建对话
- business_type: 0,
- exam_name: '流式测试',
- ai_message_id: 0
- })
- })
-
- if (!response.ok) {
- throw new Error(`HTTP错误: ${response.status}`)
- }
-
- // 使用ReadableStream处理流式响应
- const reader = response.body.getReader()
- const decoder = new TextDecoder('utf-8')
-
- while (true) {
- const { done, value } = await reader.read()
-
- if (done) {
- break
- }
-
- const chunk = decoder.decode(value, { stream: true })
- this.processStreamChunk(chunk)
- }
-
- } catch (error) {
- console.error('流式请求错误:', error)
- this.errorMessage = `请求失败: ${error.message}`
- } finally {
- this.isStreaming = false
- }
- },
-
- processStreamChunk(chunk) {
- // 调试:打印原始数据块
- console.log('原始数据块:', chunk)
-
- // 使用缓冲区处理跨chunk的数据
- if (!this.buffer) {
- this.buffer = ''
- }
-
- this.buffer += chunk
-
- // 处理完整的数据行(按换行符分割)
- const lines = this.buffer.split('\n')
- this.buffer = lines.pop() || '' // 保留最后一个不完整的行
-
- for (const line of lines) {
- if (line.trim() === '') continue
-
- console.log('收到数据行:', line)
-
- if (line.startsWith('data: ')) {
- const data = line.substring(6)
-
- if (data === '[DONE]') {
- console.log('收到结束信号 [DONE]')
- this.handleStreamEnd()
- return
- }
-
- try {
- // 尝试解析JSON数据
- const parsed = JSON.parse(data)
-
- // 处理初始响应(包含数据库ID)
- if (parsed.type === 'initial') {
- console.log('收到初始响应:', parsed)
- console.log('对话ID:', parsed.ai_conversation_id)
- console.log('消息ID:', parsed.ai_message_id)
- // 保存数据库信息到界面
- this.dbInfo = {
- ai_conversation_id: parsed.ai_conversation_id,
- ai_message_id: parsed.ai_message_id
- }
- continue
- }
-
- if (parsed.error) {
- this.errorMessage = parsed.error
- return
- }
-
- // 处理流式响应数据
- if (parsed.choices && parsed.choices.length > 0) {
- const choice = parsed.choices[0]
- console.log('解析的choice:', choice)
- if (choice.delta && choice.delta.content) {
- console.log('添加内容:', choice.delta.content)
- this.responseChunks.push(choice.delta.content)
- this.responseContent += choice.delta.content
- }
-
- // 检查是否完成
- if (choice.finish_reason) {
- console.log('收到完成信号:', choice.finish_reason)
- this.handleStreamEnd()
- break
- }
- } else {
- // JSON解析成功但没有choices,可能是纯数字或其他简单JSON值
- console.log('JSON解析成功但无choices,数据:', parsed)
- console.log('将作为文本内容处理:', String(parsed))
-
- // 将解析结果转换为字符串并添加
- const textContent = String(parsed)
- this.responseChunks.push(textContent)
- this.responseContent += textContent
- }
- } catch (e) {
- // 如果不是JSON格式,直接作为文本内容处理
- console.log('收到文本内容:', data)
- console.log('JSON解析失败,原因:', e.message)
-
- // 处理转义的换行符,将\n转换回真正的换行符
- const processedData = data.replace(/\\n/g, '\n')
-
- this.responseChunks.push(processedData)
- this.responseContent += processedData
- }
- }
- }
- },
-
- // 处理流式结束
- handleStreamEnd() {
- // 强制最终渲染,确保所有内容都被正确解析
- this.renderWithVditor(this.responseContent)
- console.log('流式响应结束,执行最终渲染')
- },
- renderWithVditor(content) {
- try {
- console.log('开始使用Vditor渲染,内容长度:', content.length)
- console.log('原始内容:', content)
-
- // 创建一个临时的DOM元素
- const tempDiv = document.createElement('div')
- tempDiv.style.display = 'none'
- document.body.appendChild(tempDiv)
-
- // 使用Vditor.preview方法渲染 - 简化配置
- Vditor.preview(tempDiv, content, {
- mode: 'light',
- markdown: {
- toc: false,
- mark: false,
- footnotes: false,
- autoSpace: false,
- fixTermTypo: false,
- chinesePunct: false,
- linkBase: '',
- linkPrefix: '',
- listStyle: false,
- paragraphBeginningSpace: false
- },
- theme: {
- current: 'light',
- path: 'https://cdn.jsdelivr.net/npm/vditor@3.10.9/dist/css/content-theme'
- },
- after: () => {
- // 获取Vditor渲染的结果并进行规范引用处理
- let html = tempDiv.innerHTML
-
- // 处理规范引用 - 将中括号内容转换为可点击的规范引用
- html = this.processStandardReferences(html)
-
- this.formattedHtml = html
- console.log('Vditor渲染完成,HTML长度:', this.formattedHtml.length)
- console.log('HTML预览:', this.formattedHtml.substring(0, 200) + '...')
-
- // 清理临时元素
- document.body.removeChild(tempDiv)
-
- // 等待DOM更新后绑定点击事件
- this.$nextTick(() => {
- this.bindStandardReferenceEvents()
- })
- }
- })
-
- } catch (error) {
- console.error('Vditor渲染错误:', error)
- // 渲染失败时使用简单HTML转换
- this.formattedHtml = content.replace(/\n/g, '<br>')
- }
- },
-
- // 处理规范引用 - 将中括号内容转换为可点击的规范引用
- processStandardReferences(html) {
- if (!html) return html
-
- console.log('开始处理规范引用,HTML长度:', html.length)
-
- // 处理中括号为可点击的标准引用/普通引用
- const processedHtml = html.replace(/\[([^\[\]]+)\]/g, (match, content) => {
- console.log('发现规范引用:', content)
-
- // 检查是否已经是处理过的规范引用
- if (/^<span\s+class="standard-reference"/i.test(content)) {
- return match
- }
-
- // 检查是否是标准格式:书名号+内容+括号+编号
- const standardMatch = content.match(/^([《「『【]?[\s\S]*?[》」』】]?)[\s]*\(([^)]+)\)$/)
- if (standardMatch) {
- const standardName = standardMatch[1]
- const standardNumber = standardMatch[2]
- console.log('标准格式规范:', { standardName, standardNumber })
- 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>`
- }
-
- // 普通引用格式
- console.log('普通格式规范:', content)
- 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>`
- })
-
- console.log('规范引用处理完成')
- return processedHtml
- },
-
- // 绑定规范引用点击事件
- bindStandardReferenceEvents() {
- const references = document.querySelectorAll('.standard-reference')
- console.log('找到规范引用元素数量:', references.length)
-
- references.forEach((ref, index) => {
- // 移除之前的事件监听器
- ref.removeEventListener('click', this.handleStandardReferenceClick)
-
- // 添加新的点击事件监听器
- ref.addEventListener('click', this.handleStandardReferenceClick)
-
- console.log(`绑定规范引用 ${index + 1}:`, ref.textContent)
- })
- },
-
- // 处理规范引用点击事件
- async handleStandardReferenceClick(event) {
- event.preventDefault()
- event.stopPropagation()
-
- const element = event.currentTarget
- const content = element.textContent
- const standardName = element.getAttribute('data-name')
- const standardNumber = element.getAttribute('data-number')
- const standardData = element.getAttribute('data-standard')
- const referenceData = element.getAttribute('data-reference')
-
- console.log('点击规范引用:', {
- content,
- standardName,
- standardNumber,
- standardData,
- referenceData
- })
-
- // 确定要查询的文件名
- let fileName = ''
- if (standardData) {
- fileName = standardData
- } else if (referenceData) {
- fileName = referenceData
- }
-
- if (fileName) {
- try {
- console.log('正在获取文件链接,文件名:', fileName)
-
- // 调用后端接口获取文件链接
- const response = await apis.getFileLink({ fileName })
- console.log('获取文件链接响应:', response)
-
- if (response.statusCode === 200 && response.data) {
- const fileLink = response.data
- console.log('获取到文件链接:', fileLink)
-
- // 如果有文件链接,打开预览
- if (fileLink) {
- // 在新窗口中打开文件链接
- window.open(fileLink, '_blank')
- console.log('文件已在新窗口中打开')
- } else {
- console.log('暂无文件')
- alert('暂无文件')
- }
- } else {
- console.log('暂无文件')
- alert('暂无文件')
- }
- } catch (error) {
- console.error('获取文件链接失败:', error)
- alert('获取文件失败,请稍后重试')
- }
- }
- },
-
- // 停止流式请求
- stopStream() {
- this.isStreaming = false
- if (this.eventSource) {
- this.eventSource.close()
- this.eventSource = null
- }
- },
-
- // 清空响应
- clearResponse() {
- this.responseContent = ''
- this.responseChunks = []
- this.formattedHtml = ''
- this.errorMessage = ''
- this.dbInfo = null
- },
-
- // 处理键盘事件
- handleKeyDown(event) {
- // 空格键发送
- if (event.code === 'Space' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
- // 阻止默认的空格输入行为
- event.preventDefault()
-
- // 检查是否可以发送
- if (!this.isStreaming && this.message.trim()) {
- this.startStream()
- }
- }
- }
- },
-
- async mounted() {
- // 组件挂载后初始化
- await this.$nextTick()
- },
-
- beforeUnmount() {
- // 清理资源
- if (this.eventSource) {
- this.eventSource.close()
- this.eventSource = null
- }
- }
- }
- </script>
- <style scoped>
- .stream-test-container {
- max-width: 1200px;
- margin: 0 auto;
- padding: 20px;
- font-family: Arial, sans-serif;
- }
- .test-form {
- background: #f8f9fa;
- padding: 20px;
- border-radius: 8px;
- margin-bottom: 20px;
- }
- .input-group {
- margin-bottom: 15px;
- }
- .input-group label {
- display: block;
- margin-bottom: 5px;
- font-weight: bold;
- color: #333;
- }
- .input-group textarea,
- .input-group input {
- width: 100%;
- padding: 10px;
- border: 1px solid #ddd;
- border-radius: 4px;
- font-size: 14px;
- box-sizing: border-box;
- }
- .input-group textarea {
- resize: vertical;
- min-height: 80px;
- }
- .button-group {
- display: flex;
- gap: 10px;
- flex-wrap: wrap;
- }
- .button-group button {
- padding: 10px 20px;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- font-size: 14px;
- transition: background-color 0.3s;
- }
- .start-btn {
- background: #007bff;
- color: white;
- }
- .start-btn:hover:not(:disabled) {
- background: #0056b3;
- }
- .start-btn:disabled {
- background: #6c757d;
- cursor: not-allowed;
- }
- .stop-btn {
- background: #dc3545;
- color: white;
- }
- .stop-btn:hover:not(:disabled) {
- background: #c82333;
- }
- .stop-btn:disabled {
- background: #6c757d;
- cursor: not-allowed;
- }
- .clear-btn {
- background: #6c757d;
- color: white;
- }
- .clear-btn:hover {
- background: #545b62;
- }
- .response-section {
- background: white;
- border: 1px solid #ddd;
- border-radius: 8px;
- overflow: hidden;
- }
- .response-header {
- background: #f8f9fa;
- padding: 15px 20px;
- border-bottom: 1px solid #ddd;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .response-header h3 {
- margin: 0;
- color: #333;
- }
- .status-indicator {
- padding: 5px 10px;
- border-radius: 4px;
- font-size: 12px;
- background: #6c757d;
- color: white;
- }
- .status-indicator.active {
- background: #28a745;
- }
- .response-content {
- padding: 20px;
- min-height: 200px;
- max-height: 600px;
- overflow-y: auto;
- }
- .empty-state {
- text-align: center;
- color: #6c757d;
- font-style: italic;
- }
- .stream-content {
- line-height: 1.6;
- }
- .formatted-content {
- margin-bottom: 20px;
- }
- .typing-indicator {
- display: flex;
- align-items: center;
- gap: 4px;
- color: #6c757d;
- font-style: italic;
- }
- .dot {
- width: 6px;
- height: 6px;
- background: #6c757d;
- border-radius: 50%;
- animation: typing 1.4s infinite ease-in-out;
- }
- .dot:nth-child(2) {
- animation-delay: 0.2s;
- }
- .dot:nth-child(3) {
- animation-delay: 0.4s;
- }
- @keyframes typing {
- 0%, 60%, 100% {
- transform: translateY(0);
- opacity: 0.5;
- }
- 30% {
- transform: translateY(-8px);
- opacity: 1;
- }
- }
- .db-info {
- background: #d1ecf1;
- color: #0c5460;
- padding: 10px;
- border-radius: 4px;
- margin-top: 10px;
- border: 1px solid #bee5eb;
- }
- .db-info div {
- margin: 2px 0;
- }
- .error-message {
- background: #f8d7da;
- color: #721c24;
- padding: 10px;
- border-radius: 4px;
- margin-top: 10px;
- border: 1px solid #f5c6cb;
- }
- /* 规范引用样式 */
- :deep(.standard-reference) {
- background-color: #EAEAEE !important;
- color: #616161 !important;
- font-size: 0.75rem !important;
- padding: 3px 8px !important;
- border-radius: 6px !important;
- cursor: pointer !important;
- display: inline-block !important;
- margin: 4px 2px !important;
- border: 1px solid #EAEAEE !important;
- font-weight: 500 !important;
- transition: all 0.2s ease !important;
- line-height: 1.4 !important;
- }
- :deep(.standard-reference:hover) {
- background-color: #d1d5db !important;
- border-color: #d1d5db !important;
- }
- </style>
|