implement conversation storage feature

This commit is contained in:
Zhang Minghan 2023-08-14 12:50:32 +08:00
parent 76e43b1d3e
commit f2be2ba82a
16 changed files with 541 additions and 60 deletions

View File

@ -14,6 +14,7 @@ import (
type WebsocketAuthForm struct {
Token string `json:"token" binding:"required"`
Id int64 `json:"id" binding:"required"`
}
func SendSegmentMessage(conn *websocket.Conn, message types.ChatGPTSegmentResponse) {
@ -62,7 +63,21 @@ func ChatAPI(c *gin.Context) {
}
db := c.MustGet("db").(*sql.DB)
instance := conversation.NewConversation(db, user.GetID(db))
var instance *conversation.Conversation
if form.Id == -1 {
// create new conversation
instance = conversation.NewConversation(db, user.GetID(db))
} else {
// load conversation
instance = conversation.LoadConversation(db, user.GetID(db), form.Id)
if instance == nil {
SendSegmentMessage(conn, types.ChatGPTSegmentResponse{
Message: "Conversation not found.",
End: true,
})
return
}
}
for {
_, message, err = conn.ReadMessage()

View File

@ -81,5 +81,8 @@ func SearchWeb(message []types.ChatGPTMessage) string {
Content: message[len(message)-1].Content,
}}, 40)
keyword := utils.UnmarshalJson[map[string]interface{}](resp)
if keyword == nil {
return ""
}
return StringCleaner(keyword["keyword"].(string))
}

View File

