* 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" | casdoorOrganization = "casbin" | ||||
casdoorApplication = "app-casibase" | casdoorApplication = "app-casibase" | ||||
cacheDir = "C:/casibase_cache" | 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 ( | import ( | ||||
"encoding/json" | "encoding/json" | ||||
"fmt" | |||||
"strings" | |||||
"github.com/casbin/casibase/ai" | |||||
"github.com/casbin/casibase/object" | "github.com/casbin/casibase/object" | ||||
"github.com/casbin/casibase/util" | |||||
) | ) | ||||
func (c *ApiController) GetGlobalMessages() { | func (c *ApiController) GetGlobalMessages() { | ||||
@@ -32,26 +36,146 @@ func (c *ApiController) GetGlobalMessages() { | |||||
func (c *ApiController) GetMessages() { | func (c *ApiController) GetMessages() { | ||||
owner := c.Input().Get("owner") | 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 { | if err != nil { | ||||
c.ResponseError(err.Error()) | c.ResponseError(err.Error()) | ||||
return | 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") | 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) | message, err := object.GetMessage(id) | ||||
if err != nil { | if err != nil { | ||||
c.ResponseError(err.Error()) | c.ResponseError(err.Error()) | ||||
return | 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() { | func (c *ApiController) UpdateMessage() { | ||||
@@ -81,12 +205,47 @@ func (c *ApiController) AddMessage() { | |||||
return | 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) | success, err := object.AddMessage(&message) | ||||
if err != nil { | if err != nil { | ||||
c.ResponseError(err.Error()) | c.ResponseError(err.Error()) | ||||
return | 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) | c.ResponseOk(success) | ||||
} | } | ||||
@@ -13,8 +13,11 @@ require ( | |||||
github.com/google/uuid v1.3.0 | github.com/google/uuid v1.3.0 | ||||
github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 | github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 | ||||
github.com/muesli/kmeans v0.3.0 | github.com/muesli/kmeans v0.3.0 | ||||
github.com/pkoukk/tiktoken-go v0.1.1 | |||||
github.com/rclone/rclone v1.63.0 | github.com/rclone/rclone v1.63.0 | ||||
github.com/sashabaranov/go-openai v1.12.0 | |||||
github.com/tealeg/xlsx v1.0.5 | github.com/tealeg/xlsx v1.0.5 | ||||
golang.org/x/net v0.8.0 | |||||
gonum.org/v1/gonum v0.11.0 | gonum.org/v1/gonum v0.11.0 | ||||
xorm.io/core v0.7.3 | xorm.io/core v0.7.3 | ||||
xorm.io/xorm v1.2.5 | 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/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/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/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.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 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= | ||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= | 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/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 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE= | ||||
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= | 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | 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= | 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/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/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/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 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= | ||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= | 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= | 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/astaxie/beego/session/redis" | ||||
"github.com/casbin/casibase/casdoor" | "github.com/casbin/casibase/casdoor" | ||||
"github.com/casbin/casibase/object" | "github.com/casbin/casibase/object" | ||||
"github.com/casbin/casibase/proxy" | |||||
"github.com/casbin/casibase/routers" | "github.com/casbin/casibase/routers" | ||||
) | ) | ||||
@@ -27,6 +28,8 @@ func main() { | |||||
object.InitAdapter() | object.InitAdapter() | ||||
casdoor.InitCasdoorAdapter() | casdoor.InitCasdoorAdapter() | ||||
proxy.InitHttpClient() | |||||
beego.InsertFilter("*", beego.BeforeRouter, cors.Allow(&cors.Options{ | beego.InsertFilter("*", beego.BeforeRouter, cors.Allow(&cors.Options{ | ||||
AllowOrigins: []string{"*"}, | AllowOrigins: []string{"*"}, | ||||
AllowMethods: []string{"GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS"}, | AllowMethods: []string{"GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS"}, | ||||
@@ -43,6 +43,16 @@ func GetGlobalMessages() ([]*Message, error) { | |||||
return messages, nil | 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) { | func GetMessages(owner string) ([]*Message, error) { | ||||
messages := []*Message{} | messages := []*Message{} | ||||
err := adapter.engine.Desc("created_time").Find(&messages, &Message{Owner: owner}) | 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-global-messages", &controllers.ApiController{}, "GET:GetGlobalMessages") | ||||
beego.Router("/api/get-messages", &controllers.ApiController{}, "GET:GetMessages") | beego.Router("/api/get-messages", &controllers.ApiController{}, "GET:GetMessages") | ||||
beego.Router("/api/get-message", &controllers.ApiController{}, "GET:GetMessage") | 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/update-message", &controllers.ApiController{}, "POST:UpdateMessage") | ||||
beego.Router("/api/add-message", &controllers.ApiController{}, "POST:AddMessage") | beego.Router("/api/add-message", &controllers.ApiController{}, "POST:AddMessage") | ||||
beego.Router("/api/delete-message", &controllers.ApiController{}, "POST:DeleteMessage") | beego.Router("/api/delete-message", &controllers.ApiController{}, "POST:DeleteMessage") | ||||
@@ -19,8 +19,10 @@ import ( | |||||
"errors" | "errors" | ||||
"fmt" | "fmt" | ||||
"io/ioutil" | "io/ioutil" | ||||
"math/rand" | |||||
"strconv" | "strconv" | ||||
"strings" | "strings" | ||||
"time" | |||||
"github.com/google/uuid" | "github.com/google/uuid" | ||||
) | ) | ||||
@@ -139,3 +141,17 @@ func DecodeBase64(s string) string { | |||||
return string(res) | 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) | tm := time.Unix(timestamp, 0) | ||||
return tm.Format(time.RFC3339) | 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 {Link, Redirect, Route, Switch, withRouter} from "react-router-dom"; | ||||
import {StyleProvider, legacyLogicalPropertiesTransformer} from "@ant-design/cssinjs"; | import {StyleProvider, legacyLogicalPropertiesTransformer} from "@ant-design/cssinjs"; | ||||
import {Avatar, Button, Card, ConfigProvider, Drawer, Dropdown, FloatButton, Layout, Menu} from "antd"; | 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 "./App.less"; | ||||
import * as Setting from "./Setting"; | import * as Setting from "./Setting"; | ||||
import * as AccountBackend from "./backend/AccountBackend"; | import * as AccountBackend from "./backend/AccountBackend"; | ||||
@@ -45,6 +45,7 @@ import ChatEditPage from "./ChatEditPage"; | |||||
import ChatListPage from "./ChatListPage"; | import ChatListPage from "./ChatListPage"; | ||||
import MessageListPage from "./MessageListPage"; | import MessageListPage from "./MessageListPage"; | ||||
import MessageEditPage from "./MessageEditPage"; | import MessageEditPage from "./MessageEditPage"; | ||||
import ChatPage from "./ChatPage"; | |||||
const {Header, Footer, Content} = Layout; | const {Header, Footer, Content} = Layout; | ||||
@@ -198,14 +199,19 @@ class App extends Component { | |||||
items.push(Setting.getItem(<><SettingOutlined /> {i18next.t("account:My Account")}</>, | items.push(Setting.getItem(<><SettingOutlined /> {i18next.t("account:My Account")}</>, | ||||
"/account" | "/account" | ||||
)); | )); | ||||
items.push(Setting.getItem(<><CommentOutlined /> {i18next.t("account:Chats & Messages")}</>, | |||||
"/chat" | |||||
)); | |||||
items.push(Setting.getItem(<><LogoutOutlined /> {i18next.t("account:Sign Out")}</>, | items.push(Setting.getItem(<><LogoutOutlined /> {i18next.t("account:Sign Out")}</>, | ||||
"/logout" | "/logout" | ||||
)); | )); | ||||
const onClick = ({e}) => { | |||||
const onClick = (e) => { | |||||
if (e.key === "/account") { | if (e.key === "/account") { | ||||
Setting.openLink(Setting.getMyProfileUrl(this.state.account)); | Setting.openLink(Setting.getMyProfileUrl(this.state.account)); | ||||
} else if (e.key === "/logout") { | } else if (e.key === "/logout") { | ||||
this.signout(); | 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="/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" 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="/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> | </Switch> | ||||
); | ); | ||||
} | } | ||||
isWithoutCard() { | |||||
return Setting.isMobile() || window.location.pathname === "/chat"; | |||||
} | |||||
renderContent() { | renderContent() { | ||||
const onClick = ({key}) => { | const onClick = ({key}) => { | ||||
this.props.history.push(key); | this.props.history.push(key); | ||||
@@ -420,9 +431,12 @@ class App extends Component { | |||||
} | } | ||||
</Header> | </Header> | ||||
<Content style={{display: "flex", flexDirection: "column"}}> | <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> | </Content> | ||||
{this.renderFooter()} | {this.renderFooter()} | ||||
</Layout> | </Layout> | ||||
@@ -93,6 +93,7 @@ img { | |||||
border-radius: 7px; | border-radius: 7px; | ||||
float: right; | float: right; | ||||
cursor: pointer; | cursor: pointer; | ||||
margin-right: 3px; | |||||
&:hover { | &:hover { | ||||
background-color: #f5f5f5; | 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, | 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()); | }).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", | method: "GET", | ||||
credentials: "include", | credentials: "include", | ||||
}).then(res => res.json()); | }).then(res => res.json()); | ||||
@@ -28,6 +28,30 @@ export function getMessages(owner) { | |||||
}).then(res => res.json()); | }).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) { | export function getMessage(owner, name) { | ||||
return fetch(`${Setting.ServerUrl}/api/get-message?id=${owner}/${encodeURIComponent(name)}`, { | return fetch(`${Setting.ServerUrl}/api/get-message?id=${owner}/${encodeURIComponent(name)}`, { | ||||
method: "GET", | method: "GET", | ||||