1. 发起 socket 连接时候传入 chatId 来区分会话。

2. Chat-Plus 界面新增复制回复内容按钮。
3. 优化 ChatFree 聊天会话切换和管理逻辑。
This commit is contained in:
RockYang 2023-04-21 11:21:41 +08:00
parent 00db78b446
commit d8bc0fe125
9 changed files with 168 additions and 80 deletions

View File

@ -29,7 +29,9 @@ func (s *Server) ChatHandle(c *gin.Context) {
} }
sessionId := c.Query("sessionId") sessionId := c.Query("sessionId")
roleKey := c.Query("role") roleKey := c.Query("role")
chatId := c.Query("chatId")
session, ok := s.ChatSession[sessionId] session, ok := s.ChatSession[sessionId]
session.ChatId = chatId
if !ok { // 用户未登录 if !ok { // 用户未登录
c.Abort() c.Abort()
return return
@ -61,18 +63,18 @@ func (s *Server) ChatHandle(c *gin.Context) {
return return
} }
logger.Info("Receive a message: ", string(message)) logger.Info("Receive a message: ", string(message))
//replyMessage(client, "当前 TOKEN 无效,请使用合法的 TOKEN 登录!", false) replyMessage(client, "当前 TOKEN 无效,请使用合法的 TOKEN 登录!", false)
//replyMessage(client, "![](images/wx.png)", true) replyMessage(client, "![](images/wx.png)", false)
ctx, cancel := context.WithCancel(context.Background()) //ctx, cancel := context.WithCancel(context.Background())
s.ReqCancelFunc[sessionId] = cancel //s.ReqCancelFunc[sessionId] = cancel
// 回复消息 //// 回复消息
err = s.sendMessage(ctx, session, chatRole, string(message), client, false) //err = s.sendMessage(ctx, session, chatRole, string(message), client, false)
if err != nil { //if err != nil {
logger.Error(err) // logger.Error(err)
} else { //} else {
replyChunkMessage(client, types.WsMessage{Type: types.WsEnd, IsHelloMsg: false}) // replyChunkMessage(client, types.WsMessage{Type: types.WsEnd, IsHelloMsg: false})
logger.Info("回答完毕: " + string(message)) // logger.Info("回答完毕: " + string(message))
} //}
} }
}() }()
@ -94,20 +96,20 @@ func (s *Server) sendMessage(ctx context.Context, session types.ChatSession, rol
if user.Status == false { if user.Status == false {
replyMessage(ws, "当前 TOKEN 已经被禁用,如果疑问,请联系管理员!", false) replyMessage(ws, "当前 TOKEN 已经被禁用,如果疑问,请联系管理员!", false)
replyMessage(ws, "![](images/wx.png)", true) replyMessage(ws, "![](images/wx.png)", false)
return errors.New("当前 TOKEN " + user.Name + "已经被禁用") return errors.New("当前 TOKEN " + user.Name + "已经被禁用")
} }
if time.Now().Unix() > user.ExpiredTime { if time.Now().Unix() > user.ExpiredTime {
exTime := time.Unix(user.ExpiredTime, 0).Format("2006-01-02 15:04:05") exTime := time.Unix(user.ExpiredTime, 0).Format("2006-01-02 15:04:05")
replyMessage(ws, "当前 TOKEN 已过期,过期时间为:"+exTime+",如果疑问,请联系管理员!", false) replyMessage(ws, "当前 TOKEN 已过期,过期时间为:"+exTime+",如果疑问,请联系管理员!", false)
replyMessage(ws, "![](images/wx.png)", true) replyMessage(ws, "![](images/wx.png)", false)
return errors.New("当前 TOKEN " + user.Name + "已过期") return errors.New("当前 TOKEN " + user.Name + "已过期")
} }
if user.MaxCalls > 0 && user.RemainingCalls <= 0 { if user.MaxCalls > 0 && user.RemainingCalls <= 0 {
replyMessage(ws, "当前 TOKEN 点数已经用尽,加入我们的知识星球可以免费领取点卡!", false) replyMessage(ws, "当前 TOKEN 点数已经用尽,加入我们的知识星球可以免费领取点卡!", false)
replyMessage(ws, "![](images/start.png)", true) replyMessage(ws, "![](images/start.png)", false)
return nil return nil
} }
var req = types.ApiRequest{ var req = types.ApiRequest{
@ -117,7 +119,7 @@ func (s *Server) sendMessage(ctx context.Context, session types.ChatSession, rol
Stream: true, Stream: true,
} }
var chatCtx []types.Message var chatCtx []types.Message
var ctxKey = fmt.Sprintf("%s-%s", session.SessionId, role.Key) var ctxKey = fmt.Sprintf("%s-%s-%s", session.SessionId, role.Key, session.ChatId)
if v, ok := s.ChatContexts[ctxKey]; ok && s.Config.Chat.EnableContext { if v, ok := s.ChatContexts[ctxKey]; ok && s.Config.Chat.EnableContext {
chatCtx = v.Messages chatCtx = v.Messages
} else { } else {
@ -190,7 +192,7 @@ func (s *Server) sendMessage(ctx context.Context, session types.ChatSession, rol
// 如果三次请求都失败的话,则返回对应的错误信息 // 如果三次请求都失败的话,则返回对应的错误信息
if err != nil { if err != nil {
replyMessage(ws, ErrorMsg, false) replyMessage(ws, ErrorMsg, false)
replyMessage(ws, "![](images/wx.png)", true) replyMessage(ws, "![](images/wx.png)", false)
return err return err
} }
@ -237,7 +239,7 @@ func (s *Server) sendMessage(ctx context.Context, session types.ChatSession, rol
if err != nil { // 数据解析出错 if err != nil { // 数据解析出错
logger.Error(err, line) logger.Error(err, line)
replyMessage(ws, ErrorMsg, false) replyMessage(ws, ErrorMsg, false)
replyMessage(ws, "![](images/wx.png)", true) replyMessage(ws, "![](images/wx.png)", false)
break break
} }
@ -473,3 +475,18 @@ func (s *Server) StopGenerateHandle(c *gin.Context) {
delete(s.ReqCancelFunc, sessionId) delete(s.ReqCancelFunc, sessionId)
c.JSON(http.StatusOK, types.BizVo{Code: types.Success}) c.JSON(http.StatusOK, types.BizVo{Code: types.Success})
} }
// GetHelloMsgHandle 获取角色的打招呼信息
func (s *Server) GetHelloMsgHandle(c *gin.Context) {
role := strings.TrimSpace(c.Query("role"))
if role == "" {
c.JSON(http.StatusOK, types.BizVo{Code: types.InvalidParams, Message: "Invalid args"})
return
}
chatRole, err := GetChatRole(role)
if err != nil {
c.JSON(http.StatusOK, types.BizVo{Code: types.Failed, Message: "Role not found"})
return
}
c.JSON(http.StatusOK, types.BizVo{Code: types.Success, Data: chatRole.HelloMsg})
}

View File

@ -94,6 +94,7 @@ func (s *Server) Run(webRoot embed.FS, path string, debug bool) {
engine.POST("api/chat/stop", s.StopGenerateHandle) engine.POST("api/chat/stop", s.StopGenerateHandle)
engine.POST("api/chat/history", s.GetChatHistoryHandle) engine.POST("api/chat/history", s.GetChatHistoryHandle)
engine.POST("api/chat/history/clear", s.ClearHistoryHandle) engine.POST("api/chat/history/clear", s.ClearHistoryHandle)
engine.GET("api/role/hello", s.GetHelloMsgHandle)
engine.POST("api/config/set", s.ConfigSetHandle) engine.POST("api/config/set", s.ConfigSetHandle)
engine.GET("api/config/chat-roles/get", s.GetChatRoleListHandle) engine.GET("api/config/chat-roles/get", s.GetChatRoleListHandle)

View File

@ -46,6 +46,7 @@ type ChatSession struct {
SessionId string `json:"session_id"` SessionId string `json:"session_id"`
ClientIP string `json:"client_ip"` // 客户端 IP ClientIP string `json:"client_ip"` // 客户端 IP
Username string `json:"user"` // 当前登录的 user Username string `json:"user"` // 当前登录的 user
ChatId string `json:"chat_id"` // 客户端聊天会话 ID
} }
// ChatContext 聊天上下文 // ChatContext 聊天上下文

View File

@ -6,16 +6,34 @@
<div class="chat-item"> <div class="chat-item">
<div class="triangle"></div> <div class="triangle"></div>
<div class="content reply-content" :data-clipboard-text="orgContent" v-html="content"></div> <div class="content-box">
<div class="content" v-html="content"></div>
<div class="tool-box">
<el-tooltip
class="box-item"
effect="light"
content="复制回答"
placement="bottom"
>
<el-button type="info" class="copy-reply" :data-clipboard-text="orgContent">
<el-icon>
<DocumentCopy/>
</el-icon>
</el-button>
</el-tooltip>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import {defineComponent} from "vue" import {defineComponent} from "vue"
import {DocumentCopy} from "@element-plus/icons-vue";
export default defineComponent({ export default defineComponent({
name: 'ChatReply', name: 'ChatReply',
components: {DocumentCopy},
props: { props: {
content: { content: {
type: String, type: String,
@ -66,6 +84,11 @@ export default defineComponent({
top: 13px; top: 13px;
} }
.content-box {
display flex
flex-direction row
.content { .content {
min-height 20px; min-height 20px;
word-break break-word; word-break break-word;
@ -93,7 +116,17 @@ export default defineComponent({
p:first-child { p:first-child {
margin-top 0 margin-top 0
} }
}
.tool-box {
padding-left 10px;
font-size 16px;
.el-button {
height 20px
padding 5px 2px;
}
}
} }
} }
} }

View File

@ -8,7 +8,6 @@ import Chat from "@/views/Chat.vue";
import NotFound from './views/404.vue' import NotFound from './views/404.vue'
import TestPage from './views/Test.vue' import TestPage from './views/Test.vue'
import Home from "@/views/Home.vue"; import Home from "@/views/Home.vue";
import './utils/prototype'
import ChatFree from "@/views/ChatFree.vue"; import ChatFree from "@/views/ChatFree.vue";
const routes = [ const routes = [

View File

@ -72,3 +72,17 @@ export function dateFormat(timestamp, format) {
} }
return timeDate; return timeDate;
} }
export function arrayContains(array, value, compare) {
if (typeof compare !== 'function') {
compare = function (v1, v2) {
return v1 === v2;
}
}
for (let i = 0; i < array.length; i++) {
if (compare(array[i], value)) {
return true;
}
}
return false;
}

View File

@ -1,8 +0,0 @@
// add prototype method for array to insert item
Array.prototype.insert = function (index, item) {
this.splice(index, 0, item)
}
Array.prototype.remove = function (index) {
this.splice(index, 1)
}

View File

@ -21,12 +21,12 @@
<span class="text" v-else>{{ chat.title }}</span> <span class="text" v-else>{{ chat.title }}</span>
<span class="btn btn-check" v-if="chat.edit || chat.removing"> <span class="btn btn-check" v-if="chat.edit || chat.removing">
<el-icon @click="confirm(chat)"><Check/></el-icon> <el-icon @click="confirm($event, chat)"><Check/></el-icon>
<el-icon @click="cancel(chat)"><Close/></el-icon> <el-icon @click="cancel($event, chat)"><Close/></el-icon>
</span> </span>
<span class="btn" v-else> <span class="btn" v-else>
<el-icon title="编辑" @click="editChatTitle(chat)"><Edit/></el-icon> <el-icon title="编辑" @click="editChatTitle($event, chat)"><Edit/></el-icon>
<el-icon title="删除会话" @click="removeChat(chat)"><Delete/></el-icon> <el-icon title="删除会话" @click="removeChat($event, chat)"><Delete/></el-icon>
</span> </span>
</a></li> </a></li>
@ -183,7 +183,7 @@ import {
setLoginUser, removeChat, clearChatHistory setLoginUser, removeChat, clearChatHistory
} from "@/utils/storage"; } from "@/utils/storage";
import {ElMessage, ElMessageBox} from "element-plus"; import {ElMessage, ElMessageBox} from "element-plus";
import {isMobile, randString} from "@/utils/libs"; import {arrayContains, isMobile, randString} from "@/utils/libs";
import Clipboard from "clipboard"; import Clipboard from "clipboard";
// ChatGPT // ChatGPT
@ -215,6 +215,7 @@ export default defineComponent({
showLoginDialog: false, showLoginDialog: false,
role: 'gpt', role: 'gpt',
replyIcon: 'images/avatar/yi_yan.png', // replyIcon: 'images/avatar/yi_yan.png', //
helloMsg: '', //
chatList: [], // chatList: [], //
tmpChatTitle: '', tmpChatTitle: '',
@ -264,9 +265,23 @@ export default defineComponent({
this.inputBoxWidth = window.innerWidth - document.getElementById('sidebar').offsetWidth - 20; this.inputBoxWidth = window.innerWidth - document.getElementById('sidebar').offsetWidth - 20;
} }
}); });
//
httpGet("api/role/hello?role=" + this.role).then((res) => {
this.helloMsg = {
type: "reply",
id: randString(32),
icon: this.replyIcon,
content: res.data,
};
})
//
const chatList = getChatList();
if (chatList) {
this.chatList = chatList;
}
//
this.newChat(); this.newChat();
}, },
methods: { methods: {
@ -284,7 +299,7 @@ export default defineComponent({
// WebSocket // WebSocket
const sessionId = getSessionId(); const sessionId = getSessionId();
const socket = new WebSocket(process.env.VUE_APP_WS_HOST + `/api/chat?sessionId=${sessionId}&role=${this.role}`); const socket = new WebSocket(process.env.VUE_APP_WS_HOST + `/api/chat?sessionId=${sessionId}&role=${this.role}&chatId=${this.curChat.id}`);
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
this.sending = false; // this.sending = false; //
this.loading = false; // this.loading = false; //
@ -292,11 +307,10 @@ export default defineComponent({
this.errorMessage.close(); // this.errorMessage.close(); //
} }
this.activelyClose = false; this.activelyClose = false;
//
const chatList = getChatList(); //
if (chatList) { this.chatData.push(this.helloMsg);
this.chatList = chatList; this.fetchChatHistory(this.curChat.id);
}
}); });
socket.addEventListener('message', event => { socket.addEventListener('message', event => {
@ -305,11 +319,6 @@ export default defineComponent({
reader.readAsText(event.data, "UTF-8"); reader.readAsText(event.data, "UTF-8");
reader.onload = () => { reader.onload = () => {
const data = JSON.parse(String(reader.result)); const data = JSON.parse(String(reader.result));
//
if (data['is_hello_msg'] && this.chatData.length > 1) {
return
}
if (data.type === 'start') { if (data.type === 'start') {
this.chatData.push({ this.chatData.push({
type: "reply", type: "reply",
@ -334,9 +343,14 @@ export default defineComponent({
}) })
} }
this.showStopGenerate = false; this.showStopGenerate = false;
this.lineBuffer = ''; // this.lineBuffer = ''; //
//
if (this.curChat.title === '') {
this.curChat.title = this.previousText;
setChat(this.curChat);
}
} else { } else {
this.lineBuffer += data.content; this.lineBuffer += data.content;
this.chatData[this.chatData.length - 1]['orgContent'] = this.lineBuffer; this.chatData[this.chatData.length - 1]['orgContent'] = this.lineBuffer;
@ -400,12 +414,11 @@ export default defineComponent({
}) })
}, },
// //
fetchChatHistory: function (chatId) { fetchChatHistory: function (chatId) {
const list = getChatHistory(chatId); const list = getChatHistory(chatId);
if (list) { if (list) {
const md = require('markdown-it')(); const md = require('markdown-it')();
console.log(list)
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
if (list[i].type === "prompt") { if (list[i].type === "prompt") {
this.chatData.push(list[i]); this.chatData.push(list[i]);
@ -562,16 +575,14 @@ export default defineComponent({
if (chatHistory === null) { if (chatHistory === null) {
return; return;
} }
this.curChat.title = chatHistory[0].content;
//
setChat(this.curChat);
} }
this.appendChat();
this.curChat = { this.curChat = {
id: randString(32), id: randString(32),
edit: false, // edit: false, //
removing: false, // removing: false, //
title: '新会话 - 0' title: ''
}; };
this.chatData = []; this.chatData = [];
@ -582,23 +593,39 @@ export default defineComponent({
this.connect(); this.connect();
}, },
//
appendChat: function () {
if (this.curChat !== null && this.curChat.title !== '') {
const compare = function (v1, v2) {
return v1.id === v2.id;
}
if (!arrayContains(this.chatList, this.curChat, compare)) {
this.chatList[this.curChat.id] = this.curChat;
}
}
},
// //
editChatTitle: function (chat) { editChatTitle: function (event, chat) {
event.stopPropagation();
chat.edit = true; chat.edit = true;
this.curOpt = 'edit'; this.curOpt = 'edit';
this.tmpChatTitle = chat.title; this.tmpChatTitle = chat.title;
}, },
// //
confirm: function (chat) { confirm: function (event, chat) {
event.stopPropagation();
if (this.curOpt === 'edit') { if (this.curOpt === 'edit') {
chat.title = this.tmpChatTitle; chat.title = this.tmpChatTitle;
chat.edit = false; chat.edit = false;
setChat(chat) setChat(chat)
} else if (this.curOpt === 'remove') { } else if (this.curOpt === 'remove') {
delete this.chatList[chat.id]; delete this.chatList[chat.id];
//
if (this.curChat.id === chat.id) { if (this.curChat.id === chat.id) {
this.chatData = []; this.curChat = null;
this.newChat();
} }
removeChat(chat.id); removeChat(chat.id);
chat.removing = false; chat.removing = false;
@ -606,13 +633,15 @@ export default defineComponent({
}, },
// //
cancel: function (chat) { cancel: function (event, chat) {
event.stopPropagation();
chat.edit = false; chat.edit = false;
chat.removing = false; chat.removing = false;
}, },
// //
removeChat: function (chat) { removeChat: function (event, chat) {
event.stopPropagation();
chat.removing = true; chat.removing = true;
this.curOpt = 'remove'; this.curOpt = 'remove';
}, },
@ -623,8 +652,10 @@ export default defineComponent({
return; return;
} }
this.appendChat();
this.chatData = [];
this.curChat = chat; this.curChat = chat;
this.fetchChatHistory(chat.id); this.connect();
}, },
// 退 // 退

View File

@ -253,7 +253,7 @@ export default defineComponent({
return; return;
} }
const clipboard = new Clipboard('.reply-content'); const clipboard = new Clipboard('.copy-reply');
clipboard.on('success', () => { clipboard.on('success', () => {
ElMessage.success('复制成功!'); ElMessage.success('复制成功!');
}) })