搭建简易版 chatgpt

172 阅读5分钟

搭建简易版 chatgpt

前端

读取流

  getReader() 方法会创建一个 reader,并将流锁定。只有当前 reader 将流释放后,其他 reader 才能使用。

示例:

function fetchStream() {
  const reader = stream.getReader();
  // read() 返回了一个 promise;当数据被接收时 resolve
  reader.read().then(({ done, value }) => {
    // Result 对象包含了两个属性:
    // done  - 当 stream 传完所有数据时则变成 true
    // value - 数据片段。当 done 为 true 时始终为 undefined;否则可能是一个 Buffer 或者一个 Uint8Array
    if (done) {
      console.log("流解析完成");
    }
  });
}

文本解码器

  TextDecoder 接口表示一个文本解码器,一个解码器只支持一种特定文本编码,例如 UTF-8、ISO-8859-2、KOI8-R、GBK,等等。解码器将字节流作为输入,并提供码位流作为输出。

示例:

let utf8decoder = new TextDecoder();
let u8arr = new Uint8Array([240, 160, 174, 183]);
console.log(utf8decoder.decode(u8arr));

滚动到底部

  当刷新页面/发送消息/接收消息时,让页面滚动到最底部。

const scrollToBottom = async () => {
  const el = document.querySelector(".content");
  if (el) {
    await nextTick();
    el.scrollTop = el.scrollHeight;
  }
};

代码高亮

MarkdownIt:以 markdown 形式展示文本

markdownItSanitizer:防止用户文本中的 XSS 攻击

highlight:语法高亮器

markdownItHighlightjs:让 markdown 中的代码块进行高亮

// 创建一个 MarkdownIt 实例
// MarkdownIt 是一个可以将 Markdown 文本转换为 HTML 文本的库
const md = MarkdownIt({
  // 允许直接在 Markdown 中插入 HTML 代码
  html: true,
  // 自动将 URL 文本转换为链接
  linkify: true,
  // 启用排版功能,例如将 straight quotes (') 转换为 curly quotes (’)
  typographer: true,
  // 自定义代码高亮函数
  highlight: function (str, lang) {
    // 如果指定了语言,并且这个语言是 hljs 支持的
    if (lang && hljs.getLanguage(lang)) {
      try {
        // 使用 hljs 进行高亮,并返回结果
        return hljs.highlight(str, { language: lang, ignoreIllegals: true })
          .value;
      } catch (__) {}
    }
    // 如果没有指定语言,或者高亮失败,那么不进行高亮
    return "";
  },
})
  // 使用 markdown-it-sanitizer 插件
  // 这个插件可以防止 XSS 攻击,它会移除 Markdown 文本中的恶意 HTML 代码
  .use(markdownItSanitizer)
  // 使用 markdown-it-highlightjs 插件
  // 这个插件可以将 Markdown 中的代码块转换为带有高亮的 HTML 代码
  .use(markdownItHighlightjs);

MutationObserver

  MutationObserver 接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。

  • observe()
    • 配置 MutationObserver 在 DOM 更改匹配给定选项时,通过其回调函数开始接收通知。

示例:

// 选择需要观察变动的节点
const targetNode = document.getElementById("some-id");

// 观察器的配置(需要观察什么变动)
const config = { attributes: true, childList: true, subtree: true };

// 当观察到变动时执行的回调函数
const callback = function (mutationsList, observer) {
  // Use traditional 'for loops' for IE 11
  for (let mutation of mutationsList) {
    if (mutation.type === "childList") {
      console.log("A child node has been added or removed.");
    } else if (mutation.type === "attributes") {
      console.log("The " + mutation.attributeName + " attribute was modified.");
    }
  }
};

// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);

// 以上述配置开始观察目标节点
observer.observe(targetNode, config);

// 之后,可停止观察
observer.disconnect();

应用:

// 创建一个新的 MutationObserver 对象
const observer = new MutationObserver(function (mutations) {
  mutations.forEach(function (mutation) {
    // 检查是否有新的节点被添加
    if (mutation.addedNodes) {
      mutation.addedNodes.forEach(function (node) {
        // 检查新的节点是否是一个 <pre> 元素
        if (node.nodeName.toLowerCase() === "pre") {
          // 检查 <pre> 元素下是否存在 <code> 元素
          const codeNode = node.querySelector("code");
          if (codeNode) {
            // 创建一个 "复制" 按钮
            const button = document.createElement("button");
            button.classList.add("copy-code-button");
            button.textContent = "copy";

            // 当按钮被点击时,复制 <code> 元素的文本
            button.addEventListener("click", function () {
              navigator.clipboard.writeText(codeNode.textContent);
            });

            // 将按钮添加到 <pre> 元素中
            node.appendChild(button);
          }
        }
      });
    }
  });
});

// 开始监听 DOM 的变化
observer.observe(document.body, {
  childList: true,
  subtree: true,
});

完整代码

<script setup>
import { nextTick, onMounted, ref } from "vue";
import { Position } from "@element-plus/icons-vue";
import MarkdownIt from "markdown-it";
import markdownItSanitizer from "markdown-it-sanitizer";
import hljs from "highlight.js";
import markdownItHighlightjs from "markdown-it-highlightjs";

const md = MarkdownIt({
  html: true,
  linkify: true,
  typographer: true,
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(str, { language: lang, ignoreIllegals: true })
          .value;
      } catch (__) {}
    }
    return ""; // 使用外部的默认转义
  },
})
  .use(markdownItSanitizer)
  .use(markdownItHighlightjs);

const textarea = ref("");

const messages = ref([
  {
    role: "system",
    content: "有什么可以帮你的吗?",
  },
]);

const scrollToBottom = async () => {
  const el = document.querySelector(".content");
  if (el) {
    await nextTick();
    el.scrollTop = el.scrollHeight;
  }
};

const sendNetworkRequest = async () => {
  const response = await fetch("http://localhost:3000/chat", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      messages: messages.value,
    }),
  });
  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  const arrayLength = messages.value.length;
  messages.value[arrayLength] = {
    role: "system",
    content: "",
  };

  while (true) {
    // 取值, value 是后端返回流信息, done 表示后端结束流的输出
    const { value, done } = await reader.read();
    if (done) break;
    messages.value[arrayLength].content += decoder.decode(value);
    scrollToBottom();
  }
};

const sendMsg = () => {
  messages.value.push({
    role: "user",
    content: textarea.value,
  });
  textarea.value = "";
  scrollToBottom();
  sendNetworkRequest();
};

// 创建一个新的 MutationObserver 对象
const observer = new MutationObserver(function (mutations) {
  mutations.forEach(function (mutation) {
    // 检查是否有新的节点被添加
    if (mutation.addedNodes) {
      mutation.addedNodes.forEach(function (node) {
        // 检查新的节点是否是一个 <pre> 元素
        if (node.nodeName.toLowerCase() === "pre") {
          // 检查 <pre> 元素下是否存在 <code> 元素
          const codeNode = node.querySelector("code");
          if (codeNode) {
            // 创建一个 "复制" 按钮
            const button = document.createElement("button");
            button.classList.add("copy-code-button");
            button.textContent = "copy";

            // 当按钮被点击时,复制 <code> 元素的文本
            button.addEventListener("click", function () {
              navigator.clipboard.writeText(codeNode.textContent);
            });

            // 将按钮添加到 <pre> 元素中
            node.appendChild(button);
          }
        }
      });
    }
  });
});

// 开始监听 DOM 的变化
observer.observe(document.body, {
  childList: true,
  subtree: true,
});

const handleKeyDown = (event) => {
  if (event.key === "Enter" && event.ctrlKey) {
    sendMsg();
  }
};

onMounted(() => {
  nextTick(() => {
    scrollToBottom();
  });
});
</script>

<template>
  <div class="chat">
    <div class="content">
      <div
        class="message"
        :class="message.role"
        v-for="(message, index) in messages"
        :key="index"
      >
        <div class="avatar">
          <img src="@/assets/system.svg" alt="" />
        </div>
        <div class="line" v-html="md.render(message.content)"></div>
      </div>
    </div>
    <div class="input">
      <el-input
        v-model="textarea"
        type="textarea"
        :autosize="{ minRows: 3, maxRows: 10 }"
        placeholder="Ctrl + Enter 发送"
        @keydown="handleKeyDown"
      />
      <el-button :icon="Position" type="primary" plain @click="sendMsg">
        发送
      </el-button>
    </div>
  </div>
</template>

<style scoped>
.chat {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  padding: 20px;
  background-color: #ccc;
  display: flex;
  flex-direction: column;
}
.content {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  background-color: #fff;
  border-top-left-radius: 5px;
  border-top-right-radius: 5px;
  padding: 20px;
  overflow-x: hidden;
  overflow-y: auto;
}
.input {
  position: relative;
  box-sizing: border-box;
  width: 100%;
  border-top: 1px solid #dedede;
  background-color: #fff;
  border-bottom-left-radius: 5px;
  border-bottom-right-radius: 5px;
  padding: 20px;
}
:deep(.input textarea) {
  padding: 10px 90px 10px 14px;
  resize: none;
}
:deep(.input .el-button) {
  position: absolute;
  right: 30px;
  bottom: 30px;
}
.message {
  display: flex;
  flex-direction: column;
}
.system {
  align-items: flex-start;
}
.user {
  align-items: flex-end;
}
.avatar {
  width: 20px;
  padding: 5px;
  border-radius: 10px;
  border: 1px solid #ccc;
}
.avatar img {
  width: 100%;
  height: 100%;
  vertical-align: middle;
}

.system .avatar {
  background-color: #e7f8ff;
}

.line {
  box-sizing: border-box;
  max-width: 70%;
  margin-top: 10px;
  margin-bottom: 10px;
  border-radius: 10px;
  padding: 10px;
  font-size: 14px;
  -webkit-user-select: text;
  -moz-user-select: text;
  user-select: text;
  word-break: break-word;
  border: 1px solid #dedede;
  position: relative;
  transition: all 0.3s ease;
}

.system .line {
  background-color: rgba(0, 0, 0, 0.05);
}

.user .line {
  background-color: #e7f8ff;
}
</style>

后端代码

import OpenAI from "openai";
import Koa from "koa";
import Router from "@koa/router";
import cors from "@koa/cors";
import { PassThrough } from "stream";
import bodyParser from "koa-bodyparser";

const app = new Koa();
const router = new Router();

const openai = new OpenAI({
  apiKey: "sk-gpt-3", // gpt-3
});

app.use(
  cors({
    origin: "*",
    allowMethods: ["GET", "POST", "DELETE", "PUT"],
    headers: [
      "Cache-Control",
      "Content-Type",
      "X-Requested-With",
      "EventSource",
    ],
    credentials: true,
  })
);

// 使用 bodyParser 中间件
app.use(bodyParser());

const receive = async (stream, messages) => {
  const AI = await openai.beta.chat.completions.stream({
    model: "gpt-3.5-turbo",
    messages,
    stream: true,
  });

  AI.on("content", (delta, snapshot) => {
    stream.write(delta);
  });

  AI.finalChatCompletion().then(() => {
    stream.end();
  });
};

router.post("/chat", (ctx) => {
  const { messages } = ctx.request.body;

  ctx.set({
    Connection: "keep-alive",
    "Cache-Control": "no-cache",
    "Content-Type": "text/event-stream",
  });

  const stream = new PassThrough();
  ctx.body = stream;
  ctx.status = 200;

  receive(stream, messages);
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, () => {
  console.log("Server running on port 3000");
});

对应源代码链接