@ -3,23 +3,20 @@ import Login from "./components/icons/login.vue";
import { auth, username } from "./assets/script/auth";
import Light from "./components/icons/light.vue";
import Star from "./components/icons/star.vue";
import { mobile, gpt4 } from "./assets/script/shared";
import Post from "./components/icons/post.vue";
import {mobile, gpt4, list, manager} from "./assets/script/shared";
import Github from "./components/icons/github.vue";
import Heart from "./components/icons/heart.vue";
import Chat from "./components/icons/chat.vue";
import Delete from "./components/icons/delete.vue";
import { deleteConversation } from "./assets/script/api";
const current = manager.getCurrent();
function goto() {
window.location.href = "https://deeptrain.net/login?app=chatnio";
}
function toggle(n: boolean) {
if (mobile.value) {
gpt4.value = !gpt4.value;
} else {
gpt4.value = n;
}
gpt4.value = mobile.value ? !gpt4.value : n;
if (gpt4.value && !auth.value) return goto();
}
</script>
@ -45,6 +42,18 @@ function toggle(n: boolean) {
<heart />
捐助我们
</a>
<div class="conversation-container" v-if="auth && !mobile">
<div class="conversation"
v-for="(conversation, idx) in list" :key="idx"
:class="{'active': current === conversation.id}"
@click="manager.toggle(conversation.id)"
>
<chat class="icon" />
<div class="title">{{ conversation.name }}</div>
<div class="id">{{ conversation.id + 1 }}</div>
<delete class="delete" @click="deleteConversation(conversation.id)" />
</div>
</div>
<div class="grow" />
<div class="user" v-if="auth">
<img class="avatar" :src="'https://api.deeptrain.net/avatar/' + username" alt="">
@ -189,6 +198,115 @@ aside {
stroke: rgb(255, 110, 122);
}
.conversation-container {
display: flex;
flex-direction: column;
align-items: center;
width: 235px;
height: 100%;
margin: 12px auto;
padding: 10px 12px;
border-radius: 8px;
gap: 8px;
background: rgba(72, 73, 85, 0.1);
overflow-x: hidden;
overflow-y: auto;
touch-action: pan-y;
scrollbar-width: thin;
}
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-thumb:hover {
background-color: #555;
}
.conversation {
display: flex;
flex-direction: row;
width: calc(100% - 18px);
height: 28px;
padding: 8px 12px;
margin: 0 16px;
border-radius: 8px;
background: rgba(152, 153, 165, 0.05);
transition: .15s;
cursor: pointer;
user-select: none;
vertical-align: center;
flex-shrink: 0;
align-items: center;
overflow: hidden;
}
.conversation:hover {
background: rgba(152, 153, 165, 0.2);
}
.conversation.active {
background: rgba(152, 153, 165, 0.3);
}
.conversation .icon {
width: 1rem;
height: 1rem;
stroke: var(--card-text);
flex-shrink: 0;
user-select: none;
margin-left: 4px;
margin-right: 2px;
transform: translateY(2px);
}
.conversation .title {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
color: var(--card-text);
user-select: none;
margin: 0 4px;
}
.conversation .id::before {
content: '#';
}
.conversation .id {
width: max-content;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
color: rgb(152, 153, 165);
user-select: none;
margin: 0 4px;
}
.conversation:hover .id {
display: none;
}
.conversation .delete {
width: 1rem;
height: 1rem;
stroke: rgb(182, 183, 205);
flex-shrink: 0;
user-select: none;
margin-left: 4px;
margin-right: 2px;
transform: translateY(2px);
display: none;
transition: .25s;
}
.conversation:hover .delete {
display: block;
}
.model {
display: flex;
align-items: center;

View File

@ -0,0 +1,32 @@
import axios from "axios";
import {list} from "./shared";
type Message = {
content: string;
role: string;
}
export type ConversationInstance = {
id: number;
name: string;
message?: Message[];
}
export async function getConversationList(): Promise<ConversationInstance[]> {
const resp = await axios.get("/conversation/list");
if (resp.data.status) return resp.data.data as ConversationInstance[];
return [];
}
export async function loadConversation(id: number): Promise<ConversationInstance> {
const resp = await axios.get(`/conversation/load?id=${id}`);
if (resp.data.status) return resp.data.data as ConversationInstance;
return { id, name: "" };
}
export async function deleteConversation(id: number): Promise<boolean> {
const resp = await axios.get(`/conversation/delete?id=${id}`);
if (!resp.data.status) return false;
list.value = list.value.filter((item) => item.id !== id);
return true;
}

View File

@ -5,7 +5,7 @@ import { auth, token } from "./auth";
import { ws_api } from "./conf";
import { gpt4 } from "./shared";
type Message = {
export type Message = {
content: string;
role: string;
time: string;
@ -23,10 +23,12 @@ type StreamMessage = {
export class Connection {
protected connection: WebSocket | undefined;
protected callback?: (message: StreamMessage) => void;
public id: number;
public state: boolean;
public constructor() {
public constructor(id: number) {
this.state = false;
this.id = id;
this.init();
}
@ -37,6 +39,7 @@ export class Connection {
this.state = true;
this.send({
token: token.value,
id: this.id,
})
}
this.connection.onclose = () => {
@ -71,25 +74,40 @@ export class Connection {
}
export class Conversation {
id: number;
title: Ref<string>;
messages: Message[];
len: Ref<number>;
state: Ref<boolean>;
refresh: () => void;
refresh?: () => void;
connection: Connection | undefined;
public constructor(id: number, refresh: () => void) {
public constructor(id: number, refresh?: () => void) {
this.id = id;
this.messages = reactive([]);
this.state = ref(false);
this.len = ref(0);
this.refresh = refresh;
if (auth.value) this.connection = new Connection();
this.title = ref("new chat");
if (auth.value) this.connection = new Connection(id);
}
public setRefresh(refresh: () => void): void {
this.refresh = refresh;
}
public setTitle(title: string): void {
this.title.value = title;
}
public notReady(): boolean {
return Boolean(auth.value && !this.connection?.state);
}
public setMessages(messages: Message[]): void {
this.messages = reactive(messages);
this.len = ref(messages.length);
}
public async send(content: string): Promise<void> {
if (this.notReady()) {
const apply = () => {
@ -125,7 +143,6 @@ export class Conversation {
this.addMessageFromUser(content);
try {
const res = await axios.post("/anonymous", {
"id": this.id,
"message": content,
});
if (res.data.status === true) {
@ -151,7 +168,7 @@ export class Conversation {
gpt4: gpt4.value,
})
nextTick(() => {
this.refresh();
this.refresh && this.refresh();
}).then(r => 0);
}
@ -184,7 +201,7 @@ export class Conversation {
const interval = setInterval(() => {
this.messages[index].content = content.substring(0, cursor);
cursor++;
this.refresh();
this.refresh && this.refresh();
if (cursor > content.length) {
this.state.value = false;
clearInterval(interval);
@ -205,10 +222,14 @@ export class Conversation {
if (cursor >= content.value.length) return;
cursor++;
this.messages[index].content = content.value.substring(0, cursor);
this.refresh();
this.refresh && this.refresh();
}, 20);
}
public getTitle(): Ref<string> {
return this.title;
}
public getMessages(): Message[] {
return this.messages;
}

View File

@ -0,0 +1,140 @@
import {nextTick, reactive, ref, watch} from "vue";
import type { Ref } from "vue";
import {Conversation} from "./conversation";
import type { Message } from "./conversation";
import {loadConversation} from "./api";
import {auth} from "./auth";
function convert(message: { content: string, role: string }): Message {
return {
content: message.content,
role: message.role,
time: new Date().toLocaleTimeString(),
stamp: new Date().getTime(),
} as Message;
}
export class Manager {
public current: Ref<number>;
public title: Ref<string>;
public state: Ref<boolean>;
public length: Ref<number>;
public messages: Message[];
public refresh?: () => void;
conversations: Record<number, Conversation>
public constructor(refresh?: () => void) {
this.refresh = refresh;
this.conversations = {};
this.current = ref(NaN);
this.title = ref("new chat");
this.state = ref(false);
this.length = ref(0);
this.messages = reactive([]);
watch(auth, () => {
this.init();
})
}
public init(): void {
this.conversations[-1] = new Conversation(-1, this.refresh);
this.set(-1);
}
public setRefresh(refresh: () => void): void {
this.refresh = refresh;
for (const conversation in this.conversations) this.conversations[conversation].setRefresh(refresh);
}
protected refreshMessages(message: Message[]): void {
this.messages = message;
}
protected refreshState(state: Ref<boolean>): void {
this.state.value = state.value;
watch(state, () => {
this.state.value = this.conversations[this.current.value].getState().value;
})
}
protected refreshLength(length: Ref<number>): void {
this.length.value = length.value;
watch(length, () => {
this.length.value = this.conversations[this.current.value].getLength().value;
})
}
protected refreshTitle(title: Ref<string>): void {
this.title.value = title.value;
watch(title, () => {
this.title.value = this.conversations[this.current.value].getTitle().value;
})
}
public set(id: number): void {
if (this.current.value === id) return;
this.current.value = id;
if (!this.conversations[id]) this.conversations[id] = new Conversation(id, this.refresh);
this.refreshState(this.conversations[id].getState());
this.refreshLength(this.conversations[id].getLength());
this.refreshTitle(this.conversations[id].getTitle());
this.refreshMessages(this.conversations[id].getMessages());
nextTick(() => {
this.refresh && this.refresh();
}).then();
}
public getState(): Ref<boolean> {
return this.state;
}
public getLength(): Ref<number> {
return this.length;
}
public getMessages(): Message[] {
return this.messages;
}
public getConversations(): Record<number, Conversation> {
return this.conversations;
}
public getTitle(): Ref<string> {
return this.title;
}
public getCurrent(): Ref<number> {
return this.current;
}
public async add(id: number): Promise<void> {
if (this.conversations[id]) return;
const instance = new Conversation(id, this.refresh);
this.conversations[id] = instance;
const res = await loadConversation(id);
instance.setMessages(res.message?.map(convert) || []);
instance.setTitle(res.name);
}
public async toggle(id: number): Promise<void> {
if (this.current.value === id) return;
if (!this.conversations[id]) await this.add(id);
this.set(id);
}
public delete(id: number): void {
if (this.current.value === id) this.set(-1);
delete this.conversations[id];
}
public async send(content: string): Promise<void> {
await this.conversations[this.current.value].send(content);
}
public get(id: number): Conversation {
return this.conversations[id];
}
}

View File

@ -1,8 +1,18 @@
import {ref} from "vue";
import { ref, watch } from "vue";
import type { ConversationInstance } from "./api";
import { auth } from "./auth";
import { getConversationList } from "./api";
import {Manager} from "./manager";
export const mobile = ref<boolean>((document.body.clientWidth < document.body.clientHeight) && (document.body.clientWidth < 600));
export const gpt4 = ref(false);
export const list = ref<ConversationInstance[]>([]);
export const manager = new Manager();
window.addEventListener("resize", () => {
mobile.value = (document.body.clientWidth < document.body.clientHeight) && (document.body.clientWidth < 600);
})
watch(auth, async () => {
if (!auth.value) return;
list.value = await getConversationList();
});

View File

@ -0,0 +1,3 @@
<template>
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
</template>

View File

@ -0,0 +1,3 @@
<template>
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
</template>

View File

@ -3,14 +3,21 @@ import 'md-editor-v3/lib/style.css';
import Post from "../components/icons/post.vue";
import Openai from "../components/icons/openai.vue";
import { MdPreview } from 'md-editor-v3';
import {Conversation} from "../assets/script/conversation";
import {nextTick, onMounted, ref} from "vue";
import {auth, username} from "../assets/script/auth";
import {nextTick, onMounted, ref, watch} from "vue";
import { auth, username } from "../assets/script/auth";
import Loading from "../components/icons/loading.vue";
import Bing from "../components/icons/bing.vue";
import { manager } from "../assets/script/shared";
const state = manager.getState(), length = manager.getLength(), current = manager.getCurrent();
manager.setRefresh(function refreshScrollbar() {
nextTick(() => {
if (!chatEl.value) return;
const el = chatEl.value as HTMLElement;
el.scrollTop = el.scrollHeight;
})
});
const conversation = new Conversation(1, refreshScrollbar);
const state = conversation.getState(), length = conversation.getLength(), messages = conversation.getMessages();
const input = ref("");
const inputEl = ref<HTMLElement | undefined>();
const chatEl = ref<HTMLElement | undefined>();
@ -19,18 +26,10 @@ async function send() {
let val = input.value.trim();
if (val && !state.value) {
input.value = "";
await conversation.send(val);
await manager.send(val);
}
}
function refreshScrollbar() {
nextTick(() => {
if (!chatEl.value) return;
const el = chatEl.value as HTMLElement;
el.scrollTop = el.scrollHeight;
})
}
onMounted(() => {
if (!inputEl.value) return;
const param = new URLSearchParams(window.location.search);
@ -50,27 +49,29 @@ onMounted(() => {
<template>
<div class="chat-wrapper" ref="chatEl">
<div class="conversation" v-if="length">
<template v-for="(message, index) in messages" :key="index">
<div class="time" v-if="index === 0 || message.stamp - messages[index - 1].stamp > 10 * 60 * 1000">
{{ message.time }}
</div>
<div class="message" :class="{'user': message.role === 'user'}">
<div class="grow" v-if="message.role === 'user'"></div>
<div class="avatar openai" :class="{'gpt4': message.gpt4}" v-else><openai /></div>
<div class="content">
<div v-if="message.role === 'bot' && message.keyword !== ''" class="bing">
<bing />
{{ message.keyword }}
<template v-for="(conversation, i, k) in manager.conversations" :key="k">
<template v-for="(message, index) in conversation.messages" :key="index" v-if="current === conversation.id">
<div class="time" v-if="index === 0 || message.stamp - conversation.messages[index - 1].stamp > 10 * 60 * 1000">
{{ message.time }}
</div>
<div class="message" :class="{'user': message.role === 'user'}">
<div class="grow" v-if="message.role === 'user'"></div>
<div class="avatar openai" :class="{'gpt4': message.gpt4}" v-else><openai /></div>
<div class="content">
<div v-if="message.role === 'bot' && message.keyword?.trim() !== ''" class="bing">
<bing />
{{ message.keyword }}
</div>
<div class="loader" v-if="!message.content" />
<span v-if="message.role === 'user'">{{ message.content }}</span>
<md-preview v-model="message.content" theme="dark" v-else />
</div>
<div class="avatar user" v-if="message.role === 'user'">
<img :src="'https://api.deeptrain.net/avatar/' + username" alt="" v-if="auth">
<img src="/favicon.ico" alt="" v-else>
</div>
<div class="loader" v-if="!message.content" />
<span v-if="message.role === 'user'">{{ message.content }}</span>
<md-preview v-model="message.content" theme="dark" v-else />
</div>
<div class="avatar user" v-if="message.role === 'user'">
<img :src="'https://api.deeptrain.net/avatar/' + username" alt="" v-if="auth">
<img src="/favicon.ico" alt="" v-else>
</div>
</div>
</template>
</template>
</div>
<div class="preview" v-else>

View File

@ -24,3 +24,16 @@ func Middleware() gin.HandlerFunc {
c.Next()
}
}
func GetToken(c *gin.Context) string {
return c.GetString("token")
}
func GetUser(c *gin.Context) *User {
if c.GetBool("auth") {
return &User{
Username: c.GetString("user"),
}
}
return nil
}

96
conversation/api.go Normal file
View File

@ -0,0 +1,96 @@
package conversation
import (
"chat/auth"
"database/sql"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
)
func ListAPI(c *gin.Context) {
user := auth.GetUser(c)
if user == nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "user not found",
})
return
}
db := c.MustGet("db").(*sql.DB)
conversations := LoadConversationList(db, user.GetID(db))
c.JSON(http.StatusOK, gin.H{
"status": true,
"message": "",
"data": conversations,
})
}
func LoadAPI(c *gin.Context) {
user := auth.GetUser(c)
if user == nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "user not found",
})
return
}
db := c.MustGet("db").(*sql.DB)
id, err := strconv.ParseInt(c.Query("id"), 10, 64)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "invalid id",
})
return
}
conversation := LoadConversation(db, user.GetID(db), id)
if conversation == nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "conversation not found",
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": true,
"message": "",
"data": conversation,
})
}
func DeleteAPI(c *gin.Context) {
user := auth.GetUser(c)
if user == nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "user not found",
})
return
}
db := c.MustGet("db").(*sql.DB)
id, err := strconv.ParseInt(c.Query("id"), 10, 64)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "invalid id",
})
return
}
conversation := LoadConversation(db, user.GetID(db), id)
if conversation == nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"message": "conversation not found",
})
return
}
conversation.DeleteConversation(db)
c.JSON(http.StatusOK, gin.H{
"status": true,
"message": "",
})
}

