From fe0df16ac1f71c62c87b59bc3a1d14fe72c995f5 Mon Sep 17 00:00:00 2001 From: Kelvin Chiu Date: Sat, 22 Jul 2023 11:14:07 +0800 Subject: [PATCH] feat: add chat page (#589) * feat: add chat page * feat: add chat page * Update ai.go * Update ai_test.go * Update message.go --------- Co-authored-by: hsluoyz --- ai/ai.go | 141 +++++++++++++++ ai/ai_test.go | 42 +++++ ai/proxy.go | 28 +++ ai/util.go | 28 +++ conf/app.conf | 3 +- conf/conf.go | 119 +++++++++++++ controllers/message.go | 167 ++++++++++++++++- go.mod | 3 + go.sum | 6 + main.go | 3 + object/message.go | 10 ++ proxy/proxy.go | 87 +++++++++ routers/router.go | 1 + util/string.go | 16 ++ util/time.go | 14 ++ web/src/App.js | 24 ++- web/src/App.less | 1 + web/src/BaseListPage.js | 54 ++++++ web/src/ChatBox.js | 219 +++++++++++++++++++++++ web/src/ChatMenu.js | 180 +++++++++++++++++++ web/src/ChatPage.js | 286 ++++++++++++++++++++++++++++++ web/src/Setting.js | 9 + web/src/backend/ChatBackend.js | 4 +- web/src/backend/MessageBackend.js | 24 +++ 24 files changed, 1457 insertions(+), 12 deletions(-) create mode 100644 ai/ai.go create mode 100644 ai/ai_test.go create mode 100644 ai/proxy.go create mode 100644 ai/util.go create mode 100644 conf/conf.go create mode 100644 proxy/proxy.go create mode 100644 web/src/BaseListPage.js create mode 100644 web/src/ChatBox.js create mode 100644 web/src/ChatMenu.js create mode 100644 web/src/ChatPage.js diff --git a/ai/ai.go b/ai/ai.go new file mode 100644 index 0000000..8329068 --- /dev/null +++ b/ai/ai.go @@ -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 +} diff --git a/ai/ai_test.go b/ai/ai_test.go new file mode 100644 index 0000000..2487ab1 --- /dev/null +++ b/ai/ai_test.go @@ -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, "")) +} diff --git a/ai/proxy.go b/ai/proxy.go new file mode 100644 index 0000000..ac26e1a --- /dev/null +++ b/ai/proxy.go @@ -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 +} diff --git a/ai/util.go b/ai/util.go new file mode 100644 index 0000000..c85cc05 --- /dev/null +++ b/ai/util.go @@ -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 +} diff --git a/conf/app.conf b/conf/app.conf index fbd22ac..494342b 100644 --- a/conf/app.conf +++ b/conf/app.conf @@ -15,4 +15,5 @@ casdoorDbName = casdoor casdoorOrganization = "casbin" casdoorApplication = "app-casibase" cacheDir = "C:/casibase_cache" -appDir = "" \ No newline at end of file +appDir = "" +socks5Proxy = "127.0.0.1:7890" \ No newline at end of file diff --git a/conf/conf.go b/conf/conf.go new file mode 100644 index 0000000..da162dc --- /dev/null +++ b/conf/conf.go @@ -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 +} diff --git a/controllers/message.go b/controllers/message.go index 33e220d..c766e54 100644 --- a/controllers/message.go +++ b/controllers/message.go @@ -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) } diff --git a/go.mod b/go.mod index 26d54f5..4f56cfa 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index e6a86a7..3a93fa2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index d7c8caf..d73f5fd 100644 --- a/main.go +++ b/main.go @@ -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"}, diff --git a/object/message.go b/object/message.go index 12eb682..5f19110 100644 --- a/object/message.go +++ b/object/message.go @@ -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}) diff --git a/proxy/proxy.go b/proxy/proxy.go new file mode 100644 index 0000000..2ea72e8 --- /dev/null +++ b/proxy/proxy.go @@ -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 + } +} diff --git a/routers/router.go b/routers/router.go index 51f2b79..0e14aa2 100644 --- a/routers/router.go +++ b/routers/router.go @@ -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") diff --git a/util/string.go b/util/string.go index 33fbb6e..110444f 100644 --- a/util/string.go +++ b/util/string.go @@ -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) +} diff --git a/util/time.go b/util/time.go index 13de0d0..74131c0 100644 --- a/util/time.go +++ b/util/time.go @@ -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") +} diff --git a/web/src/App.js b/web/src/App.js index b5c8b67..3c08c82 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -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(<>  {i18next.t("account:My Account")}, "/account" )); + items.push(Setting.getItem(<>  {i18next.t("account:Chats & Messages")}, + "/chat" + )); items.push(Setting.getItem(<>  {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 { this.renderSigninIfNotSignedIn()} /> this.renderSigninIfNotSignedIn()} /> this.renderSigninIfNotSignedIn()} /> + this.renderSigninIfNotSignedIn()} /> ); } + 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 { } - - {this.renderRouter()} - + {this.isWithoutCard() ? + this.renderRouter() : + + {this.renderRouter()} + + } {this.renderFooter()} diff --git a/web/src/App.less b/web/src/App.less index fcaf0e3..d9ff819 100644 --- a/web/src/App.less +++ b/web/src/App.less @@ -93,6 +93,7 @@ img { border-radius: 7px; float: right; cursor: pointer; + margin-right: 3px; &:hover { background-color: #f5f5f5; diff --git a/web/src/BaseListPage.js b/web/src/BaseListPage.js new file mode 100644 index 0000000..9bb3c6c --- /dev/null +++ b/web/src/BaseListPage.js @@ -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 ( + } + /> + ); + } + + return ( +
+ { + this.renderTable(this.state.data) + } +
+ ); + } +} + +export default BaseListPage; diff --git a/web/src/ChatBox.js b/web/src/ChatBox.js new file mode 100644 index 0000000..2f92762 --- /dev/null +++ b/web/src/ChatBox.js @@ -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) => ( + + {line} +
+
+ )); + + return
{lines}
; + } + + renderList() { + if (this.props.messages === undefined || this.props.messages === null) { + return ( +
+ +
+ ); + } + + return ( + +
+ { + if (Object.keys(item).length === 0 && item.constructor === Object) { + return ; + } + + return ( + +
+ } + title={ +
+ { + !item.text.includes("#ERROR#") ? this.renderText(item.text) : ( + + ) + } +
+ } + /> +
+ + + +
+
+
+ ); + }} + /> +
+
+ + ); + } + + renderInput() { + return ( +
+
+