* feat: add chat page * feat: add chat page * Update ai.go * Update ai_test.go * Update message.go --------- Co-authored-by: hsluoyz <hsluoyz@qq.com>HEAD
| @@ -0,0 +1,141 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| package ai | |||
| import ( | |||
| "context" | |||
| "fmt" | |||
| "io" | |||
| "net/http" | |||
| "strings" | |||
| "time" | |||
| "github.com/sashabaranov/go-openai" | |||
| ) | |||
| func queryAnswer(authToken string, question string, timeout int) (string, error) { | |||
| // fmt.Printf("Question: %s\n", question) | |||
| client := getProxyClientFromToken(authToken) | |||
| ctx, cancel := context.WithTimeout(context.Background(), time.Duration(2+timeout*2)*time.Second) | |||
| defer cancel() | |||
| resp, err := client.CreateChatCompletion( | |||
| ctx, | |||
| openai.ChatCompletionRequest{ | |||
| Model: openai.GPT3Dot5Turbo, | |||
| Messages: []openai.ChatCompletionMessage{ | |||
| { | |||
| Role: openai.ChatMessageRoleUser, | |||
| Content: question, | |||
| }, | |||
| }, | |||
| }, | |||
| ) | |||
| if err != nil { | |||
| return "", err | |||
| } | |||
| res := resp.Choices[0].Message.Content | |||
| res = strings.Trim(res, "\n") | |||
| // fmt.Printf("Answer: %s\n\n", res) | |||
| return res, nil | |||
| } | |||
| func QueryAnswerSafe(authToken string, question string) string { | |||
| var res string | |||
| var err error | |||
| for i := 0; i < 10; i++ { | |||
| res, err = queryAnswer(authToken, question, i) | |||
| if err != nil { | |||
| if i > 0 { | |||
| fmt.Printf("\tFailed (%d): %s\n", i+1, err.Error()) | |||
| } | |||
| } else { | |||
| break | |||
| } | |||
| } | |||
| if err != nil { | |||
| panic(err) | |||
| } | |||
| return res | |||
| } | |||
| func QueryAnswerStream(authToken string, question string, writer io.Writer, builder *strings.Builder) error { | |||
| client := getProxyClientFromToken(authToken) | |||
| ctx := context.Background() | |||
| flusher, ok := writer.(http.Flusher) | |||
| if !ok { | |||
| return fmt.Errorf("writer does not implement http.Flusher") | |||
| } | |||
| // https://platform.openai.com/tokenizer | |||
| // https://github.com/pkoukk/tiktoken-go#available-encodings | |||
| promptTokens, err := getTokenSize(openai.GPT3TextDavinci003, question) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| // https://platform.openai.com/docs/models/gpt-3-5 | |||
| maxTokens := 4097 - promptTokens | |||
| respStream, err := client.CreateCompletionStream( | |||
| ctx, | |||
| openai.CompletionRequest{ | |||
| Model: openai.GPT3TextDavinci003, | |||
| Prompt: question, | |||
| MaxTokens: maxTokens, | |||
| Stream: true, | |||
| }, | |||
| ) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| defer respStream.Close() | |||
| isLeadingReturn := true | |||
| for { | |||
| completion, streamErr := respStream.Recv() | |||
| if streamErr != nil { | |||
| if streamErr == io.EOF { | |||
| break | |||
| } | |||
| return streamErr | |||
| } | |||
| data := completion.Choices[0].Text | |||
| if isLeadingReturn && len(data) != 0 { | |||
| if strings.Count(data, "\n") == len(data) { | |||
| continue | |||
| } else { | |||
| isLeadingReturn = false | |||
| } | |||
| } | |||
| fmt.Printf("%s", data) | |||
| // Write the streamed data as Server-Sent Events | |||
| if _, err = fmt.Fprintf(writer, "event: message\ndata: %s\n\n", data); err != nil { | |||
| return err | |||
| } | |||
| flusher.Flush() | |||
| // Append the response to the strings.Builder | |||
| builder.WriteString(data) | |||
| } | |||
| return nil | |||
| } | |||
| @@ -0,0 +1,42 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| //go:build !skipCi | |||
| // +build !skipCi | |||
| package ai | |||
| import ( | |||
| "testing" | |||
| "github.com/casbin/casibase/object" | |||
| "github.com/casbin/casibase/proxy" | |||
| "github.com/sashabaranov/go-openai" | |||
| ) | |||
| func TestRun(t *testing.T) { | |||
| object.InitConfig() | |||
| proxy.InitHttpClient() | |||
| text, err := queryAnswer("", "hi", 5) | |||
| if err != nil { | |||
| panic(err) | |||
| } | |||
| println(text) | |||
| } | |||
| func TestToken(t *testing.T) { | |||
| println(getTokenSize(openai.GPT3TextDavinci003, "")) | |||
| } | |||
| @@ -0,0 +1,28 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| package ai | |||
| import ( | |||
| "github.com/casbin/casibase/proxy" | |||
| "github.com/sashabaranov/go-openai" | |||
| ) | |||
| func getProxyClientFromToken(authToken string) *openai.Client { | |||
| config := openai.DefaultConfig(authToken) | |||
| config.HTTPClient = proxy.ProxyHttpClient | |||
| c := openai.NewClientWithConfig(config) | |||
| return c | |||
| } | |||
| @@ -0,0 +1,28 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| package ai | |||
| import "github.com/pkoukk/tiktoken-go" | |||
| func getTokenSize(model string, prompt string) (int, error) { | |||
| tkm, err := tiktoken.EncodingForModel(model) | |||
| if err != nil { | |||
| return 0, err | |||
| } | |||
| token := tkm.Encode(prompt, nil, nil) | |||
| res := len(token) | |||
| return res, nil | |||
| } | |||
| @@ -15,4 +15,5 @@ casdoorDbName = casdoor | |||
| casdoorOrganization = "casbin" | |||
| casdoorApplication = "app-casibase" | |||
| cacheDir = "C:/casibase_cache" | |||
| appDir = "" | |||
| appDir = "" | |||
| socks5Proxy = "127.0.0.1:7890" | |||
| @@ -0,0 +1,119 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| package conf | |||
| import ( | |||
| "os" | |||
| "runtime" | |||
| "strconv" | |||
| "strings" | |||
| "github.com/astaxie/beego" | |||
| ) | |||
| func init() { | |||
| // this array contains the beego configuration items that may be modified via env | |||
| presetConfigItems := []string{"httpport", "appname"} | |||
| for _, key := range presetConfigItems { | |||
| if value, ok := os.LookupEnv(key); ok { | |||
| err := beego.AppConfig.Set(key, value) | |||
| if err != nil { | |||
| panic(err) | |||
| } | |||
| } | |||
| } | |||
| } | |||
| func GetConfigString(key string) string { | |||
| if value, ok := os.LookupEnv(key); ok { | |||
| return value | |||
| } | |||
| res := beego.AppConfig.String(key) | |||
| if res == "" { | |||
| if key == "staticBaseUrl" { | |||
| res = "https://cdn.casbin.org" | |||
| } else if key == "logConfig" { | |||
| res = "{\"filename\": \"logs/casdoor.log\", \"maxdays\":99999, \"perm\":\"0770\"}" | |||
| } | |||
| } | |||
| return res | |||
| } | |||
| func GetConfigBool(key string) bool { | |||
| value := GetConfigString(key) | |||
| if value == "true" { | |||
| return true | |||
| } else { | |||
| return false | |||
| } | |||
| } | |||
| func GetConfigInt64(key string) (int64, error) { | |||
| value := GetConfigString(key) | |||
| num, err := strconv.ParseInt(value, 10, 64) | |||
| return num, err | |||
| } | |||
| func GetConfigDataSourceName() string { | |||
| dataSourceName := GetConfigString("dataSourceName") | |||
| runningInDocker := os.Getenv("RUNNING_IN_DOCKER") | |||
| if runningInDocker == "true" { | |||
| // https://stackoverflow.com/questions/48546124/what-is-linux-equivalent-of-host-docker-internal | |||
| if runtime.GOOS == "linux" { | |||
| dataSourceName = strings.ReplaceAll(dataSourceName, "localhost", "172.17.0.1") | |||
| } else { | |||
| dataSourceName = strings.ReplaceAll(dataSourceName, "localhost", "host.docker.internal") | |||
| } | |||
| } | |||
| return dataSourceName | |||
| } | |||
| func GetLanguage(language string) string { | |||
| if language == "" || language == "*" { | |||
| return "en" | |||
| } | |||
| if len(language) != 2 || language == "nu" { | |||
| return "en" | |||
| } else { | |||
| return language | |||
| } | |||
| } | |||
| func IsDemoMode() bool { | |||
| return strings.ToLower(GetConfigString("isDemoMode")) == "true" | |||
| } | |||
| func GetConfigBatchSize() int { | |||
| res, err := strconv.Atoi(GetConfigString("batchSize")) | |||
| if err != nil { | |||
| res = 100 | |||
| } | |||
| return res | |||
| } | |||
| func GetConfigRealDataSourceName(driverName string) string { | |||
| var dataSourceName string | |||
| if driverName != "mysql" { | |||
| dataSourceName = GetConfigDataSourceName() | |||
| } else { | |||
| dataSourceName = GetConfigDataSourceName() + GetConfigString("dbName") | |||
| } | |||
| return dataSourceName | |||
| } | |||
| @@ -16,8 +16,12 @@ package controllers | |||
| import ( | |||
| "encoding/json" | |||
| "fmt" | |||
| "strings" | |||
| "github.com/casbin/casibase/ai" | |||
| "github.com/casbin/casibase/object" | |||
| "github.com/casbin/casibase/util" | |||
| ) | |||
| func (c *ApiController) GetGlobalMessages() { | |||
| @@ -32,26 +36,146 @@ func (c *ApiController) GetGlobalMessages() { | |||
| func (c *ApiController) GetMessages() { | |||
| owner := c.Input().Get("owner") | |||
| chat := c.Input().Get("chat") | |||
| messages, err := object.GetMessages(owner) | |||
| if owner != "" && chat == "" { | |||
| messages, err := object.GetMessages(owner) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| c.ResponseOk(messages) | |||
| } else if chat != "" && owner == "" { | |||
| messages, err := object.GetChatMessages(chat) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| c.ResponseOk(messages) | |||
| } else { | |||
| c.ResponseError("Invalid get messages request") | |||
| return | |||
| } | |||
| } | |||
| func (c *ApiController) GetMessage() { | |||
| id := c.Input().Get("id") | |||
| message, err := object.GetMessage(id) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| c.ResponseOk(messages) | |||
| c.ResponseOk(message) | |||
| } | |||
| func (c *ApiController) GetMessage() { | |||
| func (c *ApiController) ResponseErrorStream(errorText string) { | |||
| event := fmt.Sprintf("event: myerror\ndata: %s\n\n", errorText) | |||
| _, err := c.Ctx.ResponseWriter.Write([]byte(event)) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| } | |||
| func (c *ApiController) GetMessageAnswer() { | |||
| id := c.Input().Get("id") | |||
| c.Ctx.ResponseWriter.Header().Set("Content-Type", "text/event-stream") | |||
| c.Ctx.ResponseWriter.Header().Set("Cache-Control", "no-cache") | |||
| c.Ctx.ResponseWriter.Header().Set("Connection", "keep-alive") | |||
| message, err := object.GetMessage(id) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| c.ResponseOk(message) | |||
| if message == nil { | |||
| c.ResponseErrorStream(fmt.Sprintf("The message: %s is not found", id)) | |||
| return | |||
| } | |||
| if message.Author != "AI" || message.ReplyTo == "" || message.Text != "" { | |||
| c.ResponseErrorStream("The message is invalid") | |||
| return | |||
| } | |||
| chatId := util.GetIdFromOwnerAndName("admin", message.Chat) | |||
| chat, err := object.GetChat(chatId) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| //if chat == nil || chat.Organization != message.Organization { | |||
| // c.ResponseErrorStream(fmt.Sprintf("The chat: %s is not found", chatId)) | |||
| // return | |||
| //} | |||
| if chat.Type != "AI" { | |||
| c.ResponseErrorStream("The chat type must be \"AI\"") | |||
| return | |||
| } | |||
| questionMessage, err := object.GetMessage(message.ReplyTo) | |||
| if questionMessage == nil { | |||
| c.ResponseErrorStream(fmt.Sprintf("The message: %s is not found", id)) | |||
| return | |||
| } | |||
| providerId := util.GetIdFromOwnerAndName(chat.Owner, chat.User2) | |||
| provider, err := object.GetProvider(providerId) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| if provider == nil { | |||
| c.ResponseErrorStream(fmt.Sprintf("The provider: %s is not found", providerId)) | |||
| return | |||
| } | |||
| if provider.Category != "AI" || provider.ClientSecret == "" { | |||
| c.ResponseErrorStream(fmt.Sprintf("The provider: %s is invalid", providerId)) | |||
| return | |||
| } | |||
| c.Ctx.ResponseWriter.Header().Set("Content-Type", "text/event-stream") | |||
| c.Ctx.ResponseWriter.Header().Set("Cache-Control", "no-cache") | |||
| c.Ctx.ResponseWriter.Header().Set("Connection", "keep-alive") | |||
| authToken := provider.ClientSecret | |||
| question := questionMessage.Text | |||
| var stringBuilder strings.Builder | |||
| fmt.Printf("Question: [%s]\n", questionMessage.Text) | |||
| fmt.Printf("Answer: [") | |||
| err = ai.QueryAnswerStream(authToken, question, c.Ctx.ResponseWriter, &stringBuilder) | |||
| if err != nil { | |||
| c.ResponseErrorStream(err.Error()) | |||
| return | |||
| } | |||
| fmt.Printf("]\n") | |||
| event := fmt.Sprintf("event: end\ndata: %s\n\n", "end") | |||
| _, err = c.Ctx.ResponseWriter.Write([]byte(event)) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| answer := stringBuilder.String() | |||
| message.Text = answer | |||
| _, err = object.UpdateMessage(message.GetId(), message) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| } | |||
| func (c *ApiController) UpdateMessage() { | |||
| @@ -81,12 +205,47 @@ func (c *ApiController) AddMessage() { | |||
| return | |||
| } | |||
| var chat *object.Chat | |||
| if message.Chat != "" { | |||
| chatId := util.GetId("admin", message.Chat) | |||
| chat, err = object.GetChat(chatId) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| if chat == nil { | |||
| c.ResponseError(fmt.Sprintf("chat:The chat: %s is not found", chatId)) | |||
| return | |||
| } | |||
| } | |||
| success, err := object.AddMessage(&message) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| if success { | |||
| if chat != nil && chat.Type == "AI" { | |||
| answerMessage := &object.Message{ | |||
| Owner: message.Owner, | |||
| Name: fmt.Sprintf("message_%s", util.GetRandomName()), | |||
| CreatedTime: util.GetCurrentTimeEx(message.CreatedTime), | |||
| // Organization: message.Organization, | |||
| Chat: message.Chat, | |||
| ReplyTo: message.GetId(), | |||
| Author: "AI", | |||
| Text: "", | |||
| } | |||
| _, err = object.AddMessage(answerMessage) | |||
| if err != nil { | |||
| c.ResponseError(err.Error()) | |||
| return | |||
| } | |||
| } | |||
| } | |||
| c.ResponseOk(success) | |||
| } | |||
| @@ -13,8 +13,11 @@ require ( | |||
| github.com/google/uuid v1.3.0 | |||
| github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 | |||
| github.com/muesli/kmeans v0.3.0 | |||
| github.com/pkoukk/tiktoken-go v0.1.1 | |||
| github.com/rclone/rclone v1.63.0 | |||
| github.com/sashabaranov/go-openai v1.12.0 | |||
| github.com/tealeg/xlsx v1.0.5 | |||
| golang.org/x/net v0.8.0 | |||
| gonum.org/v1/gonum v0.11.0 | |||
| xorm.io/core v0.7.3 | |||
| xorm.io/xorm v1.2.5 | |||
| @@ -767,6 +767,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c | |||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |||
| github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= | |||
| github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= | |||
| github.com/dlclark/regexp2 v1.8.1 h1:6Lcdwya6GjPUNsBct8Lg/yRPwMhABj269AAzdGSiR+0= | |||
| github.com/dlclark/regexp2 v1.8.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= | |||
| github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= | |||
| github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= | |||
| github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= | |||
| @@ -1371,6 +1373,8 @@ github.com/pkg/sftp v1.13.6-0.20230213180117-971c283182b6 h1:5TvW1dv00Y13njmQ1AW | |||
| github.com/pkg/sftp v1.13.6-0.20230213180117-971c283182b6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= | |||
| github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE= | |||
| github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= | |||
| github.com/pkoukk/tiktoken-go v0.1.1 h1:jtkYlIECjyM9OW1w4rjPmTohK4arORP9V25y6TM6nXo= | |||
| github.com/pkoukk/tiktoken-go v0.1.1/go.mod h1:boMWvk9pQCOTx11pgu0DrIdrAKgQzzJKUP6vLXaz7Rw= | |||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |||
| github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= | |||
| @@ -1447,6 +1451,8 @@ github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZ | |||
| github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= | |||
| github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= | |||
| github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= | |||
| github.com/sashabaranov/go-openai v1.12.0 h1:aRNHH0gtVfrpIaEolD0sWrLLRnYQNK4cH/bIAHwL8Rk= | |||
| github.com/sashabaranov/go-openai v1.12.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= | |||
| github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= | |||
| github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= | |||
| github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= | |||
| @@ -20,6 +20,7 @@ import ( | |||
| _ "github.com/astaxie/beego/session/redis" | |||
| "github.com/casbin/casibase/casdoor" | |||
| "github.com/casbin/casibase/object" | |||
| "github.com/casbin/casibase/proxy" | |||
| "github.com/casbin/casibase/routers" | |||
| ) | |||
| @@ -27,6 +28,8 @@ func main() { | |||
| object.InitAdapter() | |||
| casdoor.InitCasdoorAdapter() | |||
| proxy.InitHttpClient() | |||
| beego.InsertFilter("*", beego.BeforeRouter, cors.Allow(&cors.Options{ | |||
| AllowOrigins: []string{"*"}, | |||
| AllowMethods: []string{"GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS"}, | |||
| @@ -43,6 +43,16 @@ func GetGlobalMessages() ([]*Message, error) { | |||
| return messages, nil | |||
| } | |||
| func GetChatMessages(chat string) ([]*Message, error) { | |||
| messages := []*Message{} | |||
| err := adapter.engine.Asc("created_time").Find(&messages, &Message{Chat: chat}) | |||
| if err != nil { | |||
| return messages, err | |||
| } | |||
| return messages, nil | |||
| } | |||
| func GetMessages(owner string) ([]*Message, error) { | |||
| messages := []*Message{} | |||
| err := adapter.engine.Desc("created_time").Find(&messages, &Message{Owner: owner}) | |||
| @@ -0,0 +1,87 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| package proxy | |||
| import ( | |||
| "crypto/tls" | |||
| "fmt" | |||
| "net" | |||
| "net/http" | |||
| "strings" | |||
| "time" | |||
| "github.com/casbin/casibase/conf" | |||
| "golang.org/x/net/proxy" | |||
| ) | |||
| var ( | |||
| DefaultHttpClient *http.Client | |||
| ProxyHttpClient *http.Client | |||
| ) | |||
| func InitHttpClient() { | |||
| // not use proxy | |||
| DefaultHttpClient = http.DefaultClient | |||
| // use proxy | |||
| ProxyHttpClient = getProxyHttpClient() | |||
| } | |||
| func isAddressOpen(address string) bool { | |||
| timeout := time.Millisecond * 100 | |||
| conn, err := net.DialTimeout("tcp", address, timeout) | |||
| if err != nil { | |||
| // cannot connect to address, proxy is not active | |||
| return false | |||
| } | |||
| if conn != nil { | |||
| defer conn.Close() | |||
| fmt.Printf("Socks5 proxy enabled: %s\n", address) | |||
| return true | |||
| } | |||
| return false | |||
| } | |||
| func getProxyHttpClient() *http.Client { | |||
| socks5Proxy := conf.GetConfigString("socks5Proxy") | |||
| if socks5Proxy == "" { | |||
| return &http.Client{} | |||
| } | |||
| if !isAddressOpen(socks5Proxy) { | |||
| return &http.Client{} | |||
| } | |||
| // https://stackoverflow.com/questions/33585587/creating-a-go-socks5-client | |||
| dialer, err := proxy.SOCKS5("tcp", socks5Proxy, nil, proxy.Direct) | |||
| if err != nil { | |||
| panic(err) | |||
| } | |||
| tr := &http.Transport{Dial: dialer.Dial, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} | |||
| return &http.Client{ | |||
| Transport: tr, | |||
| } | |||
| } | |||
| func GetHttpClient(url string) *http.Client { | |||
| if strings.Contains(url, "githubusercontent.com") || strings.Contains(url, "googleusercontent.com") { | |||
| return ProxyHttpClient | |||
| } else { | |||
| return DefaultHttpClient | |||
| } | |||
| } | |||
| @@ -90,6 +90,7 @@ func initAPI() { | |||
| beego.Router("/api/get-global-messages", &controllers.ApiController{}, "GET:GetGlobalMessages") | |||
| beego.Router("/api/get-messages", &controllers.ApiController{}, "GET:GetMessages") | |||
| beego.Router("/api/get-message", &controllers.ApiController{}, "GET:GetMessage") | |||
| beego.Router("/api/get-message-answer", &controllers.ApiController{}, "GET:GetMessageAnswer") | |||
| beego.Router("/api/update-message", &controllers.ApiController{}, "POST:UpdateMessage") | |||
| beego.Router("/api/add-message", &controllers.ApiController{}, "POST:AddMessage") | |||
| beego.Router("/api/delete-message", &controllers.ApiController{}, "POST:DeleteMessage") | |||
| @@ -19,8 +19,10 @@ import ( | |||
| "errors" | |||
| "fmt" | |||
| "io/ioutil" | |||
| "math/rand" | |||
| "strconv" | |||
| "strings" | |||
| "time" | |||
| "github.com/google/uuid" | |||
| ) | |||
| @@ -139,3 +141,17 @@ func DecodeBase64(s string) string { | |||
| return string(res) | |||
| } | |||
| func GetRandomName() string { | |||
| rand.Seed(time.Now().UnixNano()) | |||
| const charset = "0123456789abcdefghijklmnopqrstuvwxyz" | |||
| result := make([]byte, 6) | |||
| for i := range result { | |||
| result[i] = charset[rand.Intn(len(charset))] | |||
| } | |||
| return string(result) | |||
| } | |||
| func GetId(owner, name string) string { | |||
| return fmt.Sprintf("%s/%s", owner, name) | |||
| } | |||
| @@ -21,3 +21,17 @@ func GetCurrentTime() string { | |||
| tm := time.Unix(timestamp, 0) | |||
| return tm.Format(time.RFC3339) | |||
| } | |||
| func GetCurrentTimeEx(timestamp string) string { | |||
| tm := time.Now() | |||
| inputTime, err := time.Parse(time.RFC3339, timestamp) | |||
| if err != nil { | |||
| panic(err) | |||
| } | |||
| if !tm.After(inputTime) { | |||
| tm = inputTime.Add(1 * time.Millisecond) | |||
| } | |||
| return tm.Format("2006-01-02T15:04:05.999Z07:00") | |||
| } | |||
| @@ -16,7 +16,7 @@ import React, {Component} from "react"; | |||
| import {Link, Redirect, Route, Switch, withRouter} from "react-router-dom"; | |||
| import {StyleProvider, legacyLogicalPropertiesTransformer} from "@ant-design/cssinjs"; | |||
| import {Avatar, Button, Card, ConfigProvider, Drawer, Dropdown, FloatButton, Layout, Menu} from "antd"; | |||
| import {BarsOutlined, DownOutlined, LogoutOutlined, SettingOutlined} from "@ant-design/icons"; | |||
| import {BarsOutlined, CommentOutlined, DownOutlined, LogoutOutlined, SettingOutlined} from "@ant-design/icons"; | |||
| import "./App.less"; | |||
| import * as Setting from "./Setting"; | |||
| import * as AccountBackend from "./backend/AccountBackend"; | |||
| @@ -45,6 +45,7 @@ import ChatEditPage from "./ChatEditPage"; | |||
| import ChatListPage from "./ChatListPage"; | |||
| import MessageListPage from "./MessageListPage"; | |||
| import MessageEditPage from "./MessageEditPage"; | |||
| import ChatPage from "./ChatPage"; | |||
| const {Header, Footer, Content} = Layout; | |||
| @@ -198,14 +199,19 @@ class App extends Component { | |||
| items.push(Setting.getItem(<><SettingOutlined /> {i18next.t("account:My Account")}</>, | |||
| "/account" | |||
| )); | |||
| items.push(Setting.getItem(<><CommentOutlined /> {i18next.t("account:Chats & Messages")}</>, | |||
| "/chat" | |||
| )); | |||
| items.push(Setting.getItem(<><LogoutOutlined /> {i18next.t("account:Sign Out")}</>, | |||
| "/logout" | |||
| )); | |||
| const onClick = ({e}) => { | |||
| const onClick = (e) => { | |||
| if (e.key === "/account") { | |||
| Setting.openLink(Setting.getMyProfileUrl(this.state.account)); | |||
| } else if (e.key === "/logout") { | |||
| this.signout(); | |||
| } else if (e.key === "/chat") { | |||
| this.props.history.push("/chat"); | |||
| } | |||
| }; | |||
| @@ -374,10 +380,15 @@ class App extends Component { | |||
| <Route exact path="/chats/:chatName" render={(props) => this.renderSigninIfNotSignedIn(<ChatEditPage account={this.state.account} {...props} />)} /> | |||
| <Route exact path="/messages" render={(props) => this.renderSigninIfNotSignedIn(<MessageListPage account={this.state.account} {...props} />)} /> | |||
| <Route exact path="/messages/:messageName" render={(props) => this.renderSigninIfNotSignedIn(<MessageEditPage account={this.state.account} {...props} />)} /> | |||
| <Route exact path="/chat" render={(props) => this.renderSigninIfNotSignedIn(<ChatPage account={this.state.account} {...props} />)} /> | |||
| </Switch> | |||
| ); | |||
| } | |||
| isWithoutCard() { | |||
| return Setting.isMobile() || window.location.pathname === "/chat"; | |||
| } | |||
| renderContent() { | |||
| const onClick = ({key}) => { | |||
| this.props.history.push(key); | |||
| @@ -420,9 +431,12 @@ class App extends Component { | |||
| } | |||
| </Header> | |||
| <Content style={{display: "flex", flexDirection: "column"}}> | |||
| <Card className="content-warp-card"> | |||
| {this.renderRouter()} | |||
| </Card> | |||
| {this.isWithoutCard() ? | |||
| this.renderRouter() : | |||
| <Card className="content-warp-card"> | |||
| {this.renderRouter()} | |||
| </Card> | |||
| } | |||
| </Content> | |||
| {this.renderFooter()} | |||
| </Layout> | |||
| @@ -93,6 +93,7 @@ img { | |||
| border-radius: 7px; | |||
| float: right; | |||
| cursor: pointer; | |||
| margin-right: 3px; | |||
| &:hover { | |||
| background-color: #f5f5f5; | |||
| @@ -0,0 +1,54 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| import React from "react"; | |||
| import {Button, Result} from "antd"; | |||
| import i18next from "i18next"; | |||
| class BaseListPage extends React.Component { | |||
| constructor(props) { | |||
| super(props); | |||
| this.state = { | |||
| classes: props, | |||
| data: [], | |||
| loading: false, | |||
| searchText: "", | |||
| searchedColumn: "", | |||
| isAuthorized: true, | |||
| }; | |||
| } | |||
| render() { | |||
| if (!this.state.isAuthorized) { | |||
| return ( | |||
| <Result | |||
| status="403" | |||
| title="403 Unauthorized" | |||
| subTitle={i18next.t("general:Sorry, you do not have permission to access this page or logged in status invalid.")} | |||
| extra={<a href="/"><Button type="primary">{i18next.t("general:Back Home")}</Button></a>} | |||
| /> | |||
| ); | |||
| } | |||
| return ( | |||
| <div> | |||
| { | |||
| this.renderTable(this.state.data) | |||
| } | |||
| </div> | |||
| ); | |||
| } | |||
| } | |||
| export default BaseListPage; | |||
| @@ -0,0 +1,219 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| import React from "react"; | |||
| import {Alert, Avatar, Input, List, Spin} from "antd"; | |||
| import {CopyOutlined, DislikeOutlined, LikeOutlined, SendOutlined} from "@ant-design/icons"; | |||
| import i18next from "i18next"; | |||
| const {TextArea} = Input; | |||
| class ChatBox extends React.Component { | |||
| constructor(props) { | |||
| super(props); | |||
| this.state = { | |||
| inputValue: "", | |||
| }; | |||
| this.listContainerRef = React.createRef(); | |||
| } | |||
| componentDidUpdate(prevProps) { | |||
| if (prevProps.messages !== this.props.messages && this.props.messages !== undefined && this.props.messages !== null) { | |||
| this.scrollToListItem(this.props.messages.length); | |||
| } | |||
| } | |||
| handleKeyDown = (e) => { | |||
| if (e.key === "Enter" && !e.shiftKey) { | |||
| e.preventDefault(); | |||
| if (this.state.inputValue !== "") { | |||
| this.send(this.state.inputValue); | |||
| this.setState({inputValue: ""}); | |||
| } | |||
| } | |||
| }; | |||
| scrollToListItem(index) { | |||
| const listContainerElement = this.listContainerRef.current; | |||
| if (!listContainerElement) { | |||
| return; | |||
| } | |||
| const targetItem = listContainerElement.querySelector( | |||
| `#chatbox-list-item-${index}` | |||
| ); | |||
| if (!targetItem) { | |||
| return; | |||
| } | |||
| const scrollDistance = targetItem.offsetTop - listContainerElement.offsetTop; | |||
| listContainerElement.scrollTo({ | |||
| top: scrollDistance, | |||
| behavior: "smooth", | |||
| }); | |||
| } | |||
| send = (text) => { | |||
| this.props.sendMessage(text); | |||
| this.setState({inputValue: ""}); | |||
| }; | |||
| renderText(text) { | |||
| const lines = text.split("\n").map((line, index) => ( | |||
| <React.Fragment key={index}> | |||
| {line} | |||
| <br /> | |||
| </React.Fragment> | |||
| )); | |||
| return <div>{lines}</div>; | |||
| } | |||
| renderList() { | |||
| if (this.props.messages === undefined || this.props.messages === null) { | |||
| return ( | |||
| <div style={{display: "flex", justifyContent: "center", alignItems: "center"}}> | |||
| <Spin size="large" tip={i18next.t("login:Loading")} style={{paddingTop: "20%"}} /> | |||
| </div> | |||
| ); | |||
| } | |||
| return ( | |||
| <React.Fragment> | |||
| <div ref={this.listContainerRef} style={{position: "relative", maxHeight: "calc(100vh - 140px)", overflowY: "auto"}}> | |||
| <List | |||
| itemLayout="horizontal" | |||
| dataSource={[...this.props.messages, {}]} | |||
| renderItem={(item, index) => { | |||
| if (Object.keys(item).length === 0 && item.constructor === Object) { | |||
| return <List.Item id={`chatbox-list-item-${index}`} style={{ | |||
| height: "160px", | |||
| backgroundColor: index % 2 === 0 ? "white" : "rgb(247,247,248)", | |||
| borderBottom: "1px solid rgb(229, 229, 229)", | |||
| position: "relative", | |||
| }} />; | |||
| } | |||
| return ( | |||
| <List.Item id={`chatbox-list-item-${index}`} style={{ | |||
| backgroundColor: index % 2 === 0 ? "white" : "rgb(247,247,248)", | |||
| borderBottom: "1px solid rgb(229, 229, 229)", | |||
| position: "relative", | |||
| }}> | |||
| <div style={{width: "800px", margin: "0 auto", position: "relative"}}> | |||
| <List.Item.Meta | |||
| avatar={<Avatar style={{width: "30px", height: "30px", borderRadius: "3px"}} src={item.author === `${this.props.account.owner}/${this.props.account.name}` ? this.props.account.avatar : "https://cdn.casbin.com/casdoor/resource/built-in/admin/gpt.png"} />} | |||
| title={ | |||
| <div style={{fontSize: "16px", fontWeight: "normal", lineHeight: "24px", marginTop: "-15px", marginLeft: "5px", marginRight: "80px"}}> | |||
| { | |||
| !item.text.includes("#ERROR#") ? this.renderText(item.text) : ( | |||
| <Alert message={item.text.slice("#ERROR#: ".length)} type="error" showIcon /> | |||
| ) | |||
| } | |||
| </div> | |||
| } | |||
| /> | |||
| <div style={{position: "absolute", top: "0px", right: "0px"}}> | |||
| <CopyOutlined style={{color: "rgb(172,172,190)", margin: "5px"}} /> | |||
| <LikeOutlined style={{color: "rgb(172,172,190)", margin: "5px"}} /> | |||
| <DislikeOutlined style={{color: "rgb(172,172,190)", margin: "5px"}} /> | |||
| </div> | |||
| </div> | |||
| </List.Item> | |||
| ); | |||
| }} | |||
| /> | |||
| </div> | |||
| <div style={{ | |||
| position: "absolute", | |||
| bottom: 0, | |||
| left: 0, | |||
| right: 0, | |||
| height: "120px", | |||
| background: "linear-gradient(transparent 0%, rgba(255, 255, 255, 0.8) 50%, white 100%)", | |||
| pointerEvents: "none", | |||
| }} /> | |||
| </React.Fragment> | |||
| ); | |||
| } | |||
| renderInput() { | |||
| return ( | |||
| <div | |||
| style={{ | |||
| position: "fixed", | |||
| bottom: "90px", | |||
| width: "100%", | |||
| display: "flex", | |||
| justifyContent: "center", | |||
| }} | |||
| > | |||
| <div style={{position: "relative", width: "760px", marginLeft: "-280px"}}> | |||
| <TextArea | |||
| placeholder={"Send a message..."} | |||
| autoSize={{maxRows: 8}} | |||
| value={this.state.inputValue} | |||
| onChange={(e) => this.setState({inputValue: e.target.value})} | |||
| onKeyDown={this.handleKeyDown} | |||
| style={{ | |||
| fontSize: "16px", | |||
| fontWeight: "normal", | |||
| lineHeight: "24px", | |||
| width: "770px", | |||
| height: "48px", | |||
| borderRadius: "6px", | |||
| borderColor: "rgb(229,229,229)", | |||
| boxShadow: "0 0 15px rgba(0, 0, 0, 0.1)", | |||
| paddingLeft: "17px", | |||
| paddingRight: "17px", | |||
| paddingTop: "12px", | |||
| paddingBottom: "12px", | |||
| }} | |||
| suffix={<SendOutlined style={{color: "rgb(210,210,217"}} onClick={() => this.send(this.state.inputValue)} />} | |||
| autoComplete="off" | |||
| /> | |||
| <SendOutlined | |||
| style={{ | |||
| color: this.state.inputValue === "" ? "rgb(210,210,217)" : "rgb(142,142,160)", | |||
| position: "absolute", | |||
| bottom: "17px", | |||
| right: "17px", | |||
| }} | |||
| onClick={() => this.send(this.state.inputValue)} | |||
| /> | |||
| </div> | |||
| </div> | |||
| ); | |||
| } | |||
| render() { | |||
| return ( | |||
| <div> | |||
| { | |||
| this.renderList() | |||
| } | |||
| { | |||
| this.renderInput() | |||
| } | |||
| </div> | |||
| ); | |||
| } | |||
| } | |||
| export default ChatBox; | |||
| @@ -0,0 +1,180 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| import React from "react"; | |||
| import {Button, Menu} from "antd"; | |||
| import {DeleteOutlined, LayoutOutlined, PlusOutlined} from "@ant-design/icons"; | |||
| class ChatMenu extends React.Component { | |||
| constructor(props) { | |||
| super(props); | |||
| const items = this.chatsToItems(this.props.chats); | |||
| const openKeys = items.map((item) => item.key); | |||
| this.state = { | |||
| openKeys: openKeys, | |||
| selectedKeys: ["0-0"], | |||
| }; | |||
| } | |||
| chatsToItems(chats) { | |||
| const categories = {}; | |||
| chats.forEach((chat) => { | |||
| if (!categories[chat.category]) { | |||
| categories[chat.category] = []; | |||
| } | |||
| categories[chat.category].push(chat); | |||
| }); | |||
| const selectedKeys = this.state === undefined ? [] : this.state.selectedKeys; | |||
| return Object.keys(categories).map((category, index) => { | |||
| return { | |||
| key: `${index}`, | |||
| icon: <LayoutOutlined />, | |||
| label: category, | |||
| children: categories[category].map((chat, chatIndex) => { | |||
| const globalChatIndex = chats.indexOf(chat); | |||
| const isSelected = selectedKeys.includes(`${index}-${chatIndex}`); | |||
| return { | |||
| key: `${index}-${chatIndex}`, | |||
| index: globalChatIndex, | |||
| label: ( | |||
| <div | |||
| className="menu-item-container" | |||
| style={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| alignItems: "center", | |||
| }} | |||
| > | |||
| {chat.displayName} | |||
| {isSelected && ( | |||
| <DeleteOutlined | |||
| className="menu-item-delete-icon" | |||
| style={{ | |||
| visibility: "visible", | |||
| color: "inherit", | |||
| transition: "color 0.3s", | |||
| }} | |||
| onMouseEnter={(e) => { | |||
| e.currentTarget.style.color = "rgba(89,54,213,0.6)"; | |||
| }} | |||
| onMouseLeave={(e) => { | |||
| e.currentTarget.style.color = "inherit"; | |||
| }} | |||
| onMouseDown={(e) => { | |||
| e.currentTarget.style.color = "rgba(89,54,213,0.4)"; | |||
| }} | |||
| onMouseUp={(e) => { | |||
| e.currentTarget.style.color = "rgba(89,54,213,0.6)"; | |||
| }} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| if (this.props.onDeleteChat) { | |||
| this.props.onDeleteChat(globalChatIndex); | |||
| } | |||
| }} | |||
| /> | |||
| )} | |||
| </div> | |||
| ), | |||
| }; | |||
| }), | |||
| }; | |||
| }); | |||
| } | |||
| onSelect = (info) => { | |||
| const [categoryIndex, chatIndex] = info.selectedKeys[0].split("-").map(Number); | |||
| const selectedItem = this.chatsToItems(this.props.chats)[categoryIndex].children[chatIndex]; | |||
| this.setState({ | |||
| selectedKeys: [`${categoryIndex}-${chatIndex}`], | |||
| }); | |||
| if (this.props.onSelectChat) { | |||
| this.props.onSelectChat(selectedItem.index); | |||
| } | |||
| }; | |||
| getRootSubmenuKeys(items) { | |||
| return items.map((item, index) => `${index}`); | |||
| } | |||
| setSelectedKeyToNewChat(chats) { | |||
| const items = this.chatsToItems(chats); | |||
| const openKeys = items.map((item) => item.key); | |||
| this.setState({ | |||
| openKeys: openKeys, | |||
| selectedKeys: ["0-0"], | |||
| }); | |||
| } | |||
| onOpenChange = (keys) => { | |||
| const items = this.chatsToItems(this.props.chats); | |||
| const rootSubmenuKeys = this.getRootSubmenuKeys(items); | |||
| const latestOpenKey = keys.find((key) => this.state.openKeys.indexOf(key) === -1); | |||
| if (rootSubmenuKeys.indexOf(latestOpenKey) === -1) { | |||
| this.setState({openKeys: keys}); | |||
| } else { | |||
| this.setState({openKeys: latestOpenKey ? [latestOpenKey] : []}); | |||
| } | |||
| }; | |||
| render() { | |||
| const items = this.chatsToItems(this.props.chats); | |||
| return ( | |||
| <div> | |||
| <Button | |||
| icon={<PlusOutlined />} | |||
| style={{ | |||
| width: "calc(100% - 8px)", | |||
| height: "40px", | |||
| margin: "4px", | |||
| borderColor: "rgb(229,229,229)", | |||
| }} | |||
| onMouseEnter={(e) => { | |||
| e.currentTarget.style.borderColor = "rgba(89,54,213,0.6)"; | |||
| }} | |||
| onMouseLeave={(e) => { | |||
| e.currentTarget.style.borderColor = "rgba(0, 0, 0, 0.1)"; | |||
| }} | |||
| onMouseDown={(e) => { | |||
| e.currentTarget.style.borderColor = "rgba(89,54,213,0.4)"; | |||
| }} | |||
| onMouseUp={(e) => { | |||
| e.currentTarget.style.borderColor = "rgba(89,54,213,0.6)"; | |||
| }} | |||
| onClick={this.props.onAddChat} | |||
| > | |||
| New Chat | |||
| </Button> | |||
| <Menu | |||
| style={{maxHeight: "calc(100vh - 140px - 40px - 8px)", overflowY: "auto"}} | |||
| mode="inline" | |||
| openKeys={this.state.openKeys} | |||
| selectedKeys={this.state.selectedKeys} | |||
| onOpenChange={this.onOpenChange} | |||
| onSelect={this.onSelect} | |||
| items={items} | |||
| /> | |||
| </div> | |||
| ); | |||
| } | |||
| } | |||
| export default ChatMenu; | |||
| @@ -0,0 +1,286 @@ | |||
| // Copyright 2023 The casbin Authors. All Rights Reserved. | |||
| // | |||
| // Licensed under the Apache License, Version 2.0 (the "License"); | |||
| // you may not use this file except in compliance with the License. | |||
| // You may obtain a copy of the License at | |||
| // | |||
| // http://www.apache.org/licenses/LICENSE-2.0 | |||
| // | |||
| // Unless required by applicable law or agreed to in writing, software | |||
| // distributed under the License is distributed on an "AS IS" BASIS, | |||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| // See the License for the specific language governing permissions and | |||
| // limitations under the License. | |||
| import React from "react"; | |||
| import {Spin} from "antd"; | |||
| import moment from "moment"; | |||
| import ChatMenu from "./ChatMenu"; | |||
| import ChatBox from "./ChatBox"; | |||
| import * as Setting from "./Setting"; | |||
| import * as ChatBackend from "./backend/ChatBackend"; | |||
| import * as MessageBackend from "./backend/MessageBackend"; | |||
| import i18next from "i18next"; | |||
| import BaseListPage from "./BaseListPage"; | |||
| class ChatPage extends BaseListPage { | |||
| constructor(props) { | |||
| super(props); | |||
| this.menu = React.createRef(); | |||
| } | |||
| UNSAFE_componentWillMount() { | |||
| this.setState({ | |||
| loading: true, | |||
| }); | |||
| this.fetch(); | |||
| } | |||
| newChat(chat) { | |||
| const randomName = Setting.getRandomName(); | |||
| return { | |||
| owner: "admin", // this.props.account.applicationName, | |||
| name: `chat_${randomName}`, | |||
| createdTime: moment().format(), | |||
| updatedTime: moment().format(), | |||
| // organization: this.props.account.owner, | |||
| displayName: `New Chat - ${randomName}`, | |||
| type: "AI", | |||
| category: chat !== undefined ? chat.category : "Chat Category - 1", | |||
| user1: `${this.props.account.owner}/${this.props.account.name}`, | |||
| user2: "", | |||
| users: [`${this.props.account.owner}/${this.props.account.name}`], | |||
| messageCount: 0, | |||
| }; | |||
| } | |||
| newMessage(text) { | |||
| const randomName = Setting.getRandomName(); | |||
| return { | |||
| owner: this.props.account.owner, // this.props.account.messagename, | |||
| name: `message_${randomName}`, | |||
| createdTime: moment().format(), | |||
| // organization: this.props.account.owner, | |||
| chat: this.state.chatName, | |||
| replyTo: "", | |||
| author: `${this.props.account.owner}/${this.props.account.name}`, | |||
| text: text, | |||
| }; | |||
| } | |||
| sendMessage(text) { | |||
| const newMessage = this.newMessage(text); | |||
| MessageBackend.addMessage(newMessage) | |||
| .then((res) => { | |||
| if (res.status === "ok") { | |||
| this.getMessages(this.state.chatName); | |||
| } else { | |||
| Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`); | |||
| } | |||
| }) | |||
| .catch(error => { | |||
| Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`); | |||
| }); | |||
| } | |||
| getMessages(chatName) { | |||
| MessageBackend.getChatMessages(chatName) | |||
| .then((res) => { | |||
| this.setState({ | |||
| messages: res.data, | |||
| }); | |||
| if (res.data.length > 0) { | |||
| const lastMessage = res.data[res.data.length - 1]; | |||
| if (lastMessage.author === "AI" && lastMessage.replyTo !== "" && lastMessage.text === "") { | |||
| let text = ""; | |||
| MessageBackend.getMessageAnswer(lastMessage.owner, lastMessage.name, (data) => { | |||
| if (data === "") { | |||
| data = "\n"; | |||
| } | |||
| const lastMessage2 = Setting.deepCopy(lastMessage); | |||
| text += data; | |||
| lastMessage2.text = text; | |||
| res.data[res.data.length - 1] = lastMessage2; | |||
| this.setState({ | |||
| messages: res.data, | |||
| }); | |||
| }, (error) => { | |||
| Setting.showMessage("error", `${i18next.t("general:Failed to get answer")}: ${error}`); | |||
| const lastMessage2 = Setting.deepCopy(lastMessage); | |||
| lastMessage2.text = `#ERROR#: ${error}`; | |||
| res.data[res.data.length - 1] = lastMessage2; | |||
| this.setState({ | |||
| messages: res.data, | |||
| }); | |||
| }); | |||
| } | |||
| } | |||
| Setting.scrollToDiv(`chatbox-list-item-${res.data.length}`); | |||
| }); | |||
| } | |||
| addChat(chat) { | |||
| const newChat = this.newChat(chat); | |||
| ChatBackend.addChat(newChat) | |||
| .then((res) => { | |||
| if (res.status === "ok") { | |||
| Setting.showMessage("success", i18next.t("general:Successfully added")); | |||
| this.setState({ | |||
| chatName: newChat.name, | |||
| messages: null, | |||
| }); | |||
| this.getMessages(newChat.name); | |||
| this.fetch({}, false); | |||
| } else { | |||
| Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`); | |||
| } | |||
| }) | |||
| .catch(error => { | |||
| Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`); | |||
| }); | |||
| } | |||
| deleteChat(chats, i, chat) { | |||
| ChatBackend.deleteChat(chat) | |||
| .then((res) => { | |||
| if (res.status === "ok") { | |||
| Setting.showMessage("success", i18next.t("general:Successfully deleted")); | |||
| const data = Setting.deleteRow(this.state.data, i); | |||
| const j = Math.min(i, data.length - 1); | |||
| if (j < 0) { | |||
| this.setState({ | |||
| chatName: undefined, | |||
| messages: [], | |||
| data: data, | |||
| }); | |||
| } else { | |||
| const focusedChat = data[j]; | |||
| this.setState({ | |||
| chatName: focusedChat.name, | |||
| messages: null, | |||
| data: data, | |||
| }); | |||
| this.getMessages(focusedChat.name); | |||
| } | |||
| } else { | |||
| Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`); | |||
| } | |||
| }) | |||
| .catch(error => { | |||
| Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`); | |||
| }); | |||
| } | |||
| getCurrentChat() { | |||
| return this.state.data.filter(chat => chat.name === this.state.chatName)[0]; | |||
| } | |||
| renderTable(chats) { | |||
| const onSelectChat = (i) => { | |||
| const chat = chats[i]; | |||
| this.setState({ | |||
| chatName: chat.name, | |||
| messages: null, | |||
| }); | |||
| this.getMessages(chat.name); | |||
| }; | |||
| const onAddChat = () => { | |||
| const chat = this.getCurrentChat(); | |||
| this.addChat(chat); | |||
| }; | |||
| const onDeleteChat = (i) => { | |||
| const chat = chats[i]; | |||
| this.deleteChat(chats, i, chat); | |||
| }; | |||
| if (this.state.loading) { | |||
| return ( | |||
| <div style={{display: "flex", justifyContent: "center", alignItems: "center"}}> | |||
| <Spin size="large" tip={i18next.t("login:Loading")} style={{paddingTop: "10%"}} /> | |||
| </div> | |||
| ); | |||
| } | |||
| return ( | |||
| <div style={{display: "flex", height: "calc(100vh - 136px)"}}> | |||
| <div style={{width: "250px", height: "100%", backgroundColor: "white", borderRight: "1px solid rgb(245,245,245)", borderBottom: "1px solid rgb(245,245,245)"}}> | |||
| <ChatMenu ref={this.menu} chats={chats} onSelectChat={onSelectChat} onAddChat={onAddChat} onDeleteChat={onDeleteChat} /> | |||
| </div> | |||
| <div style={{flex: 1, height: "100%", backgroundColor: "white", position: "relative"}}> | |||
| { | |||
| (this.state.messages === undefined || this.state.messages === null) ? null : ( | |||
| <div style={{ | |||
| position: "absolute", | |||
| top: -50, | |||
| left: 0, | |||
| right: 0, | |||
| bottom: 0, | |||
| backgroundImage: "url(https://cdn.casbin.org/img/casdoor-logo_1185x256.png)", | |||
| backgroundPosition: "center", | |||
| backgroundRepeat: "no-repeat", | |||
| backgroundSize: "200px auto", | |||
| backgroundBlendMode: "luminosity", | |||
| filter: "grayscale(80%) brightness(140%) contrast(90%)", | |||
| opacity: 0.5, | |||
| pointerEvents: "none", | |||
| }}> | |||
| </div> | |||
| ) | |||
| } | |||
| <ChatBox messages={this.state.messages} sendMessage={(text) => {this.sendMessage(text);}} account={this.props.account} /> | |||
| </div> | |||
| </div> | |||
| ); | |||
| } | |||
| fetch = (params = {}, setLoading = true) => { | |||
| let field = params.searchedColumn, value = params.searchText; | |||
| const sortField = params.sortField, sortOrder = params.sortOrder; | |||
| if (params.category !== undefined && params.category !== null) { | |||
| field = "category"; | |||
| value = params.category; | |||
| } else if (params.type !== undefined && params.type !== null) { | |||
| field = "type"; | |||
| value = params.type; | |||
| } | |||
| if (setLoading) { | |||
| this.setState({loading: true}); | |||
| } | |||
| ChatBackend.getChats("admin", -1, field, value, sortField, sortOrder) | |||
| .then((res) => { | |||
| if (res.status === "ok") { | |||
| this.setState({ | |||
| loading: false, | |||
| data: res.data, | |||
| messages: [], | |||
| searchText: params.searchText, | |||
| searchedColumn: params.searchedColumn, | |||
| }); | |||
| const chats = res.data; | |||
| if (this.state.chatName === undefined && chats.length > 0) { | |||
| const chat = chats[0]; | |||
| this.getMessages(chat.name); | |||
| this.setState({ | |||
| chatName: chat.name, | |||
| }); | |||
| } | |||
| if (!setLoading) { | |||
| this.menu.current.setSelectedKeyToNewChat(chats); | |||
| } | |||
| } | |||
| }); | |||
| }; | |||
| } | |||
| export default ChatPage; | |||
| @@ -630,3 +630,12 @@ export function getOption(label, value) { | |||
| value, | |||
| }; | |||
| } | |||
| export function scrollToDiv(divId) { | |||
| if (divId) { | |||
| const ele = document.getElementById(divId); | |||
| if (ele) { | |||
| ele.scrollIntoView({behavior: "smooth"}); | |||
| } | |||
| } | |||
| } | |||
| @@ -21,8 +21,8 @@ export function getGlobalChats() { | |||
| }).then(res => res.json()); | |||
| } | |||
| export function getChats(owner) { | |||
| return fetch(`${Setting.ServerUrl}/api/get-chats?owner=${owner}`, { | |||
| export function getChats(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") { | |||
| return fetch(`${Setting.ServerUrl}/api/get-chats?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, { | |||
| method: "GET", | |||
| credentials: "include", | |||
| }).then(res => res.json()); | |||
| @@ -28,6 +28,30 @@ export function getMessages(owner) { | |||
| }).then(res => res.json()); | |||
| } | |||
| export function getChatMessages(chat) { | |||
| return fetch(`${Setting.ServerUrl}/api/get-messages?chat=${chat}`, { | |||
| method: "GET", | |||
| credentials: "include", | |||
| }).then(res => res.json()); | |||
| } | |||
| export function getMessageAnswer(owner, name, onMessage, onError) { | |||
| const eventSource = new EventSource(`${Setting.ServerUrl}/api/get-message-answer?id=${owner}/${encodeURIComponent(name)}`); | |||
| eventSource.addEventListener("message", (e) => { | |||
| onMessage(e.data); | |||
| }); | |||
| eventSource.addEventListener("myerror", (e) => { | |||
| onError(e.data); | |||
| eventSource.close(); | |||
| }); | |||
| eventSource.addEventListener("end", (e) => { | |||
| eventSource.close(); | |||
| }); | |||
| } | |||
| export function getMessage(owner, name) { | |||
| return fetch(`${Setting.ServerUrl}/api/get-message?id=${owner}/${encodeURIComponent(name)}`, { | |||
| method: "GET", | |||