|
|
@ -14,15 +14,15 @@ |
|
|
|
<div class="chat-container"> |
|
|
|
<div class="message-list" ref="messaggListRef"> |
|
|
|
<div |
|
|
|
v-for="(message, index) in messages" |
|
|
|
:key="index" |
|
|
|
:class=" |
|
|
|
v-for="(message, index) in messages" |
|
|
|
:key="index" |
|
|
|
:class=" |
|
|
|
message.isUser ? 'message user-message' : 'message bot-message' |
|
|
|
" |
|
|
|
> |
|
|
|
<!-- 会话图标 --> |
|
|
|
<i |
|
|
|
:class=" |
|
|
|
:class=" |
|
|
|
message.isUser |
|
|
|
? 'fa-solid fa-user message-icon' |
|
|
|
: 'fa-solid fa-robot message-icon' |
|
|
@ -30,11 +30,11 @@ |
|
|
|
></i> |
|
|
|
<!-- 会话内容 --> |
|
|
|
<span> |
|
|
|
<span v-html="message.content"></span> |
|
|
|
<span v-html="convertStreamOutput(message.content)"></span> |
|
|
|
<!-- loading --> |
|
|
|
<span |
|
|
|
class="loading-dots" |
|
|
|
v-if="message.isThinking || message.isTyping" |
|
|
|
class="loading-dots" |
|
|
|
v-if="message.isThinking || message.isTyping" |
|
|
|
> |
|
|
|
<span class="dot"></span> |
|
|
|
<span class="dot"></span> |
|
|
@ -44,12 +44,12 @@ |
|
|
|
</div> |
|
|
|
<div class="input-container"> |
|
|
|
<el-input |
|
|
|
v-model="inputMessage" |
|
|
|
placeholder="请输入消息" |
|
|
|
@keyup.enter="sendMessage" |
|
|
|
v-model="inputMessage" |
|
|
|
placeholder="请输入消息" |
|
|
|
@keyup.enter="sendMessage" |
|
|
|
></el-input> |
|
|
|
<el-button @click="sendMessage" :disabled="isSending" type="primary" |
|
|
|
>发送</el-button |
|
|
|
>发送</el-button |
|
|
|
> |
|
|
|
</div> |
|
|
|
</div> |
|
|
@ -61,6 +61,7 @@ |
|
|
|
import { onMounted, ref, watch } from 'vue' |
|
|
|
import axios from 'axios' |
|
|
|
import { v4 as uuidv4 } from 'uuid' |
|
|
|
import { marked } from 'marked' |
|
|
|
|
|
|
|
const messaggListRef = ref() |
|
|
|
const isSending = ref(false) |
|
|
@ -100,12 +101,11 @@ const sendRequest = (message) => { |
|
|
|
isTyping: false, |
|
|
|
isThinking: false, |
|
|
|
} |
|
|
|
//第一条默认发送的用户消息”你好“不放入会话列表 |
|
|
|
//第一条默认发送的用户消息"你好"不放入会话列表 |
|
|
|
if(messages.value.length > 0){ |
|
|
|
messages.value.push(userMsg) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 添加机器人加载消息 |
|
|
|
const botMsg = { |
|
|
|
isUser: false, |
|
|
@ -118,31 +118,30 @@ const sendRequest = (message) => { |
|
|
|
scrollToBottom() |
|
|
|
|
|
|
|
axios |
|
|
|
.post( |
|
|
|
'/api/xiaozhi/chat', |
|
|
|
{ memoryId: uuid.value, message }, |
|
|
|
{ |
|
|
|
responseType: 'stream', // 必须为合法值 "text" |
|
|
|
onDownloadProgress: (e) => { |
|
|
|
const fullText = e.event.target.responseText // 累积的完整文本 |
|
|
|
let newText = fullText.substring(lastMsg.content.length) |
|
|
|
lastMsg.content += newText //增量更新 |
|
|
|
console.log(lastMsg) |
|
|
|
scrollToBottom() // 实时滚动 |
|
|
|
}, |
|
|
|
} |
|
|
|
) |
|
|
|
.then(() => { |
|
|
|
// 流结束后隐藏加载动画 |
|
|
|
messages.value.at(-1).isTyping = false |
|
|
|
isSending.value = false |
|
|
|
}) |
|
|
|
.catch((error) => { |
|
|
|
console.error('流式错误:', error) |
|
|
|
messages.value.at(-1).content = '请求失败,请重试' |
|
|
|
messages.value.at(-1).isTyping = false |
|
|
|
isSending.value = false |
|
|
|
}) |
|
|
|
.post( |
|
|
|
'/api/xiaozhi/chat', |
|
|
|
{ memoryId: uuid.value, message }, |
|
|
|
{ |
|
|
|
responseType: 'stream', // 必须为合法值 "text" |
|
|
|
onDownloadProgress: (e) => { |
|
|
|
const fullText = e.event.target.responseText // 累积的完整文本 |
|
|
|
let newText = fullText.substring(lastMsg.content.length) |
|
|
|
lastMsg.content += newText //增量更新 |
|
|
|
scrollToBottom() // 实时滚动 |
|
|
|
}, |
|
|
|
} |
|
|
|
) |
|
|
|
.then(() => { |
|
|
|
// 流结束后隐藏加载动画 |
|
|
|
messages.value.at(-1).isTyping = false |
|
|
|
isSending.value = false |
|
|
|
}) |
|
|
|
.catch((error) => { |
|
|
|
console.error('流式错误:', error) |
|
|
|
messages.value.at(-1).content = '请求失败,请重试' |
|
|
|
messages.value.at(-1).isTyping = false |
|
|
|
isSending.value = false |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
// 初始化 UUID |
|
|
@ -166,12 +165,12 @@ const uuidToNumber = (uuid) => { |
|
|
|
|
|
|
|
// 转换特殊字符 |
|
|
|
const convertStreamOutput = (output) => { |
|
|
|
return output |
|
|
|
.replace(/\n/g, '<br>') |
|
|
|
.replace(/\t/g, ' ') |
|
|
|
.replace(/&/g, '&') // 新增转义,避免 HTML 注入 |
|
|
|
.replace(/</g, '<') |
|
|
|
.replace(/>/g, '>') |
|
|
|
// 使用 marked 解析 Markdown |
|
|
|
return marked(output, { |
|
|
|
breaks: true, // 支持换行 |
|
|
|
gfm: true, // 支持 GitHub 风格的 Markdown |
|
|
|
sanitize: true // 防止 XSS 攻击 |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
const newChat = () => { |
|
|
@ -378,4 +377,70 @@ const newChat = () => { |
|
|
|
margin-top: 20px; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/* Markdown 样式 */ |
|
|
|
:deep(p) { |
|
|
|
margin: 0.5em 0; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(pre) { |
|
|
|
background-color: #f6f8fa; |
|
|
|
border-radius: 6px; |
|
|
|
padding: 16px; |
|
|
|
overflow: auto; |
|
|
|
margin: 0.5em 0; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(code) { |
|
|
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; |
|
|
|
background-color: rgba(175, 184, 193, 0.2); |
|
|
|
padding: 0.2em 0.4em; |
|
|
|
border-radius: 6px; |
|
|
|
font-size: 85%; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(ul), :deep(ol) { |
|
|
|
padding-left: 2em; |
|
|
|
margin: 0.5em 0; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(li) { |
|
|
|
margin: 0.25em 0; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(blockquote) { |
|
|
|
margin: 0.5em 0; |
|
|
|
padding: 0 1em; |
|
|
|
color: #57606a; |
|
|
|
border-left: 0.25em solid #d0d7de; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(table) { |
|
|
|
border-collapse: collapse; |
|
|
|
width: 100%; |
|
|
|
margin: 0.5em 0; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(th), :deep(td) { |
|
|
|
border: 1px solid #d0d7de; |
|
|
|
padding: 6px 13px; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(th) { |
|
|
|
background-color: #f6f8fa; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(img) { |
|
|
|
max-width: 100%; |
|
|
|
height: auto; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(a) { |
|
|
|
color: #0969da; |
|
|
|
text-decoration: none; |
|
|
|
} |
|
|
|
|
|
|
|
:deep(a:hover) { |
|
|
|
text-decoration: underline; |
|
|
|
} |
|
|
|
</style> |
|
|
|