* 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", | |||