diff --git a/Dockerfile b/Dockerfile index 1ccd9dafc..b36f1cdeb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM jumpserver/koko-base:20250513_030103 AS stage-build +FROM jumpserver/koko-base:20250512_022127 AS stage-build WORKDIR /opt/koko ARG TARGETARCH diff --git a/pkg/httpd/chat.go b/pkg/httpd/chat.go index fc2e6b6d1..3d5c4b80a 100644 --- a/pkg/httpd/chat.go +++ b/pkg/httpd/chat.go @@ -1,163 +1,259 @@ package httpd import ( + "context" "encoding/json" + "fmt" "github.com/jumpserver/koko/pkg/common" + "github.com/jumpserver/koko/pkg/i18n" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/proxy" + "github.com/jumpserver/koko/pkg/session" "github.com/sashabaranov/go-openai" "sync" "time" "github.com/jumpserver/koko/pkg/jms-sdk-go/model" - "github.com/jumpserver/koko/pkg/logger" "github.com/jumpserver/koko/pkg/srvconn" ) var _ Handler = (*chat)(nil) type chat struct { - ws *UserWebsocket + ws *UserWebsocket + term *model.TerminalConfig - conversationMap sync.Map - - termConf *model.TerminalConfig + // conversationMap: map[conversationID]*AIConversation + conversations sync.Map } func (h *chat) Name() string { return ChatName } -func (h *chat) CleanUp() { - h.CleanConversationMap() -} +func (h *chat) CleanUp() { h.cleanupAll() } func (h *chat) CheckValidation() error { return nil } func (h *chat) HandleMessage(msg *Message) { - conversationID := msg.Id - conversation := &AIConversation{} - - if conversationID == "" { - id := common.UUID() - conversation = &AIConversation{ - Id: id, - Prompt: msg.Prompt, - HistoryRecords: make([]string, 0), - InterruptCurrentChat: false, - } + if msg.Interrupt { + h.interrupt(msg.Id) + return + } - // T000 Currently a websocket connection only retains one conversation - h.CleanConversationMap() - h.conversationMap.Store(id, conversation) - } else { - c, ok := h.conversationMap.Load(conversationID) - if !ok { - logger.Errorf("Ws[%s] conversation %s not found", h.ws.Uuid, conversationID) - h.sendErrorMessage(conversationID, "conversation not found") - return + conv, err := h.getOrCreateConversation(msg) + if err != nil { + h.sendError(msg.Id, err.Error()) + return + } + conv.Question = msg.Data + conv.NewDialogue = true + + go h.runChat(conv) +} + +func (h *chat) getOrCreateConversation(msg *Message) (*AIConversation, error) { + if msg.Id != "" { + if v, ok := h.conversations.Load(msg.Id); ok { + return v.(*AIConversation), nil } - conversation = c.(*AIConversation) + return nil, fmt.Errorf("conversation %s not found", msg.Id) } - if msg.Interrupt { - conversation.InterruptCurrentChat = true - return + jmsSrv, err := proxy.NewChatJMSServer( + h.ws.user.String(), h.ws.ClientIP(), + h.ws.user.ID, h.ws.langCode, h.ws.apiClient, h.term, + ) + if err != nil { + return nil, fmt.Errorf("create JMS server: %w", err) } - openAIParam := &OpenAIParam{ - AuthToken: h.termConf.GptApiKey, - BaseURL: h.termConf.GptBaseUrl, - Proxy: h.termConf.GptProxy, - Model: h.termConf.GptModel, - Prompt: conversation.Prompt, + sess := session.NewSession(jmsSrv.Session, h.sessionCallback) + session.AddSession(sess) + + conv := &AIConversation{ + Id: jmsSrv.Session.ID, + Prompt: msg.Prompt, + Model: msg.ChatModel, + Context: make([]QARecord, 0), + JMSServer: jmsSrv, + } + h.conversations.Store(jmsSrv.Session.ID, conv) + go h.Monitor(conv) + return conv, nil +} + +func (h *chat) sessionCallback(task *model.TerminalTask) error { + if task.Name == model.TaskKillSession { + h.endConversation(task.Args, "close", "kill session") + return nil } - conversation.HistoryRecords = append(conversation.HistoryRecords, msg.Data) - go h.chat(openAIParam, conversation) + return fmt.Errorf("unknown session task %s", task.Name) } -func (h *chat) chat( - chatGPTParam *OpenAIParam, conversation *AIConversation, -) string { - doneCh := make(chan string) - answerCh := make(chan string) - defer close(doneCh) - defer close(answerCh) +func (h *chat) runChat(conv *AIConversation) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() - c := srvconn.NewOpenAIClient( - chatGPTParam.AuthToken, - chatGPTParam.BaseURL, - chatGPTParam.Proxy, + client := srvconn.NewOpenAIClient( + h.term.GptApiKey, h.term.GptBaseUrl, h.term.GptProxy, ) - startIndex := len(conversation.HistoryRecords) - 15 - if startIndex < 0 { - startIndex = 0 + // Keep the last 8 contexts + if len(conv.Context) > 8 { + conv.Context = conv.Context[len(conv.Context)-8:] } - contents := conversation.HistoryRecords[startIndex:] + messages := buildChatMessages(conv) - openAIConn := &srvconn.OpenAIConn{ - Id: conversation.Id, - Client: c, - Prompt: chatGPTParam.Prompt, - Model: chatGPTParam.Model, - Contents: contents, + chatModel := conv.Model + if conv.Model == "" { + chatModel = h.term.GptModel + } + + conn := &srvconn.OpenAIConn{ + Id: conv.Id, + Client: client, + Prompt: conv.Prompt, + Model: chatModel, + Question: conv.Question, + Context: messages, + AnswerCh: make(chan string), + DoneCh: make(chan string), IsReasoning: false, - AnswerCh: answerCh, - DoneCh: doneCh, - Type: h.termConf.ChatAIType, + Type: h.term.ChatAIType, } - go openAIConn.Chat(&conversation.InterruptCurrentChat) - return h.processChatMessages(openAIConn) + // 启动 streaming + go conn.Chat(&conv.InterruptCurrentChat) + + conv.JMSServer.Replay.WriteInput(conv.Question) + + h.streamResponses(ctx, conv, conn) +} + +func buildChatMessages(conv *AIConversation) []openai.ChatCompletionMessage { + msgs := make([]openai.ChatCompletionMessage, 0, len(conv.Context)*2) + for _, r := range conv.Context { + msgs = append(msgs, + openai.ChatCompletionMessage{Role: openai.ChatMessageRoleUser, Content: r.Question}, + openai.ChatCompletionMessage{Role: openai.ChatMessageRoleAssistant, Content: r.Answer}, + ) + } + return msgs } -func (h *chat) processChatMessages( - openAIConn *srvconn.OpenAIConn, -) string { - messageID := common.UUID() - id := openAIConn.Id +func (h *chat) streamResponses( + ctx context.Context, conv *AIConversation, conn *srvconn.OpenAIConn, +) { + msgID := common.UUID() for { select { - case answer := <-openAIConn.AnswerCh: - h.sendSessionMessage(id, answer, messageID, "message", openAIConn.IsReasoning) - case answer := <-openAIConn.DoneCh: - h.sendSessionMessage(id, answer, messageID, "finish", false) - return answer + case <-ctx.Done(): + h.sendError(conv.Id, "chat timeout") + return + case ans := <-conn.AnswerCh: + h.sendMessage(conv.Id, msgID, ans, "message", conn.IsReasoning) + case ans := <-conn.DoneCh: + h.sendMessage(conv.Id, msgID, ans, "finish", false) + h.finalizeConversation(conv, ans) + return } } } -func (h *chat) sendSessionMessage(id, answer, messageID, messageType string, isReasoning bool) { - message := ChatGPTMessage{ - Content: answer, - ID: messageID, +func (h *chat) finalizeConversation(conv *AIConversation, fullAnswer string) { + runes := []rune(fullAnswer) + snippet := fullAnswer + if len(runes) > 100 { + snippet = string(runes[:100]) + } + conv.Context = append(conv.Context, QARecord{Question: conv.Question, Answer: snippet}) + + cmd := conv.JMSServer.GenerateCommandItem(h.ws.user.String(), conv.Question, fullAnswer) + go conv.JMSServer.CmdR.Record(cmd) + go conv.JMSServer.Replay.WriteOutput(fullAnswer) +} + +func (h *chat) sendMessage( + convID, msgID, content, typ string, reasoning bool, +) { + msg := ChatGPTMessage{ + Content: content, + ID: msgID, CreateTime: time.Now(), - Type: messageType, + Type: typ, Role: openai.ChatMessageRoleAssistant, - IsReasoning: isReasoning, + IsReasoning: reasoning, } - data, _ := json.Marshal(message) - msg := Message{ - Id: id, - Type: "message", - Data: string(data), + data, _ := json.Marshal(msg) + h.ws.SendMessage(&Message{Id: convID, Type: "message", Data: string(data)}) +} + +func (h *chat) sendError(convID, errMsg string) { + h.endConversation(convID, "error", errMsg) +} + +func (h *chat) endConversation(convID, typ, msg string) { + + defer func() { + if r := recover(); r != nil { + logger.Errorf("panic while sending message to session %s: %v", convID, r) + } + }() + + if v, ok := h.conversations.Load(convID); ok { + if conv, ok2 := v.(*AIConversation); ok2 && conv.JMSServer != nil { + conv.JMSServer.Close(msg) + } } - h.ws.SendMessage(&msg) + h.conversations.Delete(convID) + h.ws.SendMessage(&Message{Id: convID, Type: typ, Data: msg}) } -func (h *chat) sendErrorMessage(id, message string) { - msg := Message{ - Id: id, - Type: "error", - Data: message, +func (h *chat) interrupt(convID string) { + if v, ok := h.conversations.Load(convID); ok { + v.(*AIConversation).InterruptCurrentChat = true } - h.ws.SendMessage(&msg) } -func (h *chat) CleanConversationMap() { - h.conversationMap.Range(func(key, value interface{}) bool { - h.conversationMap.Delete(key) +func (h *chat) cleanupAll() { + h.conversations.Range(func(key, _ interface{}) bool { + h.endConversation(key.(string), "close", "") return true }) } + +func (h *chat) Monitor(conv *AIConversation) { + lang := i18n.NewLang(h.ws.langCode) + + lastActiveTime := time.Now() + maxIdleTime := time.Duration(h.term.MaxIdleTime) * time.Minute + MaxSessionTime := time.Now().Add(time.Duration(h.term.MaxSessionTime) * time.Hour) + + for { + now := time.Now() + if MaxSessionTime.Before(now) { + msg := lang.T("Session max time reached, disconnect") + logger.Infof("Session[%s] max session time reached, disconnect", conv.Id) + h.endConversation(conv.Id, "close", msg) + return + } + + outTime := lastActiveTime.Add(maxIdleTime) + if now.After(outTime) { + msg := fmt.Sprintf(lang.T("Connect idle more than %d minutes, disconnect"), h.term.MaxIdleTime) + logger.Infof("Session[%s] idle more than %d minutes, disconnect", conv.Id, h.term.MaxIdleTime) + h.endConversation(conv.Id, "close", msg) + return + } + + if conv.NewDialogue { + lastActiveTime = time.Now() + conv.NewDialogue = false + } + + time.Sleep(10 * time.Second) + } +} diff --git a/pkg/httpd/message.go b/pkg/httpd/message.go index 9ed65854f..3c98fb2d4 100644 --- a/pkg/httpd/message.go +++ b/pkg/httpd/message.go @@ -1,6 +1,7 @@ package httpd import ( + "github.com/jumpserver/koko/pkg/proxy" "time" "github.com/jumpserver/koko/pkg/exchange" @@ -18,6 +19,7 @@ type Message struct { //Chat AI Prompt string `json:"prompt"` Interrupt bool `json:"interrupt"` + ChatModel string `json:"chat_model"` //K8s KubernetesId string `json:"k8s_id"` @@ -163,11 +165,20 @@ type OpenAIParam struct { Type string } +type QARecord struct { + Question string + Answer string +} + type AIConversation struct { Id string Prompt string - HistoryRecords []string + Question string + Model string + Context []QARecord + JMSServer *proxy.ChatJMSServer InterruptCurrentChat bool + NewDialogue bool } type ChatGPTMessage struct { diff --git a/pkg/httpd/webserver.go b/pkg/httpd/webserver.go index 86ff6e280..ca494d1b4 100644 --- a/pkg/httpd/webserver.go +++ b/pkg/httpd/webserver.go @@ -158,9 +158,9 @@ func (s *Server) ChatAIWebsocket(ctx *gin.Context) { } userConn.handler = &chat{ - ws: userConn, - conversationMap: sync.Map{}, - termConf: &termConf, + ws: userConn, + conversations: sync.Map{}, + term: &termConf, } s.broadCaster.EnterUserWebsocket(userConn) defer s.broadCaster.LeaveUserWebsocket(userConn) diff --git a/pkg/jms-sdk-go/model/account.go b/pkg/jms-sdk-go/model/account.go index 0e9e893ed..fbedc194b 100644 --- a/pkg/jms-sdk-go/model/account.go +++ b/pkg/jms-sdk-go/model/account.go @@ -55,6 +55,18 @@ type AccountDetail struct { Privileged bool `json:"privileged"` } +type AssetChat struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type AccountChatDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + Asset AssetChat `json:"asset"` +} + type PermAccount struct { Name string `json:"name"` Username string `json:"username"` diff --git a/pkg/jms-sdk-go/service/jms_asset.go b/pkg/jms-sdk-go/service/jms_asset.go index fc15ce8cf..39d57c430 100644 --- a/pkg/jms-sdk-go/service/jms_asset.go +++ b/pkg/jms-sdk-go/service/jms_asset.go @@ -23,3 +23,9 @@ func (s *JMService) GetAccountSecretById(accountId string) (res model.AccountDet _, err = s.authClient.Get(url, &res) return } + +func (s *JMService) GetAccountChat() (res model.AccountChatDetail, err error) { + url := fmt.Sprintf(AccountChatURL) + _, err = s.authClient.Get(url, &res) + return +} diff --git a/pkg/jms-sdk-go/service/url.go b/pkg/jms-sdk-go/service/url.go index 893e40d5b..39051e826 100644 --- a/pkg/jms-sdk-go/service/url.go +++ b/pkg/jms-sdk-go/service/url.go @@ -77,6 +77,7 @@ const ( UserPermsAssetAccountsURL = "/api/v1/perms/users/%s/assets/%s/" AccountSecretURL = "/api/v1/assets/account-secrets/%s/" + AccountChatURL = "/api/v1/accounts/accounts/chat/" UserPermsAssetsURL = "/api/v1/perms/users/%s/assets/" AssetLoginConfirmURL = "/api/v1/acls/login-asset/check/" diff --git a/pkg/proxy/chat.go b/pkg/proxy/chat.go new file mode 100644 index 000000000..0e845f206 --- /dev/null +++ b/pkg/proxy/chat.go @@ -0,0 +1,170 @@ +package proxy + +import ( + "fmt" + "github.com/jumpserver/koko/pkg/common" + modelCommon "github.com/jumpserver/koko/pkg/jms-sdk-go/common" + "github.com/jumpserver/koko/pkg/jms-sdk-go/model" + "github.com/jumpserver/koko/pkg/jms-sdk-go/service" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/session" + "strings" + "time" +) + +type ChatReplyRecorder struct { + *ReplyRecorder +} + +func (rh *ChatReplyRecorder) WriteInput(inputStr string) { + currentTime := time.Now() + formattedTime := currentTime.Format("2006-01-02 15:04:05") + inputStr = fmt.Sprintf("[%s]#: %s", formattedTime, inputStr) + rh.Record([]byte(inputStr)) +} + +func (rh *ChatReplyRecorder) WriteOutput(outputStr string) { + wrappedText := rh.wrapText(outputStr) + outputStr = "\r\n" + wrappedText + "\r\n" + rh.Record([]byte(outputStr)) + +} + +func (rh *ChatReplyRecorder) wrapText(text string) string { + var wrappedTextBuilder strings.Builder + words := strings.Fields(text) + currentLineLength := 0 + + for _, word := range words { + wordLength := len(word) + + if currentLineLength+wordLength > rh.Writer.Width { + wrappedTextBuilder.WriteString("\r\n" + word + " ") + currentLineLength = wordLength + 1 + } else { + wrappedTextBuilder.WriteString(word + " ") + currentLineLength += wordLength + 1 + } + } + + return wrappedTextBuilder.String() +} + +func NewChatJMSServer( + user, ip, userID, langCode string, + jmsService *service.JMService, conf *model.TerminalConfig) (*ChatJMSServer, error) { + accountInfo, err := jmsService.GetAccountChat() + if err != nil { + logger.Errorf("Get account chat info error: %s", err) + return nil, err + } + + id := common.UUID() + + apiSession := &model.Session{ + ID: id, + User: user, + LoginFrom: model.LoginFromWeb, + RemoteAddr: ip, + Protocol: model.ActionALL, + Asset: accountInfo.Asset.Name, + Account: accountInfo.Name, + AccountID: accountInfo.ID, + AssetID: accountInfo.Asset.ID, + UserID: userID, + OrgID: "00000000-0000-0000-0000-000000000004", + Type: model.NORMALType, + LangCode: langCode, + DateStart: modelCommon.NewNowUTCTime(), + } + + _, err2 := jmsService.CreateSession(*apiSession) + if err2 != nil { + return nil, err2 + } + + chat := &ChatJMSServer{ + JmsService: jmsService, + Session: apiSession, + Conf: conf, + } + + chat.CmdR = chat.GetCommandRecorder() + chat.Replay = chat.GetReplayRecorder() + + if err1 := jmsService.RecordSessionLifecycleLog(id, model.AssetConnectSuccess, + model.EmptyLifecycleLog); err1 != nil { + logger.Errorf("Record session activity log err: %s", err1) + } + + return chat, nil +} + +type ChatJMSServer struct { + JmsService *service.JMService + Session *model.Session + CmdR *CommandRecorder + Replay *ChatReplyRecorder + Conf *model.TerminalConfig +} + +func (s *ChatJMSServer) GenerateCommandItem(user, input, output string) *model.Command { + createdDate := time.Now() + return &model.Command{ + SessionID: s.Session.ID, + OrgID: s.Session.OrgID, + Input: input, + Output: output, + User: user, + Server: s.Session.Asset, + Account: s.Session.Account, + Timestamp: createdDate.Unix(), + RiskLevel: model.NormalLevel, + DateCreated: createdDate.UTC(), + } +} + +func (s *ChatJMSServer) GetReplayRecorder() *ChatReplyRecorder { + info := &ReplyInfo{ + Width: 200, + Height: 200, + TimeStamp: time.Now(), + } + recorder, err := NewReplayRecord(s.Session.ID, s.JmsService, + NewReplayStorage(s.JmsService, s.Conf), + info) + if err != nil { + logger.Error(err) + } + + return &ChatReplyRecorder{recorder} +} + +func (s *ChatJMSServer) GetCommandRecorder() *CommandRecorder { + cmdR := CommandRecorder{ + sessionID: s.Session.ID, + storage: NewCommandStorage(s.JmsService, s.Conf), + queue: make(chan *model.Command, 10), + closed: make(chan struct{}), + jmsService: s.JmsService, + } + go cmdR.record() + return &cmdR +} + +func (s *ChatJMSServer) Close(msg string) { + session.RemoveSessionById(s.Session.ID) + if err := s.JmsService.SessionFinished(s.Session.ID, modelCommon.NewNowUTCTime()); err != nil { + logger.Errorf("finish session %s: %v", s.Session.ID, err) + } + + s.CmdR.End() + s.Replay.End() + + logObj := model.SessionLifecycleLog{Reason: msg, User: s.Session.User} + err := s.JmsService.RecordSessionLifecycleLog(s.Session.ID, model.AssetConnectFinished, logObj) + if err != nil { + logger.Errorf("record session lifecycle log %s: %v", s.Session.ID, err) + return + } +} diff --git a/pkg/srvconn/conn_openai.go b/pkg/srvconn/conn_openai.go index 9d639a6a9..50b1e5ad6 100644 --- a/pkg/srvconn/conn_openai.go +++ b/pkg/srvconn/conn_openai.go @@ -93,7 +93,8 @@ type OpenAIConn struct { Client *openai.Client Model string Prompt string - Contents []string + Question string + Context []openai.ChatCompletionMessage IsReasoning bool AnswerCh chan string DoneCh chan string @@ -102,11 +103,10 @@ type OpenAIConn struct { func (conn *OpenAIConn) Chat(interruptCurrentChat *bool) { ctx := context.Background() - var messages []openai.ChatCompletionMessage - messages = append(messages, openai.ChatCompletionMessage{ + messages := append(conn.Context, openai.ChatCompletionMessage{ Role: openai.ChatMessageRoleUser, - Content: strings.Join(conn.Contents, "\n"), + Content: conn.Question, }) systemPrompt := conn.Prompt @@ -182,6 +182,10 @@ func (conn *OpenAIConn) Chat(interruptCurrentChat *bool) { newContent = response.Choices[0].Delta.Content } + if newContent == "" { + continue + } + content += newContent conn.AnswerCh <- content } diff --git a/ui/components.d.ts b/ui/components.d.ts index bbd2dab51..d0dfc84f8 100644 --- a/ui/components.d.ts +++ b/ui/components.d.ts @@ -16,6 +16,7 @@ declare module 'vue' { Keyboard: typeof import('./src/components/Drawer/components/Keyboard/index.vue')['default'] Logo: typeof import('./src/components/Kubernetes/Sidebar/components/Logo/index.vue')['default'] MainContent: typeof import('./src/components/Kubernetes/MainContent/index.vue')['default'] + NAvatar: typeof import('naive-ui')['NAvatar'] NBreadcrumb: typeof import('naive-ui')['NBreadcrumb'] NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem'] NButton: typeof import('naive-ui')['NButton'] @@ -66,6 +67,7 @@ declare module 'vue' { NTag: typeof import('naive-ui')['NTag'] NText: typeof import('naive-ui')['NText'] NThing: typeof import('naive-ui')['NThing'] + NTime: typeof import('naive-ui')['NTime'] NTooltip: typeof import('naive-ui')['NTooltip'] NTree: typeof import('naive-ui')['NTree'] NUpload: typeof import('naive-ui')['NUpload'] diff --git a/ui/package.json b/ui/package.json index ef75a076e..4905a818f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -23,7 +23,7 @@ "clipboard-polyfill": "^4.1.0", "dayjs": "^1.11.13", "loglevel": "^1.9.1", - "lucide-vue-next": "^0.487.0", + "lucide-vue-next": "^0.507.0", "mitt": "^3.0.1", "naive-ui": "^2.39.0", "nora-zmodemjs": "^1.1.1", @@ -41,6 +41,7 @@ "xterm-theme": "^1.1.0" }, "devDependencies": { + "@duskmoon/vue3-typed-js": "^0.0.4", "@eslint/js": "^9.23.0", "@types/node": "^20.14.11", "@types/sortablejs": "^1.15.0", diff --git a/ui/public/icons/help.svg b/ui/public/icons/help.svg deleted file mode 100644 index e5bdc1fbb..000000000 --- a/ui/public/icons/help.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/public/icons/k8s.svg b/ui/public/icons/k8s.svg deleted file mode 100644 index 4a4b34d91..000000000 --- a/ui/public/icons/k8s.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/public/icons/logo.svg b/ui/public/icons/logo.svg new file mode 100755 index 000000000..eb25227d1 --- /dev/null +++ b/ui/public/icons/logo.svg @@ -0,0 +1 @@ +JumpServer-svg \ No newline at end of file diff --git a/ui/public/icons/organize.svg b/ui/public/icons/organize.svg deleted file mode 100644 index 0d78983d8..000000000 --- a/ui/public/icons/organize.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/public/icons/setting.svg b/ui/public/icons/setting.svg deleted file mode 100644 index a8d642278..000000000 --- a/ui/public/icons/setting.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/public/icons/split.svg b/ui/public/icons/split.svg deleted file mode 100644 index 2fdae88cb..000000000 --- a/ui/public/icons/split.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/public/icons/tree.svg b/ui/public/icons/tree.svg deleted file mode 100644 index 19b13eb69..000000000 --- a/ui/public/icons/tree.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/public/icons/user.svg b/ui/public/icons/user.svg deleted file mode 100644 index 58a1fb7af..000000000 --- a/ui/public/icons/user.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/public/images/ChatGPT-Logo.png b/ui/public/images/ChatGPT-Logo.png new file mode 100644 index 000000000..71c600e9a Binary files /dev/null and b/ui/public/images/ChatGPT-Logo.png differ diff --git a/ui/public/images/DeepSeek_Logo.webp b/ui/public/images/DeepSeek_Logo.webp new file mode 100644 index 000000000..a21641385 Binary files /dev/null and b/ui/public/images/DeepSeek_Logo.webp differ diff --git a/ui/src/enum/index.ts b/ui/src/enum/index.ts index bd2bd8d88..086a3aadf 100644 --- a/ui/src/enum/index.ts +++ b/ui/src/enum/index.ts @@ -29,6 +29,7 @@ export enum MESSAGE_TYPE { CLOSE = 'CLOSE', ERROR = 'ERROR', CONNECT = 'CONNECT', + MESSAGE = 'message', TERMINAL_SHARE = 'TERMINAL_SHARE', TERMINAL_ERROR = 'TERMINAL_ERROR', MESSAGE_NOTIFY = 'MESSAGE_NOTIFY', diff --git a/ui/src/hooks/helper/index.ts b/ui/src/hooks/helper/index.ts index 6b99888a4..328edb554 100644 --- a/ui/src/hooks/helper/index.ts +++ b/ui/src/hooks/helper/index.ts @@ -306,6 +306,10 @@ export const generateWsURL = () => { connectURL = BASE_WS_URL + '/koko/ws/terminal/?' + requireParams.toString(); break; } + case 'Chat': + // connectURL = BASE_WS_URL + `/koko/ws/chat/system` + connectURL = 'ws://localhost:5050' + `/koko/ws/chat/system/` + break; default: { connectURL = urlParams ? `${BASE_WS_URL}/koko/ws/terminal/?${urlParams.toString()}` : ''; } diff --git a/ui/src/hooks/useChat.ts b/ui/src/hooks/useChat.ts new file mode 100644 index 000000000..945c3be21 --- /dev/null +++ b/ui/src/hooks/useChat.ts @@ -0,0 +1,69 @@ +import { ref } from 'vue'; +import { MessageType } from '@/enum'; +import { useMessage } from 'naive-ui'; +import { useWebSocket } from '@vueuse/core'; +import { generateWsURL } from '@/hooks/helper'; + +import type { ChatSendMessage } from '@/types/modules/chat.type'; + +export const useChat = () => { + const message = useMessage(); + const socket = ref(); + + const socketOnMessage = (message: MessageEvent) => { + let data = ''; + const messageData = JSON.parse(message.data); + + if (typeof messageData.data === 'string') { + data = JSON.parse(messageData.data); + } + + switch (messageData.type) { + case MessageType.CONNECT: + // console.log(data); + break; + case MessageType.MESSAGE: + console.log(data); + break; + } + }; + const socketClose = () => { + message.error('Socket connection has been closed'); + }; + const socketError = () => { + message.error('Socket connection has been error'); + }; + const socketOpen = () => { + // TODO 发送心跳 + }; + + const sendChatMessage = (message: ChatSendMessage) => { + socket.value?.send(JSON.stringify(message)); + }; + + const createChatSocket = () => { + const url = generateWsURL(); + + const { ws } = useWebSocket(url); + + if (!ws.value) { + return; + } + + socket.value = ws.value; + + ws.value.onopen = socketOpen; + ws.value.onclose = socketClose; + ws.value.onerror = socketError; + ws.value.onmessage = socketOnMessage; + + return { + socket: socket.value + }; + }; + + return { + sendChatMessage, + createChatSocket + }; +}; diff --git a/ui/src/index.css b/ui/src/index.css index 3d552a61f..2534f1b56 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1,2 +1,9 @@ @import "tailwindcss"; +.icon-hover-primary { + @apply cursor-pointer hover:text-[#16987D] focus:outline-none transition-all duration-300; +} + +.icon-hover-danger { + @apply cursor-pointer hover:text-[#ff0000] focus:outline-none transition-all duration-300; +} diff --git a/ui/src/overrides.ts b/ui/src/overrides.ts index 707313c2a..30a5dedbc 100644 --- a/ui/src/overrides.ts +++ b/ui/src/overrides.ts @@ -126,7 +126,7 @@ export const themeOverrides: GlobalThemeOverrides = { }, Layout: { color: 'rgba(0, 0, 0, 1)', - siderColor: 'rgba(0, 0, 0, 1)', + siderColor: 'rgba(255, 255, 255, 0.09)', headerColor: 'rgba(0, 0, 0, 1)' } }; diff --git a/ui/src/store/modules/chat.ts b/ui/src/store/modules/chat.ts new file mode 100644 index 000000000..c776c81f1 --- /dev/null +++ b/ui/src/store/modules/chat.ts @@ -0,0 +1,8 @@ +import { defineStore } from 'pinia' + +export const useChatStore = defineStore('chat-store', { + state: () => ({}), + actions: { + + } +}) \ No newline at end of file diff --git a/ui/src/store/modules/useChat.ts b/ui/src/store/modules/useChat.ts new file mode 100644 index 000000000..a1a82e2e1 --- /dev/null +++ b/ui/src/store/modules/useChat.ts @@ -0,0 +1,27 @@ +import { defineStore } from 'pinia'; +import type { ChatState, ChatMessage } from '@/types/modules/chat.type'; + +type Chatitem = { + chatItem: Map; +}; + +export const useChatStore = defineStore('chat', { + state: (): Chatitem => ({ + chatItem: new Map() + }), + actions: { + addChatItem(id: string, chatState: ChatState) { + this.chatItem.set(id, chatState); + }, + removeChatItem(id: string) { + this.chatItem.delete(id); + }, + addMessageContext(id: string, message: ChatMessage) { + const chatState = this.chatItem.get(id); + + if (chatState) { + chatState.messages.push(message); + } + } + } +}); diff --git a/ui/src/types/modules/chat.type.ts b/ui/src/types/modules/chat.type.ts new file mode 100644 index 000000000..955d405f0 --- /dev/null +++ b/ui/src/types/modules/chat.type.ts @@ -0,0 +1,55 @@ +// 定义会话消息类型 +export type ChatMessage = ChatSendMessage | ChatReceiveMessage; + +// 定义发送消息类型 +export interface ChatSendMessage { + data: string; + + id: string; + + prompt: string; +} + +// 定义接收消息类型 +export interface ChatReceiveMessage { + chat_model: string; + + data: string; + + id: string; + + interrupt: boolean; + + prompt: string; + + type: string; +} + +// 定义会话状态类型 +export interface ChatState { + // 会话角色 + prompt: string; + + // 会话消息 + // 数组的奇数项(index % 2 === 1)为收到的消息(ChatReceiveMessage) + // 数组的偶数项(index % 2 === 0)为发出去的消息(ChatSendMessage) + messages: ChatMessage[]; +} + +// 侧边栏 +export interface ChatSider { + time_stamp: string; + + chat_items: { + id: string; + + chat_title: string; + }; +} + +// 角色类型 +export interface RoleType { + content: string; + + name: string; +} diff --git a/ui/src/views/chat/components/Content/index.vue b/ui/src/views/chat/components/Content/index.vue new file mode 100644 index 000000000..5603bce90 --- /dev/null +++ b/ui/src/views/chat/components/Content/index.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/ui/src/views/chat/components/Conversation/index.vue b/ui/src/views/chat/components/Conversation/index.vue new file mode 100644 index 000000000..7c0761a39 --- /dev/null +++ b/ui/src/views/chat/components/Conversation/index.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/ui/src/views/chat/components/Conversation/optionRender.tsx b/ui/src/views/chat/components/Conversation/optionRender.tsx new file mode 100644 index 000000000..544ea6332 --- /dev/null +++ b/ui/src/views/chat/components/Conversation/optionRender.tsx @@ -0,0 +1,72 @@ +import { useI18n } from 'vue-i18n'; +import { NSpace, NText } from 'naive-ui'; +import { SquareArrowOutUpRight, PencilLine, Trash2 } from 'lucide-vue-next'; + +import type { SelectOption } from 'naive-ui'; +import type { FunctionalComponent } from 'vue'; +import type { LucideProps } from 'lucide-vue-next'; + +interface OptionItem { + value: string; + label: string; + textColor?: string; + iconColor?: string; + click: (chatId: string) => void; + icon: FunctionalComponent; +} + +type EmitsType = { + (e: 'chat-share', shareId: string): void; + (e: 'chat-rename', shareId: string): void; + (e: 'chat-delete', shareId: string): void; +}; + +export const OptionRender = (emits: EmitsType): SelectOption[] => { + const { t } = useI18n(); + + const optionItems: OptionItem[] = [ + { + value: 'share', + icon: SquareArrowOutUpRight, + label: t('Share'), + iconColor: 'white', + click: (chatId: string) => { + emits('chat-share', chatId); + } + }, + { + value: 'rename', + icon: PencilLine, + label: t('Rename'), + iconColor: 'white', + click: (chatId: string) => { + emits('chat-rename', chatId); + } + }, + { + value: 'delete', + icon: Trash2, + label: t('Delete'), + iconColor: '#fb2c36', + textColor: '!text-red-500', + click: (chatId: string) => { + emits('chat-delete', chatId); + } + } + ]; + + const commonClass = 'px-4 py-2 w-30 hover:bg-[#ffffff1A] cursor-pointer transition-all duration-300'; + + return optionItems.map(item => ({ + value: item.value, + render: () => ( + item.click(item.value)}> + {item.icon && } + + + {item.label} + + + ) + })); +}; diff --git a/ui/src/views/chat/components/Header/index.vue b/ui/src/views/chat/components/Header/index.vue new file mode 100644 index 000000000..a62d03f3a --- /dev/null +++ b/ui/src/views/chat/components/Header/index.vue @@ -0,0 +1,21 @@ + + + \ No newline at end of file diff --git a/ui/src/views/chat/components/InputArea/index.vue b/ui/src/views/chat/components/InputArea/index.vue new file mode 100644 index 000000000..9d0ab6a58 --- /dev/null +++ b/ui/src/views/chat/components/InputArea/index.vue @@ -0,0 +1,62 @@ +