文章说明

核心代码

<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>

效果展示

源码下载

08-27 23:58