概述
聊天页面旨在依照chatgpt完成一个类似的聊天页面,主要分为三部分:输入框,聊天容器和聊天对话管理侧边栏,使用到的组件技术主要有vue,vuex,tailwind
类定义
这里使用ts,可以定义各个类,这可以很好的理清思路,同时可以知道这个类用在哪几个组件中。
message
messageType定义了发给后端的信息,包含的信息有
- idx用于定义一条message在一个conversation中是第几条
- role表示的是说话角色,这里我们按照智谱清言的定义方式,其中assistant为ai的回答,user为用户的问题,system为系统设定(system在实际中不常使用)
- speech为回答数组,用于ai重新生成回答时使用
- timestamp为消息发送时间(待完成
- suitable为是否赞同回答(这个是按照openai的策略,实现用户重新回答后评分,暂未完成)
export interface messageType {
idx: number;
loading: boolean;
role: string;
speeches: string[];
timestamp?: number;
suitable: number[];
}
convType
conversation就是在多个message的集合加上几个属性进行扩展,
- id这里的id表示的是当前有几个会话
- timestamp表示会话创建时间
- msgList就是messageType的数组
- selected表示该会话是否被选择,可以用于样式的更新
export interface convType {
id: number;
title: string;
timestamp?: number;
msgList?: messageType[];
selected?:boolean;
}
convListType
convList表示多个conversation的集合,这里直接使用conversation组成的数组表示,就不详细写了
代码概述
该界面主要分为三部分,分别是侧边栏,消息显示区和输入框,三部分分别负责不同的内容,其中侧边栏主要负责会话管理,包括会话的增删改;消息显示区主要负责对话和页面的渲染;输入框我在项目中用于与后端的对话和信息传递。
组件之间的通讯我选择使用vuex进行全局变量管理,管理变量如下
conversation: {
id: 0,
title: "",
msgList: [],
},
chatStatus: "available",
chatMsg: "",
conversationList: [],
selectedIdx: -1
在项目中,我使用了eventbus使用发布订阅的模式实现了兄弟组件的通信,用于在一个vue组件中调用另一个vue的方法。
下面我就我完成的这三部分做一个简单的介绍,包括代码设计等。
输入框
功能
简单介绍一下输入框的主要功能吧,主要功能一共有三个
- 信息输入,用户输入信息,输入框要随着输入信息而扩大
- 问题发送,这个功能比较麻烦,需要和后端和其他模块进行交互
- 重新回答:点击之后重新生成回答(待完成)
页面
需要实现的静态页面很简单,一个输入文本的输入框,一个发送和停止可以切换的按钮和一个重新生成对话的按钮,这里就不给静态代码了,太长了。
内部功能
输入框自适应
一开始我直接使用最简单的输入框textarea,结果发现如果输入的字符串比输入框长的话之前的就会被遮住,非常丑,就模仿chatgpt实现了个输入框的自适应。
这里实现逻辑如下:
首先将chatMsg和textarea使用v-model进行双向绑定,对于chatMsg进行监听,在chatMsg变化的时候调用方法改变文本框高度
接着实现修改高度的方法,实现的changeHeight方法如下
changeHeight() {
var elem = this.$refs.inputChat;
elem.style.height = '24px';
var scrollHeight = elem.scrollHeight;
if (24 >= scrollHeight || this.chatMsg.length == 0) {
this.resetHeight();
return;
}
elem.style.removeProperty("overflow-y")
elem.style.height = scrollHeight + 'px';
console.log(scrollHeight)
},
这里使用ref通过引用来修改textarea的样式,scollHeight为元素的真实高度,如果高度≤24表示不用改,否则调用resetHeight方法修改高度
信息发送
发送消息需要实现一个和后端交互的接口,后端接口如下
@RequestMapping("/chat")
public class ChatAiController {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
@GetMapping(value = "/stream", produces = "text/event-stream")
public SseEmitter chatStream(@RequestParam String modelName, @RequestParam String content) {
StreamResponseHandler<AiMessage> handler = new StreamResponseHandler<>();
StreamingChatLanguageModel aimodel= StreamingChatModelEnums.findValue(modelName);
if (aimodel == null) {
throw new IllegalArgumentException("Model not found");
}
aimodel.generate(content, handler);
handler.get();
return handler.getEmitter();
}
}
信息初始化
时间:触发send方法
实现功能:
- [x] 在store中插入两条初始化的信息。
实现流程:生成两条信息,分别为用户提问和ai回答,用户提问的信息内容为chatMsg即输入框中的内容
addInitMessage(){
this.addMessage({
"idx": 0,
"role": "user",
"message": this.chatMsg
})
var message = {
"idx": 0,
"loading": true,
"role": "assistant",
"suitable": [0],
"speeches": [""]
}
this.addMessage(message)
},
后端请求流数据
时间:点击send触发后
实现功能:
- [x] 调用后端流聊天接口
- [x] 调用siderbar模块的newChat方法
- [x] 修改conversation的message
sseBuild(){
var that = this;
var source = this.source = new EventSource(`http://localhost:8080/chat/stream?content=${this.chatMsg}&&model=GLM3`);
var message = this.addMessageToVConv()
source.addEventListener("open", function () {
console.log("connect");
that.chatMsg = "";
console.log("bus-send",that.conversation)
bus.emit('bus-send',that.conversation.id)
});
//如果服务器响应报文中没有指明事件,默认触发message事件
source.addEventListener("message", function (e) {
message = that.conversation.msgList[that.conversation.msgList?.length - 1];
//console.log("conversation",that.conversation)
var content = e.data;
console.log(`resp:(${e.data})`);
// 滚动到最下面
that.handleScrollBottom();
message["speeches"][0] += content
that.refreshConversation();
});
source.addEventListener('complete', (event) => {
source.close();
message["loading"] = false;
that.convLoading = false;
console.log(message)
that.refreshConversation();
that.refreshMessage(message)
//that.refreshListByConv()
that.source = undefined;
});
//发生错误,则会触发error事件
source.addEventListener("error", function (e) {
console.log("error:" + e.data);
source.close();
that.source = undefined;
});
},
发送
时间:点击send调用
实现
- [x] 修改loading状态
- [x] 加入初始化信息
- [x] 滚动到最下面
- [x] 调用chat或者streamchat
send() {
if (this.chatMsg.trim().length == 0) {
return;
}
if (this.convLoading) {
return;
}
this.convLoading = true;
this.chatMsg = this.chatMsg.trim().replace(/\\n/g, "")
this.addInitMessage()
// 滚动到最下面
this.handleScrollBottom();
//this.streamChat();
this.chat();
//this.convLoading = false;
},
侧边栏
侧边栏需要实现会话组的管理和显示,具体来说需要实现的功能有显示会话,新建会话,添加会话,删除会话,修改会话标题等。
修改标题
修改标题这个功能实现需要首先添加一个修改的图标,在点击修改图标之后会触发editTitle方法,将该会话的editable的值改为true,同时设置convTitletmp变量的值为当前的会话title,而当editable为true的时候,会触发v-if的渲染,将原来的text改成一个输入框,内容就是convTitiletmp,editTitle方法如下。
editTitle(idx) {
if (idx < 0 || idx >= this.conversations.length) {
alert("invalid index")
console.log("invalid index")
return;
}
var conv = this.conversations[idx];
this.convTitletmp = conv.title;
conv.editable = true;
this.conversations[idx] = conv;
setTimeout(() => {
document.getElementById("titleInput").focus();
}, 150)
},
取消修改
下面再说下取消修改的cancelChange方法,
触发方式:有两种,一种是在输入的时候丢失 了焦点调用,另一种是自己取消修改
cancelChangeConvTitle(idx, conv) {
conv.editable = false;
this.conversations[idx] = conv;
},
确认修改
输入:会话在列表中的位置idx,会话对象
实现功能:
- [x] vuex中conversation修改
- [x] vuex中conversationList修改
- [x] localstorage中conversationList修改
具体实现:修改会话对象的标题,调用方法修改vuex中全局的conversationList对象,调用saveConversations方法,把conversations保存到loacalStorage中。
触发方式:在编辑完成新的会话标题之后点击确认调用
changeConvTitle(idx, conv) {
conv.title = this.convTitletmp;
this.setConversationByIndex({index:idx,newConv:conv});
this.saveConversations();
this.cancelChangeConvTitle(idx, conv)
},
会话删除
delConversationByIndex(state:stateType, index:number) {
if (state.conversationList != null&&state.conversationList.length>index)
state.conversationList.splice(index, 1);
else console.log("delConversationByIndex fail",state.conversationList,index);
},
会话选择
传参:
实现功能
- [x] 选中后高亮
- [x] 页面中显示当前会话内容
实现简述:
首先判断是否可以可以选中cidx是否合法,之后添加属性表示选中并判断是否真实切换会话(可能是当前会话),旧会话切换选中属性,最后将vuex中的会话更新为当前会话
selectConversation(cidx) {
var that = this;
if (cidx < 0 || cidx >= this.getConversationList.length) {
console.log("invalid index")
return;
}
//get the conversation which you want to select by index
var conv = this.getConversationByIndex(cidx);
console.log("selectConversation", cidx, conv)
//if the conversation is already selected, return
if (this.oldConv && this.oldConv.id == conv.id) {
console.log("same conversation")
return;
}
//set the old conversation to unselected
if (this.oldConv) {
this.oldConv.selected = false;
}
//set the new conversation to selected
conv.selected = true
this.setSelectedIdx(cidx);
this.oldConv = conv;
document.title = conv.title || "chatai";
this.chatTitle = conv.title || "chatai";
//set the conversation to vuex
this.setConversation(conv);
this.conversations[cidx] = conv;
},
提供服务
新建chat
调用情形:当inputbox中触发send方法,且当前并未有会话时。
实现原理:通过bus总线进行兄弟组件之间的消息传递
实现流程:通过bus注册bus-send方法,当该方法被触发的时候调用busSend方法,表示inputBox模块触发了send方法。busSend方法先获取当前vuex中的conversation的id,再在conversationList中找这个会话,如果没找到就会新建一个会话,同时新会话的messageList为当前的messageList
报错
bug
焦点丢失
对于conversation 的title修改中,我一开始设置焦点丢失后立即调用canceledit方法,结束修改,但是在点击确定修改按钮后,也会先触发焦点丢失的取消修改方法,导致变量没被修改,修改失败
解决方法就是设置一个延时,让在失去焦点之后,不会立即调用取消修改的方法,而是在半秒钟之后取消。
传参
又是一个痛苦的bug调试了好久,才发现是store中的全局变量mutation和action都是只能传一个参数的,解决方法就是使用对象传递多个参数。。。累死了。。还是vue不太熟练