View File

@ -21,7 +21,7 @@ type FormMessage struct {
func NewConversation(db *sql.DB, id int64) *Conversation {
return &Conversation{
UserID: id,
Id: GetConversationLengthByUserID(db, id),
Id: GetConversationLengthByUserID(db, id) + 1,
Name: "new chat",
Message: []types.ChatGPTMessage{},
}
@ -120,11 +120,14 @@ func (c *Conversation) AddMessageFromUserForm(data []byte) (string, error) {
}
func (c *Conversation) HandleMessage(db *sql.DB, data []byte) bool {
_, err := c.AddMessageFromUserForm(data)
head := len(c.Message) == 0
msg, err := c.AddMessageFromUserForm(data)
if err != nil {
return false
}
if head {
c.SetName(db, msg)
}
c.SaveConversation(db)
return true
}

View File

@ -51,7 +51,12 @@ func LoadConversationList(db *sql.DB, userId int64) []Conversation {
if err != nil {
return conversationList
}
defer rows.Close()
defer func(rows *sql.Rows) {
err := rows.Close()
if err != nil {
return
}
}(rows)
for rows.Next() {
var conversation Conversation
@ -62,5 +67,13 @@ func LoadConversationList(db *sql.DB, userId int64) []Conversation {
conversationList = append(conversationList, conversation)
}
return conversationList
return utils.Reverse(conversationList)
}
func (c *Conversation) DeleteConversation(db *sql.DB) bool {
_, err := db.Exec("DELETE FROM conversation WHERE user_id = ? AND conversation_id = ?", c.UserID, c.Id)
if err != nil {
return false
}
return true
}

View File

@ -4,6 +4,7 @@ import (
"chat/api"
"chat/auth"
"chat/connection"
"chat/conversation"
"chat/middleware"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
@ -26,7 +27,9 @@ func main() {
app.GET("/chat", api.ChatAPI)
app.POST("/login", auth.LoginAPI)
app.POST("/state", auth.StateAPI)
app.GET("/conversation/list", conversation.ListAPI)
app.GET("/conversation/load", conversation.LoadAPI)
app.GET("/conversation/delete", conversation.DeleteAPI)
}
if viper.GetBool("debug") {
gin.SetMode(gin.DebugMode)

View File

@ -85,3 +85,10 @@ func GetLatestSegment[T any](arr []T, length int) []T {
}
return arr[len(arr)-length:]
}
func Reverse[T any](arr []T) []T {
for i := 0; i < len(arr)/2; i++ {
arr[i], arr[len(arr)-i-1] = arr[len(arr)-i-1], arr[i]
}
return arr
}