本文是一个简单的LLM 对话框组件的开发过程介绍
要求
模仿市面上常见的LLM 对话框布局,进行开发一个对话框组件
技术栈
框架:Vite + Vue3
语言:使用 TypeScript 编写代码
测试:vitest/jest
规范:eslint
布局
页面布局:
组件结构:
初始化
创建项目:使用Vite创建Vue3项目,并配置TypeScript语言
# 创建项目
npm create vite@latest llm-dialog
# 安装依赖
npm i
布局过程:页面垂直分配,上面部分为发送给LLM的消息和返回的消息,下面部分为用户的输入框
布局较为简单,使用 flex 弹性盒布局,设主轴为垂直,再给子元素配置flex属性即可
核心逻辑
向 LLM 大模型发送请求,并接收流式传输的数据,逐字打印显示在消息框处
(之前调用过 Kimi 的接口,就先用这个举例了)
发送请求
// 发送请求
const stream = await this.client.chat.completions.create({
model: "moonshot-v1-8k",
messages: [
{ "role": "system", "content": "你是 Kimi,由 Moonshot AI 提供的人工智能助手,你更擅长中文和英文的对话。你会为用户提供安全,有帮助,准确的回答。同时,你会拒绝一切涉及恐怖主义,种族歧视,黄色暴力等问题的回答。Moonshot AI 为专有名词,不可翻译成其他语言。" },
{ "role": "user", content: message }
],
temperature: 0.3,
stream: true, // 开启流式传输
});
接收流式传输的数据
// 逐字打印
for await (const chunk of stream) {
// 如果传输结束
if (chunk.choices[0].finish_reason === 'stop') {
messagesStore.finishPrint();
}
// 提取每个块返回的内容
const delta = chunk.choices[0].delta
// 如果有内容,则放入缓存里
if (delta.content) {
messagesStore.cacheContent(delta.content);
}
}
内容不直接打印出来,而是放入缓存的原因:每次返回的字符数不固定,放入缓存后可以控制打印的速率(个人爱好处理,可自行调整)
逐字打印的逻辑:当内容放入缓存时,开启定时器,定时器会以固定的频率尝试从缓存中取出字符并依次打印到屏幕上,在接收结束时设置标志,让定时器在下一次缓存内没有内容时自动关闭
// 逐字打印
function printContent() {
if (cacheMessages.value.length > 0) {
// 逐字打印
const char = cacheMessages.value.charAt(0);
cacheMessages.value = cacheMessages.value.slice(1);
messages[messages.length - 1].content += char;
// 尝试停止打印定时器
stopPrintTimer()
}
}
// 缓存内容
function cacheContent(content: string) {
isTransmiting.value = true;
cacheMessages.value += content;
if (timer === null) {
timer = setInterval(printContent, printSpeed);
}
}
// 完成传输
function finishPrint() {
isTransmiting.value = false;
}
// 停止打印定时器
function stopPrintTimer() {
if (!isTransmiting.value && cacheMessages.value.length === 0 && timer !== null) {
// 停止定时器
clearInterval(timer);
timer = null;
// 发送完成事件
emitter.emit('CompletePrint');
}
}
适配多个 LLM
使用适配器模式适配多个 LLM 大模型的调用
// 定义一个统一的 LLM 接口
interface LLMAdapter {
sendMessage(message: string): void;
}
单个 LLM 大模型调用的适配器
// KIMI LLM 适配器
class KimiAdapter implements LLMAdapter {
private apiKey: string;
private client: OpenAI;
constructor(apiKey: string) {
......
}
async sendMessage(message: string) {
......
}
}
export default KimiAdapter;
编写适配器工厂
class LLMAdapterFactory {
static createAdapter(type: string, apiKey: string): LLMAdapter {
switch (type) {
case 'kimi':
return new KimiAdapter(apiKey);
case '???':
return new ???(key);
default:
throw new Error(`Unsupported LLM type: ${type}`);
}
}
}
export default LLMAdapterFactory;
处理发送事件
// 处理发送消息事件
const handleSendMessage = (content: string) => {
const kimi = new KimiAdapter(import.meta.env.VITE_KIMI_API_KEY as string)
kimi.sendMessage(content);
};
语法解析
使用 marked.parse()
解析 LLM 返回的 Markdown 文档
let toMDcontent = computed(() => {
return marked(props.message.content);
});
引入样式
import "github-markdown-css";
代码语法高亮:使用自定义指令实现
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
export const highlight = {
mounted(el: HTMLElement) {
const blocks = el.querySelectorAll('pre code');
blocks.forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
},
updated(el: HTMLElement) {
const blocks = el.querySelectorAll('pre code');
blocks.forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
},
};
注册自定义指令
// 注册代码块高亮指令
app.directive('highlight', highlight);
配置高亮 v-highlight
<div
v-highlight
v-if="message.role === 'bot'"
v-html="toMDcontent"
:class="['chat-message__content', 'markdown-body', message.role]"
>
</div>
开发暂时到此,后面还需要配置记忆化对话,上传并解析图片与文档等