文章说明
核心代码
<script setup>
import {nextTick, onBeforeMount, reactive, watch} from "vue";
import {dbOperation} from "@/DbOperation";
import "@/config.js"
import {openDb} from "@/config";
import {confirm, message} from "@/util";
import {MdPreview} from 'md-editor-v3';
import 'md-editor-v3/lib/preview.css';
const data = reactive({
content: "",
messageList: [],
});
let isReplying = false;
let container;
function getMaxHeight() {
container = document.getElementsByClassName("container")[0];
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth"
});
}
onBeforeMount(() => {
openDb(() => {
dbOperation.getAllData((res) => {
data.messageList = res.data;
nextTick(() => {
getMaxHeight();
});
});
});
});
watch(() => data.content, () => {
if (data.content.length > 500) {
data.content = String(data.content).slice(0, 500);
}
});
const avatarRobot = require("@/image/1.jpg");
const avatarUser = require("@/image/2.jpg");
function question() {
if (isReplying) {
message("正在对话中", "warning");
return;
}
isReplying = true;
const messageItem = {
question: data.content,
reply: "",
createTime: new Date(),
};
data.messageList.push(messageItem);
setTimeout(() => {
const height = container.scrollHeight;
container.scrollTo({
top: height,
behavior: "smooth"
});
}, 0);
fetch(new Request('http://localhost:11434/api/chat', {
method: 'post',
mode: "cors",
body: JSON.stringify({
"model": "llama3.1",
"messages": [
{
"role": "user",
"content": data.content
}
],
}),
})).then(response => {
data.content = "";
const reader = response.body.getReader();
read();
function read() {
reader.read().then(({done, value}) => {
if (done) {
isReplying = false;
dbOperation.add(messageItem, () => {
message("对话成功", "success");
});
return;
}
const readContent = new Uint8Array(value);
const content = JSON.parse(Uint8ArrayToString(readContent)).message.content;
data.messageList[data.messageList.length - 1].reply += content;
read();
}).catch(error => {
message(error, "error");
});
}
}).catch(error => {
message(error, "error");
});
}
function Uint8ArrayToString(fileData) {
const decoder = new TextDecoder('utf-8');
return decoder.decode(fileData);
}
function editQuestion(item) {
data.content = item.question;
}
function copy(item) {
navigator.clipboard.writeText(item.reply).then(() => {
message("复制到剪切板", "success");
});
}
function deleteItem(item) {
confirm("确认要删除该消息吗?", () => {
data.messageList = data.messageList.filter(message => message.id !== item.id);
dbOperation.delete(item.id, () => {
message("删除成功", "success");
});
});
}
</script>
<template>
<div class="container">
<div class="message-container">
<div v-for="item in data.messageList" :key="item.id" class="message-item">
<div class="create-time">
{{ item.createTime }}
</div>
<div class="user">
<img :src="avatarUser" alt=""/>
<p>
<MdPreview v-model="item.question"/>
</p>
<i class="iconfont icon-edit" @click="editQuestion(item)"></i>
</div>
<div class="robot">
<img :src="avatarRobot" alt=""/>
<p>
<MdPreview v-model="item.reply"/>
</p>
<i class="iconfont icon-copy" @click="copy(item)"></i>
<i class="iconfont icon-delete" @click="deleteItem(item)"></i>
</div>
</div>
</div>
<div class="input-container">
<textarea v-model="data.content"/>
<p class="tip">{{ data.content.length }}/500</p>
<i class="iconfont icon-send" @click="question"></i>
</div>
</div>
</template>
<style lang="scss">
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
.container {
min-width: 20rem;
width: 100vw;
height: 100vh;
background-color: #eaedf6;
overflow: auto;
position: relative;
user-select: none;
.message-container {
max-width: 70rem;
width: 100%;
height: fit-content;
min-height: calc(100vh - 8rem);
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
padding: 0 1rem 8rem;
overflow: auto;
.message-item {
margin: 1.5rem 0;
.create-time {
height: 1rem;
line-height: 1rem;
font-size: 0.6rem;
color: #999;
margin-bottom: -0.5rem;
margin-left: 0.5rem;
}
.user, .robot {
margin: 1rem 0;
display: flex;
position: relative;
&:hover .icon-edit, &:hover .icon-copy, &:hover .icon-delete {
display: block;
}
img {
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
}
p {
flex: 1;
display: flex;
align-items: center;
margin-left: 0.5rem;
.md-editor-preview-wrapper {
padding: 0 1rem;
}
}
.icon-edit {
position: absolute;
right: 0.5rem;
bottom: 0;
transform: translateY(-50%);
display: none;
}
.icon-copy {
position: absolute;
right: 1.8rem;
bottom: 0;
transform: translateY(-50%);
display: none;
}
.icon-delete {
position: absolute;
right: 0.5rem;
bottom: 0;
transform: translateY(-50%);
display: none;
}
}
}
}
.input-container {
max-width: 70rem;
width: 80%;
height: 6rem;
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background-color: #fff;
border-radius: 1rem;
&:focus-within {
box-shadow: 0 0 1rem 0.1rem #888888;
}
textarea {
outline: none;
border: none;
padding: 0.6rem;
font-size: 1.2rem;
resize: none;
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
border-radius: 1rem;
}
.tip {
font-size: 0.6rem;
position: absolute;
right: 1rem;
bottom: 0.5rem;
}
.icon-send {
font-size: 1.5rem;
position: absolute;
right: 1rem;
bottom: 2rem;
cursor: pointer;
}
}
}
</style>
效果展示
源码下载