vue 仿deepseek前端开发一个对话界面
后端:调用deepseek的api,所以返回数据格式和deepseek相同
{"model": "DeepSeek-R1-Distill-Qwen-1.5B", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ",有什么", "tool_calls": null}, "finish_reason": null, "logprobs": null}], "usage": {"prompt_tokens": 5, "completion_tokens": 11, "total_tokens": 16}, "id": "chatcmpl-203a3024a36e4c02b02200ca47d8901e", "object": "chat.completion.chunk", "created": 1741766893}
前端开发的几个注意点:
- 将后端返回的文本转换为html展示在页面上,注意调整样式
- 模拟打字形式,页面随之滚动
- 撑开的输入框和对话内容部分样式调整
- 测试多种文本输入,例如含有html标签的
- 记录思考时间,思考内容模仿deepseek做收起
这只是初步的项目,仅支持文本输入
懒得分步写了,直接贴完整代码吧
有些地方可能写得比较繁琐
比如判断思考内容那段,只能通过think标签判断吗?
请各位多多指点,欢迎交流!!
<template>
<div class="talk-window">
<div class="talk-title">
<p>{{ answerTitle }}</p>
<el-input v-model="choicedTasks.name" placeholder="请选择任务" class="sangedianBtn" readonly>
<template #append>
<el-button class="ec-font icon-sangedian" @click="isShowTask = true" />
</template>
</el-input>
</div>
<div class="talk-container">
<div class="talk-welcome" v-if="contentList.length == 0">
<h1>{{ welcome.title }}</h1>
<p>{{ welcome.desc }}</p>
</div>
<div class="talk-box" v-else :style="{ height: answerContHeight }">
<div ref="logContainer" class="talk-content">
<el-row v-for="(item, i) in contentList" :key="i" class="chat-assistant">
<transition name="fade">
<div :class="['answer-cont', item.type === 'send' ? 'end' : 'start']">
<img v-if="item.type == 'answer'" :src="welcome.icon" />
<div :class="item.type === 'send' ? 'send-item' : 'answer-item'">
<div v-if="item.type == 'answer'" class="hashrate-markdown" v-html="item.message" />
<div v-else>{{ item.message }}</div>
</div>
<!-- 增加复制 -->
<!-- <div v-if="item.type == 'answer' && !isTalking">
<el-tooltip centent="复制">
<i class="ec-font icon-ticket" @click="copyMsg(item.message)" />
</el-tooltip>
</div> -->
</div>
</transition>
</el-row>
</div>
<div style="text-align: center; margin-top: 10px">
<el-button class="chat-add" @click="newChat">
<i class="ec-font icon-tianjia1" />
新建对话
</el-button>
</div>
</div>
<div class="talk-send">
<textarea
@keyup.enter="enterMessage"
ref="input"
v-model="inputMessage"
@input="adjustInputHeight"
placeholder="输入消息..."
:rows="2" />
<!-- <el-input
v-model="inputMessage"
:autosize="{ minRows: 2, maxRows: 5 }"
type="textarea"
@keyup.enter="enterMessage"
placeholder="Please input" /> -->
<div class="talk-btn-cont" style="text-align: right">
<img @click="sendMessage" :src="iconImg" />
</div>
</div>
</div>
<copyrightContent :systemNameOption="systemNameOption" />
<taskDialog v-if="isShowTask" :choicedTasks="choicedTasks" v-model:isShow="isShowTask" @confirm="confirmTask" />
</div>
</template>
<script>
import taskDialog from '@/views/chat/task/taskDialog.vue'
import copyrightContent from '@/views/chat/talking/components/copyright.vue'
import hljs from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css'
import MarkdownIt from 'markdown-it'
import { VSConfig } from '/config/envConfig'
window.hiddenThink = function (index) {
// 隐藏思考内容
if (document.getElementById(`think_content_${index}`).style.display == 'none') {
document.getElementById(`think_content_${index}`).style.display = 'block'
document.getElementById(`think_icon_${index}`).classList.replace('icon-a-xiangshang3', 'icon-a-xiangxia3')
} else {
document.getElementById(`think_content_${index}`).style.display = 'none'
document.getElementById(`think_icon_${index}`).classList.replace('icon-a-xiangxia3', 'icon-a-xiangshang3')
}
}
export default {
props: {
isCollapsed: {
type: Boolean,
default: false
},
activeChat: String,
activeTitle: String,
chat_style_setting: {
type: Object,
default: function () {
return {}
}
},
systemNameOption: {
type: Object,
default: function () {
return {}
}
}
},
components: { taskDialog, copyrightContent },
data() {
return {
inputMessage: '',
messages: [],
choicedTasks: {
uuid: '',
name: ''
},
isShowTask: false,
contentList: [],
eventSourceChat: null,
markdownIt: {},
startAnwer: false,
startTime: null,
endTime: null,
thinkTime: null,
answerTitle: '',
talkUUID: '',
refreshHistoryFlag: false, // 是否已经生成了对话uuid,生成了的话就刷新历史列表
msgHight: null,
isTalking: false, //是否在对话中,处于对话中则展示休止按钮
welcome: {
title: '很高兴见到你!',
desc: '我可以帮你写代码、读文件、写作各种创意内容,请把你的任务交给我吧~',
icon: require('@/assets/image/AI.png')
},
store: {}
}
},
watch: {
activeTitle(val) {
// 重命名了对话
this.answerTitle = val
},
activeChat(val) {
this.answerTitle = ''
this.talkUUID = val || ''
this.inputMessage = ''
this.refreshHistoryFlag = false
this.isTalking = false
if (val) {
// 滚动回到顶部
const logContainer = this.$refs.logContainer
if (logContainer) {
logContainer.scrollTop = 0
}
this.getTalkDetail()
} else {
this.contentList = []
}
this.eventSourceChat && this.eventSourceChat.close()
},
chat_style_setting(val) {
this.welcome.title = val.welcome_speech_style || this.welcome.title
this.welcome.desc = val.description_style || this.welcome.desc
this.welcome.icon = val.icon_image || this.welcome.icon
}
},
computed: {
iconImg() {
if (
!this.inputMessage ||
this.inputMessage.trim() === '' ||
this.inputMessage.split(/\r?\n/).every((line) => line.trim() === '')
) {
return this.isTalking
? require('/src/assets/image/chat/stop.png')
: require('/src/assets/image/chat/unsend.png')
} else {
return require('/src/assets/image/chat/send.png')
}
},
answerContHeight() {
// 回到初始值
return this.msgHight == '56px' ? 'calc(100% - 140px)' : `calc(100% - 140px - ${this.msgHight} + 56px)`
}
},
mounted() {
this.store = mainStore()
this.markdownIt = MarkdownIt({
html: true,
linkify: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
} catch (__) {}
}
return '' // use external default escaping
}
})
},
methods: {
getTalkDetail() {
chat
.historyDetail({
uuid: this.activeChat
})
.then((res) => {
this.contentList = []
if (res.code == 0) {
res.info.history_meta &&
res.info.history_meta.forEach((item, index) => {
if (item.conversation_type == 'Answer') {
// 增加一个历史思考记录收起吧
item.context = item.context
.replace(/<think>\n\n<\/think>/g, '')
.replaceAll(
'<think>',
`<div class="think-time">历史思考<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="ec-font icon-a-xiangxia3"></i></div><section id="think_content_${index}">`
)
.replaceAll('</think>', '</section>')
item.context = this.markdownIt.render(item.context)
}
this.contentList.push({
type: item.conversation_type == 'Question' ? 'send' : 'answer',
message: item.context
})
})
this.answerTitle = res.info.name.substring(0, 20)
}
})
},
enterMessage(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
this.sendMessage()
}
},
sendMessage() {
// 终止当前对话
if (this.isTalking) {
this.eventSourceChat && this.eventSourceChat.close()
// 关闭后,处理正在对话的"思考中"
let curAnswer = this.contentList[this.contentList.length - 1].message
curAnswer = curAnswer.replaceAll('<div class="think-time">思考中……</div>', '对话中止')
this.contentList[this.contentList.length - 1].message = curAnswer
this.isTalking = false
// 再获取一下历史记录
// 暂时不获取历史记录,因为中止对话时,历史UUID可能还没返回
// this.$emit('getHistoryList')
return
}
if (
!this.inputMessage ||
this.inputMessage.trim() === '' ||
this.inputMessage.split(/\r?\n/).every((line) => line.trim() === '')
) {
this.inputMessage = ''
return false
}
if (!this.choicedTasks.name) {
this.$message({ message: '请选择推理任务', type: 'warning' })
return false
}
// 回到初始高度
const textarea = this.$refs.input
textarea.style.height = '56px'
this.msgHight = '56px'
this.eventSourceChat && this.eventSourceChat.close()
// let markedText = this.markdownIt.render(this.inputMessage)
this.contentList.push({ type: 'send', message: this.inputMessage })
this.contentList.push({ type: 'answer', message: `<div class="think-time">思考中……</div>` })
this.answerTitle = this.answerTitle || this.contentList[0].message.substring(0, 20)
this.scrollToBottom()
this.initSSEChat()
},
initSSEChat() {
const url = `${VSConfig.isHttps ? 'https' : 'http'}://${
this.store.leader
}/v1/intelligent_computing/task/chat/stream?uuid=${this.choicedTasks.uuid}&message=${encodeURIComponent(
this.inputMessage
)}&token=${this.store.token}&conversation_uuid=${this.talkUUID}`
this.inputMessage = ''
this.eventSourceChat = new EventSource(url)
let buffer = ''
this.startTime = null
this.endTime = null
this.thinkTime = null
let len = this.contentList.length
let index = len % 2 === 0 ? len - 1 : len
this.isTalking = true
this.eventSourceChat.onmessage = async (event) => {
await this.sleep(10)
if (event.data == '[DONE]') {
return false
}
// 接收 Delta 数据
// 最后一条是UUID,第二次发对话的时候要传参
try {
var { choices, created } = JSON.parse(event.data)
} catch (e) {
// 新对话在历史列表补充数据
this.talkUUID = event.data
if (!this.refreshHistoryFlag) {
this.refreshHistoryFlag = event.data
this.$emit('refreshHistory', this.refreshHistoryFlag)
}
}
// const { choices, created } = JSON.parse(event.data)
if (choices && choices[0].delta?.content) {
buffer += choices[0].delta.content
// think标签内是思考内容,单独记录思考时间
if (choices[0].delta.content.includes('<think>')) {
choices[0].delta.content = `<div class="think-time">思考中……</div><section id="think_content_${index}">`
buffer = buffer.replaceAll('<think>', choices[0].delta.content)
this.startTime = Math.floor(new Date().getTime() / 1000)
}
if (choices[0].delta.content.includes('</think>')) {
// console.log("结束时间赋值的判断")
choices[0].delta.content = `</section>`
this.endTime = Math.floor(new Date().getTime() / 1000)
// 获取到结束时间后,直接展示收起按钮
this.thinkTime = this.endTime - this.startTime
buffer = buffer
.replaceAll(
'<div class="think-time">思考中……</div>',
`<div class="think-time">已深度思考(${this.thinkTime}S)<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="ec-font icon-a-xiangxia3"></i></div>`
)
.replaceAll('</think>', choices[0].delta.content)
.replaceAll(`<section id="think_content_${index}"></section>`, '')
}
let markedText = this.markdownIt.render(buffer)
this.contentList[index] = { type: 'answer', message: markedText }
this.scrollToBottomIfAtBottom()
}
}
this.eventSourceChat.onerror = (event) => {
console.log('错误触发===》', event)
this.contentList[index] = { type: 'answer', message: `<div class="think-time">对话服务连接失败</div>` }
this.eventSourceChat.close()
this.isTalking = false
}
this.eventSourceChat.onclose = (event) => {
// 关闭事件
console.log('关闭事件--->')
this.isTalking = false
}
},
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
},
scrollToBottomIfAtBottom() {
this.$nextTick(() => {
const logContainer = this.$refs.logContainer
if (logContainer) {
const threshold = 100
const distanceToBottom = logContainer.scrollHeight - logContainer.scrollTop - logContainer.clientHeight
if (distanceToBottom <= threshold) logContainer.scrollTop = logContainer.scrollHeight
}
})
},
scrollToBottom() {
this.$nextTick(() => {
const logContainer = this.$refs.logContainer
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight
}
})
},
confirmTask(val) {
this.choicedTasks = val
},
clearWindow() {
this.eventSourceChat && this.eventSourceChat.close()
this.contentList = []
this.answerTitle = ''
this.talkUUID = ''
this.inputMessage = ''
this.refreshHistoryFlag = false
this.isTalking = false
const textarea = this.$refs.input
textarea.style.height = '56px'
this.msgHight = '56px'
},
newChat() {
this.clearWindow()
this.$emit('clearChat')
},
adjustInputHeight(event) {
// enter键盘按下的换行赋值为空
if (event.key === 'Enter' && !event.shiftKey) {
this.inputMessage = ''
event.preventDefault()
return
}
this.$nextTick(() => {
const textarea = this.$refs.input
textarea.style.height = 'auto'
// 最高200px
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'
this.msgHight = textarea.style.height
})
},
copyMsg(txt) {
// 复制功能
// 创建一个临时的 textarea 元素
const textarea = document.createElement('textarea')
textarea.value = txt.replace(/<[^>]+>/g, '') // 去掉html标签
textarea.style.position = 'fixed'
document.body.appendChild(textarea)
textarea.select() // 选中文本
try {
document.execCommand('copy') // 执行复制
ElMessage({
message: '复制成功',
type: 'success'
})
} catch (err) {
ElMessage({
message: '复制失败',
type: 'error'
})
} finally {
document.body.removeChild(textarea) // 移除临时元素
}
}
},
beforeDestroy() {
this.eventSourceChat && this.eventSourceChat.close()
}
}
</script>
<style scoped lang="scss">
.talk-window {
height: 100%;
transition: margin 0.2s ease;
position: relative;
}
.talk-container {
height: calc(100% - 58px);
position: relative;
}
.talk-welcome {
text-align: center;
// margin-bottom: 25px;
padding: 10% 20% 25px;
box-sizing: border-box;
h1 {
margin-bottom: 30px;
font-size: 21px;
}
p {
color: #8f9aad;
}
}
.messages {
padding: 20px;
overflow-y: auto;
}
.message {
display: flex;
margin: 12px 0;
}
.message.user {
justify-content: flex-end;
}
.bubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.message.user .bubble {
background: #e8f4ff;
}
.time {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.talk-send {
background: #f1f2f7;
border-radius: 10px;
border: 1px solid #e9e9eb;
padding: 5px 10px;
margin: 0px 20%;
img {
cursor: pointer;
}
textarea {
width: 100%;
padding: 10px;
resize: none;
overflow: auto;
// min-height: 48px;
max-height: 200px;
line-height: 1.5;
box-sizing: border-box;
font-family: inherit;
border: 0px;
background: #f1f2f7;
}
textarea:focus {
outline: none !important;
}
}
input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
}
.talk-title {
height: 56px;
line-height: 56px;
p {
color: #000000;
font-size: 15px;
font-weight: 550;
text-align: center;
}
.sangedianBtn {
width: 225px;
height: 32px;
position: absolute;
top: 15px;
right: 30px;
}
}
.send-item {
max-width: 60%;
word-break: break-all;
padding: 10px;
background: #eef6ff;
border-radius: 10px;
color: #000000;
white-space: pre-wrap;
font-size: 13px;
}
.msg-row {
margin-bottom: 10px;
}
.talk-box {
height: calc(100% - 140px);
.talk-content {
background-color: #fff;
color: #324659;
overflow-y: auto;
height: calc(100% - 50px);
box-sizing: border-box;
padding: 0px 20%;
// &:hover {
// overflow-y: auto;
// }
.chat-assistant {
display: flex;
margin-bottom: 10px;
.answer-item {
line-height: 30px;
color: #324659;
}
}
.answer-cont {
position: relative;
display: flex;
width: 100%;
> img {
width: 30px;
height: 30px;
margin-right: 10px;
}
&.end {
justify-content: flex-end;
}
&.start {
justify-content: flex-start;
}
}
}
.chat-sse {
min-height: 100px;
max-height: 460px;
}
.chat-message {
height: calc(100vh - 276px);
}
.thinking-bubble {
height: calc(100vh - 296px);
}
}
.chat-add {
width: 111px;
height: 33px;
background: #dbeafe;
border-radius: 6px !important;
font-size: 14px !important;
border: 0px;
color: #516ffe !important;
&:hover {
background: #ebf0f7;
}
.icon-tianjia1 {
margin-right: 10px;
font-size: 14px;
}
}
.talk-btn-cont {
text-align: right;
height: 30px;
margin-top: 5px;
}
</style>
<style lang="scss">
@use './markdown.scss';
</style>
最终页面: