搭建简易版 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");
});