feat: add ai chatbot
This commit is contained in:
		
							parent
							
								
									ca01c7100c
								
							
						
					
					
						commit
						6fe80a5498
					
				
							
								
								
									
										51
									
								
								backend/.air.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								backend/.air.toml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,51 @@ | |||||||
|  | root = "." | ||||||
|  | testdata_dir = "testdata" | ||||||
|  | tmp_dir = "tmp" | ||||||
|  | 
 | ||||||
|  | [build] | ||||||
|  |   args_bin = [] | ||||||
|  |   bin = "./tmp/main" | ||||||
|  |   cmd = "go build -o ./tmp/main ." | ||||||
|  |   delay = 1000 | ||||||
|  |   exclude_dir = ["assets", "tmp", "vendor", "testdata"] | ||||||
|  |   exclude_file = [] | ||||||
|  |   exclude_regex = ["_test.go"] | ||||||
|  |   exclude_unchanged = false | ||||||
|  |   follow_symlink = false | ||||||
|  |   full_bin = "" | ||||||
|  |   include_dir = [] | ||||||
|  |   include_ext = ["go", "tpl", "tmpl", "html"] | ||||||
|  |   include_file = [] | ||||||
|  |   kill_delay = "0s" | ||||||
|  |   log = "build-errors.log" | ||||||
|  |   poll = false | ||||||
|  |   poll_interval = 0 | ||||||
|  |   post_cmd = [] | ||||||
|  |   pre_cmd = [] | ||||||
|  |   rerun = false | ||||||
|  |   rerun_delay = 500 | ||||||
|  |   send_interrupt = false | ||||||
|  |   stop_on_error = false | ||||||
|  | 
 | ||||||
|  | [color] | ||||||
|  |   app = "" | ||||||
|  |   build = "yellow" | ||||||
|  |   main = "magenta" | ||||||
|  |   runner = "green" | ||||||
|  |   watcher = "cyan" | ||||||
|  | 
 | ||||||
|  | [log] | ||||||
|  |   main_only = false | ||||||
|  |   time = false | ||||||
|  | 
 | ||||||
|  | [misc] | ||||||
|  |   clean_on_exit = false | ||||||
|  | 
 | ||||||
|  | [proxy] | ||||||
|  |   app_port = 0 | ||||||
|  |   enabled = false | ||||||
|  |   proxy_port = 0 | ||||||
|  | 
 | ||||||
|  | [screen] | ||||||
|  |   clear_on_rebuild = false | ||||||
|  |   keep_scroll = true | ||||||
							
								
								
									
										3
									
								
								backend/.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								backend/.env.example
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | |||||||
|  | # | ||||||
|  | DATABASE_PATH=database.db | ||||||
|  | GEMINI_API_KEY= | ||||||
							
								
								
									
										5
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | |||||||
|  | # | ||||||
|  | tmp/ | ||||||
|  | .env* | ||||||
|  | !.env.example | ||||||
|  | *.db | ||||||
							
								
								
									
										34
									
								
								backend/database/db.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								backend/database/db.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | |||||||
|  | package database | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"os" | ||||||
|  | 
 | ||||||
|  | 	"gorm.io/driver/sqlite" | ||||||
|  | 	"gorm.io/gorm" | ||||||
|  | 	"gorm.io/gorm/logger" | ||||||
|  | 	"rul.sh/furina-id/models" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var db *gorm.DB | ||||||
|  | 
 | ||||||
|  | func Init() { | ||||||
|  | 	dbPath := os.Getenv("DATABASE_PATH") | ||||||
|  | 	if dbPath == "" { | ||||||
|  | 		panic("DATABASE_PATH is not set") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var err error | ||||||
|  | 	db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{ | ||||||
|  | 		Logger: logger.Default.LogMode(logger.Info), | ||||||
|  | 	}) | ||||||
|  | 	if err != nil { | ||||||
|  | 		panic("failed to connect database") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Migrate the schema | ||||||
|  | 	db.AutoMigrate(&models.Chat{}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func Get() *gorm.DB { | ||||||
|  | 	return db | ||||||
|  | } | ||||||
							
								
								
									
										59
									
								
								backend/go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								backend/go.mod
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,59 @@ | |||||||
|  | module rul.sh/furina-id | ||||||
|  | 
 | ||||||
|  | go 1.22.5 | ||||||
|  | 
 | ||||||
|  | require ( | ||||||
|  | 	github.com/gofiber/fiber/v2 v2.52.5 | ||||||
|  | 	github.com/google/generative-ai-go v0.17.0 | ||||||
|  | 	github.com/joho/godotenv v1.5.1 | ||||||
|  | 	google.golang.org/api v0.186.0 | ||||||
|  | 	gorm.io/driver/sqlite v1.5.6 | ||||||
|  | 	gorm.io/gorm v1.25.11 | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | require ( | ||||||
|  | 	cloud.google.com/go v0.115.0 // indirect | ||||||
|  | 	cloud.google.com/go/ai v0.8.0 // indirect | ||||||
|  | 	cloud.google.com/go/auth v0.6.0 // indirect | ||||||
|  | 	cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect | ||||||
|  | 	cloud.google.com/go/compute/metadata v0.3.0 // indirect | ||||||
|  | 	cloud.google.com/go/longrunning v0.5.7 // indirect | ||||||
|  | 	github.com/andybalholm/brotli v1.0.5 // indirect | ||||||
|  | 	github.com/felixge/httpsnoop v1.0.4 // indirect | ||||||
|  | 	github.com/go-logr/logr v1.4.1 // indirect | ||||||
|  | 	github.com/go-logr/stdr v1.2.2 // indirect | ||||||
|  | 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect | ||||||
|  | 	github.com/golang/protobuf v1.5.4 // indirect | ||||||
|  | 	github.com/google/s2a-go v0.1.7 // indirect | ||||||
|  | 	github.com/google/uuid v1.6.0 // indirect | ||||||
|  | 	github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect | ||||||
|  | 	github.com/googleapis/gax-go/v2 v2.12.5 // indirect | ||||||
|  | 	github.com/jinzhu/inflection v1.0.0 // indirect | ||||||
|  | 	github.com/jinzhu/now v1.1.5 // indirect | ||||||
|  | 	github.com/klauspost/compress v1.17.0 // indirect | ||||||
|  | 	github.com/mattn/go-colorable v0.1.13 // indirect | ||||||
|  | 	github.com/mattn/go-isatty v0.0.20 // indirect | ||||||
|  | 	github.com/mattn/go-runewidth v0.0.15 // indirect | ||||||
|  | 	github.com/mattn/go-sqlite3 v1.14.22 // indirect | ||||||
|  | 	github.com/rivo/uniseg v0.2.0 // indirect | ||||||
|  | 	github.com/valyala/bytebufferpool v1.0.0 // indirect | ||||||
|  | 	github.com/valyala/fasthttp v1.51.0 // indirect | ||||||
|  | 	github.com/valyala/tcplisten v1.0.0 // indirect | ||||||
|  | 	go.opencensus.io v0.24.0 // indirect | ||||||
|  | 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect | ||||||
|  | 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect | ||||||
|  | 	go.opentelemetry.io/otel v1.26.0 // indirect | ||||||
|  | 	go.opentelemetry.io/otel/metric v1.26.0 // indirect | ||||||
|  | 	go.opentelemetry.io/otel/trace v1.26.0 // indirect | ||||||
|  | 	golang.org/x/crypto v0.24.0 // indirect | ||||||
|  | 	golang.org/x/net v0.26.0 // indirect | ||||||
|  | 	golang.org/x/oauth2 v0.21.0 // indirect | ||||||
|  | 	golang.org/x/sync v0.8.0 // indirect | ||||||
|  | 	golang.org/x/sys v0.21.0 // indirect | ||||||
|  | 	golang.org/x/text v0.17.0 // indirect | ||||||
|  | 	golang.org/x/time v0.5.0 // indirect | ||||||
|  | 	google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect | ||||||
|  | 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect | ||||||
|  | 	google.golang.org/grpc v1.64.1 // indirect | ||||||
|  | 	google.golang.org/protobuf v1.34.2 // indirect | ||||||
|  | ) | ||||||
							
								
								
									
										201
									
								
								backend/go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								backend/go.sum
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,201 @@ | |||||||
|  | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= | ||||||
|  | cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= | ||||||
|  | cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= | ||||||
|  | cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= | ||||||
|  | cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= | ||||||
|  | cloud.google.com/go/auth v0.6.0 h1:5x+d6b5zdezZ7gmLWD1m/xNjnaQ2YDhmIz/HH3doy1g= | ||||||
|  | cloud.google.com/go/auth v0.6.0/go.mod h1:b4acV+jLQDyjwm4OXHYjNvRi4jvGBzHWJRtJcy+2P4g= | ||||||
|  | cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= | ||||||
|  | cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= | ||||||
|  | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= | ||||||
|  | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= | ||||||
|  | cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU= | ||||||
|  | cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= | ||||||
|  | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= | ||||||
|  | github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= | ||||||
|  | github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= | ||||||
|  | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= | ||||||
|  | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= | ||||||
|  | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= | ||||||
|  | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||||
|  | 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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= | ||||||
|  | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= | ||||||
|  | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= | ||||||
|  | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= | ||||||
|  | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= | ||||||
|  | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | ||||||
|  | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | ||||||
|  | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= | ||||||
|  | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||||||
|  | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= | ||||||
|  | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= | ||||||
|  | github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= | ||||||
|  | github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= | ||||||
|  | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= | ||||||
|  | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= | ||||||
|  | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= | ||||||
|  | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= | ||||||
|  | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= | ||||||
|  | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||||||
|  | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||||||
|  | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= | ||||||
|  | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= | ||||||
|  | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= | ||||||
|  | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= | ||||||
|  | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= | ||||||
|  | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= | ||||||
|  | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= | ||||||
|  | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= | ||||||
|  | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= | ||||||
|  | github.com/google/generative-ai-go v0.17.0 h1:kUmCXUIwJouD7I7ev3OmxzzQVICyhIWAxaXk2yblCMY= | ||||||
|  | github.com/google/generative-ai-go v0.17.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= | ||||||
|  | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= | ||||||
|  | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= | ||||||
|  | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= | ||||||
|  | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||||
|  | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||||
|  | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||||
|  | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | ||||||
|  | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | ||||||
|  | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= | ||||||
|  | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= | ||||||
|  | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||||
|  | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||||||
|  | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||||
|  | github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= | ||||||
|  | github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= | ||||||
|  | github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= | ||||||
|  | github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= | ||||||
|  | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= | ||||||
|  | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= | ||||||
|  | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= | ||||||
|  | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= | ||||||
|  | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= | ||||||
|  | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= | ||||||
|  | github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= | ||||||
|  | github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= | ||||||
|  | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= | ||||||
|  | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= | ||||||
|  | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= | ||||||
|  | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||||
|  | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||||
|  | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= | ||||||
|  | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= | ||||||
|  | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= | ||||||
|  | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= | ||||||
|  | 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= | ||||||
|  | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= | ||||||
|  | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | ||||||
|  | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||||
|  | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||||||
|  | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | ||||||
|  | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||||
|  | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | ||||||
|  | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | ||||||
|  | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= | ||||||
|  | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||||||
|  | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= | ||||||
|  | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= | ||||||
|  | github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= | ||||||
|  | github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= | ||||||
|  | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= | ||||||
|  | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= | ||||||
|  | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= | ||||||
|  | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= | ||||||
|  | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0= | ||||||
|  | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU= | ||||||
|  | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= | ||||||
|  | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= | ||||||
|  | go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= | ||||||
|  | go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= | ||||||
|  | go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= | ||||||
|  | go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= | ||||||
|  | go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= | ||||||
|  | go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= | ||||||
|  | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||||
|  | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | ||||||
|  | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= | ||||||
|  | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= | ||||||
|  | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | ||||||
|  | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= | ||||||
|  | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= | ||||||
|  | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= | ||||||
|  | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||||
|  | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||||
|  | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||||
|  | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||||||
|  | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||||||
|  | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= | ||||||
|  | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= | ||||||
|  | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= | ||||||
|  | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||||||
|  | golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= | ||||||
|  | golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= | ||||||
|  | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
|  | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
|  | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
|  | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= | ||||||
|  | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||||
|  | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||||
|  | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||||
|  | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
|  | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
|  | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
|  | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
|  | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= | ||||||
|  | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||||
|  | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||||
|  | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||||
|  | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= | ||||||
|  | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= | ||||||
|  | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= | ||||||
|  | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= | ||||||
|  | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||||
|  | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||||
|  | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= | ||||||
|  | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= | ||||||
|  | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= | ||||||
|  | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||||
|  | google.golang.org/api v0.186.0 h1:n2OPp+PPXX0Axh4GuSsL5QL8xQCTb2oDwyzPnQvqUug= | ||||||
|  | google.golang.org/api v0.186.0/go.mod h1:hvRbBmgoje49RV3xqVXrmP6w93n6ehGgIVPYrGtBFFc= | ||||||
|  | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | ||||||
|  | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||||
|  | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= | ||||||
|  | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= | ||||||
|  | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= | ||||||
|  | google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 h1:MuYw1wJzT+ZkybKfaOXKp5hJiZDn2iHaXRw0mRYdHSc= | ||||||
|  | google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4/go.mod h1:px9SlOOZBg1wM1zdnr8jEL4CNGUBZ+ZKYtNPApNQc4c= | ||||||
|  | google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0= | ||||||
|  | google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= | ||||||
|  | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= | ||||||
|  | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= | ||||||
|  | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= | ||||||
|  | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= | ||||||
|  | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= | ||||||
|  | google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= | ||||||
|  | google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= | ||||||
|  | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= | ||||||
|  | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= | ||||||
|  | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= | ||||||
|  | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= | ||||||
|  | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= | ||||||
|  | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= | ||||||
|  | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= | ||||||
|  | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= | ||||||
|  | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= | ||||||
|  | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= | ||||||
|  | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= | ||||||
|  | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||||
|  | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||||
|  | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||||
|  | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||||
|  | gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= | ||||||
|  | gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= | ||||||
|  | gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= | ||||||
|  | gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= | ||||||
|  | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= | ||||||
|  | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= | ||||||
							
								
								
									
										161
									
								
								backend/handler/chat.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								backend/handler/chat.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,161 @@ | |||||||
|  | package handler | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/gofiber/fiber/v2" | ||||||
|  | 	"github.com/gofiber/fiber/v2/utils" | ||||||
|  | 	"gorm.io/gorm" | ||||||
|  | 	"rul.sh/furina-id/database" | ||||||
|  | 	"rul.sh/furina-id/models" | ||||||
|  | 	"rul.sh/furina-id/services" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var initialChat = models.Chat{ | ||||||
|  | 	Role:    "model", | ||||||
|  | 	Content: "Ah, finally someone worthy of my time! What shall we do today?", | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func getAll(c *fiber.Ctx) error { | ||||||
|  | 	sessionId := getSessionId(c) | ||||||
|  | 	if sessionId == 0 { | ||||||
|  | 		return c.JSON([]models.Chat{initialChat}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	fmt.Println(sessionId) | ||||||
|  | 
 | ||||||
|  | 	db := database.Get() | ||||||
|  | 	var chats []models.Chat | ||||||
|  | 
 | ||||||
|  | 	if result := db.Where("session_id = ?", sessionId).Order("id DESC").Limit(10).Find(&chats); result.Error != nil { | ||||||
|  | 		return c.Status(500).JSON(fiber.Map{"message": result.Error.Error()}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	result := []fiber.Map{} | ||||||
|  | 	for _, chat := range chats { | ||||||
|  | 		result = append(result, fiber.Map{"id": chat.ID, "role": chat.Role, "content": chat.Content}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	result = append(result, fiber.Map{ | ||||||
|  | 		"id":      initialChat.ID, | ||||||
|  | 		"role":    initialChat.Role, | ||||||
|  | 		"content": initialChat.Content, | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
|  | 	return c.JSON(result) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type CreateChatPayload struct { | ||||||
|  | 	Message string `json:"message"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func create(c *fiber.Ctx) error { | ||||||
|  | 	sessionId := getSessionId(c) | ||||||
|  | 	db := database.Get() | ||||||
|  | 
 | ||||||
|  | 	if sessionId == 0 { | ||||||
|  | 		var err error | ||||||
|  | 		sessionId, err = createSession(c) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return c.Status(500).JSON(fiber.Map{"message": err.Error()}) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if count := getTotalChatsLastHour(); count > 60 { | ||||||
|  | 		return c.Status(500).JSON(fiber.Map{"message": "Too many chats. Please try again later."}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	data := CreateChatPayload{} | ||||||
|  | 	if err := c.BodyParser(&data); err != nil { | ||||||
|  | 		return c.Status(500).JSON(fiber.Map{"message": err.Error()}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	msg := models.Chat{ | ||||||
|  | 		SessionID: sessionId, | ||||||
|  | 		Role:      "user", | ||||||
|  | 		Content:   data.Message, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	history := []models.Chat{} | ||||||
|  | 	db.Where("session_id = ?", sessionId).Order("id DESC").Limit(10).Find(&history) | ||||||
|  | 	history = append([]models.Chat{msg}, history...) | ||||||
|  | 
 | ||||||
|  | 	err := db.Transaction(func(tx *gorm.DB) error { | ||||||
|  | 		resp, err := services.GenerateAiChat(&history, msg.Content) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 		if r := tx.Create(&msg); r.Error != nil { | ||||||
|  | 			return r.Error | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		resp.SessionID = sessionId | ||||||
|  | 		if r := tx.Create(&resp); r.Error != nil { | ||||||
|  | 			return r.Error | ||||||
|  | 		} | ||||||
|  | 		return nil | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
|  | 	if err != nil { | ||||||
|  | 		return c.Status(500).JSON(fiber.Map{"message": err.Error()}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return c.JSON(msg) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func HandleChat(router fiber.Router) { | ||||||
|  | 	router.Get("/chats", getAll) | ||||||
|  | 	router.Post("/chats", create) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func getSessionId(c *fiber.Ctx) uint { | ||||||
|  | 	uuid := c.Cookies("session_id") | ||||||
|  | 
 | ||||||
|  | 	if uuid == "" { | ||||||
|  | 		return 0 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	db := database.Get() | ||||||
|  | 	var session models.Session | ||||||
|  | 	db.Where("uuid = ?", uuid).First(&session) | ||||||
|  | 
 | ||||||
|  | 	return session.ID | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func createSession(c *fiber.Ctx) (uint, error) { | ||||||
|  | 	uuid := utils.UUIDv4() | ||||||
|  | 
 | ||||||
|  | 	db := database.Get() | ||||||
|  | 	session := models.Session{ | ||||||
|  | 		UUID: uuid, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if result := db.Create(&session); result.Error != nil { | ||||||
|  | 		return 0, result.Error | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	c.Cookie(&fiber.Cookie{ | ||||||
|  | 		Name:     "session_id", | ||||||
|  | 		Value:    uuid, | ||||||
|  | 		Expires:  time.Now().Add(24 * time.Hour), | ||||||
|  | 		HTTPOnly: true, | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
|  | 	return session.ID, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func getTotalChatsLastHour() int64 { | ||||||
|  | 	var count int64 | ||||||
|  | 	oneHourAgo := time.Now().Add(-1 * time.Hour) | ||||||
|  | 
 | ||||||
|  | 	r := database.Get().Model(&models.Chat{}). | ||||||
|  | 		Where("created_at >= ?", oneHourAgo). | ||||||
|  | 		Count(&count) | ||||||
|  | 
 | ||||||
|  | 	if r.Error != nil { | ||||||
|  | 		return 0 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return count | ||||||
|  | } | ||||||
							
								
								
									
										35
									
								
								backend/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								backend/main.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | |||||||
|  | package main | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"log" | ||||||
|  | 	"os" | ||||||
|  | 
 | ||||||
|  | 	"github.com/gofiber/fiber/v2" | ||||||
|  | 	"github.com/joho/godotenv" | ||||||
|  | 	"rul.sh/furina-id/database" | ||||||
|  | 	"rul.sh/furina-id/handler" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func main() { | ||||||
|  | 	godotenv.Load() | ||||||
|  | 	database.Init() | ||||||
|  | 
 | ||||||
|  | 	app := fiber.New() | ||||||
|  | 
 | ||||||
|  | 	app.Get("/", func(c *fiber.Ctx) error { | ||||||
|  | 		return c.SendString("OK") | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
|  | 	// API handler | ||||||
|  | 	api := app.Group("/api") | ||||||
|  | 	handler.HandleChat(api) | ||||||
|  | 
 | ||||||
|  | 	port := os.Getenv("PORT") | ||||||
|  | 	if port == "" { | ||||||
|  | 		port = "8100" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	fmt.Printf("Listening on http://localhost:%s\n", port) | ||||||
|  | 	log.Fatal(app.Listen(fmt.Sprintf(":%s", port))) | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								backend/models/base.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/models/base.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | |||||||
|  | package models | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type Base struct { | ||||||
|  | 	ID        uint      `gorm:"primarykey" json:"id"` | ||||||
|  | 	CreatedAt time.Time `json:"created_at"` | ||||||
|  | 	UpdatedAt time.Time `json:"updated_at"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // type SoftDelete struct { | ||||||
|  | // 	DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` | ||||||
|  | // } | ||||||
							
								
								
									
										10
									
								
								backend/models/chat.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								backend/models/chat.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | |||||||
|  | package models | ||||||
|  | 
 | ||||||
|  | type Chat struct { | ||||||
|  | 	Base | ||||||
|  | 
 | ||||||
|  | 	SessionID uint    `json:"session_id"` | ||||||
|  | 	Session   Session `json:"session"` | ||||||
|  | 	Role      string  `json:"role"` | ||||||
|  | 	Content   string  `json:"content"` | ||||||
|  | } | ||||||
							
								
								
									
										7
									
								
								backend/models/session.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								backend/models/session.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | |||||||
|  | package models | ||||||
|  | 
 | ||||||
|  | type Session struct { | ||||||
|  | 	Base | ||||||
|  | 
 | ||||||
|  | 	UUID string `gorm:"uniqueIndex" json:"uuid"` | ||||||
|  | } | ||||||
							
								
								
									
										71
									
								
								backend/services/chatbot.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								backend/services/chatbot.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,71 @@ | |||||||
|  | package services | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"fmt" | ||||||
|  | 	"os" | ||||||
|  | 
 | ||||||
|  | 	"github.com/google/generative-ai-go/genai" | ||||||
|  | 	"google.golang.org/api/option" | ||||||
|  | 	"rul.sh/furina-id/models" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func GenerateAiChat(history *[]models.Chat, message string) (*models.Chat, error) { | ||||||
|  | 	apiKey := os.Getenv("GEMINI_API_KEY") | ||||||
|  | 	if apiKey == "" { | ||||||
|  | 		return nil, fmt.Errorf("GEMINI_API_KEY is not set") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	defer client.Close() | ||||||
|  | 
 | ||||||
|  | 	prompt := "You're a cute anime girl, your name is Furina de Fontaine. Furina is flamboyant and overconfident Hydro Archon (God of Hydro). Speaking in a manner peppered with bravado and drama. She is impatient and has a childlike temper, and she will occasionally make judgments that she doesn't mean. While she enjoys being in the spotlight, she only does so when it is focused at her positively, breaking down in complete shambles should something go out of plan and will try to save face at the first possible opportunity. Her favorite food is Macaroni & Pasta. She know little about cooking, but she's trying to be good at that.\n\nExample of her voice lines:\n- Boring... Isn't there anything else more interesting to do?\n- *sigh* Being too popular can be such a hassle. Who knew the people would adore me so much?\n- What a wild and desolate sight... Allow me to grant you the blessing of water!\n- *sigh* Given that we know each other, you may relax a little and needn't act so respectfully in my presence. Wait, what's that expression on your face? Don't tell me that you've never respected me from the very beginning!?\n\nAnswer with only short length message, max 120 characters." | ||||||
|  | 
 | ||||||
|  | 	model := client.GenerativeModel("gemini-1.5-flash") | ||||||
|  | 	model.SystemInstruction = genai.NewUserContent(genai.Text(prompt)) | ||||||
|  | 	cs := model.StartChat() | ||||||
|  | 
 | ||||||
|  | 	cs.History = []*genai.Content{} | ||||||
|  | 
 | ||||||
|  | 	for _, msg := range *history { | ||||||
|  | 		content := genai.Content{ | ||||||
|  | 			Role:  msg.Role, | ||||||
|  | 			Parts: []genai.Part{genai.Text(msg.Content)}, | ||||||
|  | 		} | ||||||
|  | 		cs.History = append([]*genai.Content{&content}, cs.History...) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res, err := cs.SendMessage(ctx, genai.Text(message)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	respContent := getResponseContent(res) | ||||||
|  | 	if respContent == "" { | ||||||
|  | 		return nil, fmt.Errorf("no content") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return &models.Chat{ | ||||||
|  | 		Role:    "model", | ||||||
|  | 		Content: respContent, | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func getResponseContent(resp *genai.GenerateContentResponse) string { | ||||||
|  | 	content := "" | ||||||
|  | 	for _, cand := range resp.Candidates { | ||||||
|  | 		if cand.Content != nil { | ||||||
|  | 			for _, part := range cand.Content.Parts { | ||||||
|  | 				textPart, ok := part.(genai.Text) | ||||||
|  | 				if ok { | ||||||
|  | 					content += string(textPart) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return content | ||||||
|  | } | ||||||
| @ -10,10 +10,13 @@ | |||||||
|     "preview": "vite preview" |     "preview": "vite preview" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|  |     "@tanstack/react-query": "^5.52.2", | ||||||
|     "clsx": "^2.1.1", |     "clsx": "^2.1.1", | ||||||
|     "lucide-react": "^0.436.0", |     "lucide-react": "^0.436.0", | ||||||
|     "react": "^18.3.1", |     "react": "^18.3.1", | ||||||
|     "react-dom": "^18.3.1", |     "react-dom": "^18.3.1", | ||||||
|  |     "react-loader-spinner": "^6.1.6", | ||||||
|  |     "react-markdown": "^9.0.1", | ||||||
|     "tailwind-merge": "^2.5.2" |     "tailwind-merge": "^2.5.2" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|  | |||||||
							
								
								
									
										793
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										793
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										20
									
								
								src/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/api.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | //
 | ||||||
|  | 
 | ||||||
|  | export const BASE_URL = "/api"; | ||||||
|  | 
 | ||||||
|  | export const api = async <T = any>(path: string, init: RequestInit = {}) => { | ||||||
|  |   const res = await fetch(BASE_URL + path, { ...init, credentials: "include" }); | ||||||
|  | 
 | ||||||
|  |   if (!res.ok) { | ||||||
|  |     const data = await res.json().catch(() => null); | ||||||
|  |     const message = data?.message || res.statusText; | ||||||
|  |     throw new Error(message); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   try { | ||||||
|  |     const data = (await res.json()) as T; | ||||||
|  |     return data; | ||||||
|  |   } catch (err) { | ||||||
|  |     throw new Error("Invalid response"); | ||||||
|  |   } | ||||||
|  | }; | ||||||
							
								
								
									
										228
									
								
								src/app.tsx
									
									
									
									
									
								
							
							
						
						
									
										228
									
								
								src/app.tsx
									
									
									
									
									
								
							| @ -1,43 +1,9 @@ | |||||||
| import { ComponentPropsWithoutRef, useEffect, useRef, useState } from "react"; |  | ||||||
| import { MessageSquareQuote, SendIcon, X } from "lucide-react"; |  | ||||||
| import furinaImg from "@/assets/furina.webp"; | import furinaImg from "@/assets/furina.webp"; | ||||||
| import hydroElement from "@/assets/hydro.svg"; | import hydroElement from "@/assets/hydro.svg"; | ||||||
| import dotPattern from "@/assets/dotpattern.svg"; | import dotPattern from "@/assets/dotpattern.svg"; | ||||||
| import logo from "@/assets/logo.svg"; | import logo from "@/assets/logo.svg"; | ||||||
| import furinaAvatar from "@/assets/furina-avatar.webp"; | import ParallaxView from "./components/parallax-view"; | ||||||
| import { cn } from "./utils"; | import ChatWindow from "./components/chat-window"; | ||||||
| import { useIsMobile } from "./hooks/useScreen"; |  | ||||||
| 
 |  | ||||||
| const ParallaxView = ({ |  | ||||||
|   depth, |  | ||||||
|   style, |  | ||||||
|   ...props |  | ||||||
| }: ComponentPropsWithoutRef<"div"> & { depth: number }) => { |  | ||||||
|   const ref = useRef<HTMLDivElement>(null); |  | ||||||
|   const isMobile = useIsMobile(); |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     const el = ref.current; |  | ||||||
|     if (!el || isMobile) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const onMouseMove = (e: MouseEvent) => { |  | ||||||
|       const x = e.clientX * -depth; |  | ||||||
|       const y = e.clientY * -depth; |  | ||||||
|       el.style.transform = `${ |  | ||||||
|         style?.transform || "" |  | ||||||
|       } translateX(${x}px) translateY(${y}px)`;
 |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     document.addEventListener("mousemove", onMouseMove); |  | ||||||
|     return () => { |  | ||||||
|       document.removeEventListener("mousemove", onMouseMove); |  | ||||||
|     }; |  | ||||||
|   }, [isMobile]); |  | ||||||
| 
 |  | ||||||
|   return <div {...props} style={style} ref={ref} />; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| const App = () => { | const App = () => { | ||||||
|   return ( |   return ( | ||||||
| @ -102,194 +68,4 @@ const App = () => { | |||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| type ChatMessage = { |  | ||||||
|   name: string; |  | ||||||
|   role: "assistant" | "user"; |  | ||||||
|   text: string; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const ChatWindow = () => { |  | ||||||
|   const containerRef = useRef<HTMLDivElement>(null); |  | ||||||
|   const moveWindowStateRef = useRef({ |  | ||||||
|     isMoving: false, |  | ||||||
|     x: 0, |  | ||||||
|     y: 0, |  | ||||||
|     startX: 0, |  | ||||||
|     startY: 0, |  | ||||||
|   }); |  | ||||||
|   const isMobile = useIsMobile(); |  | ||||||
|   const [isOpen, setOpen] = useState(false); |  | ||||||
|   const [messages, setMessages] = useState<ChatMessage[]>([ |  | ||||||
|     { |  | ||||||
|       name: "Furina", |  | ||||||
|       role: "assistant", |  | ||||||
|       text: "Ah, akhirnya seseorang yang mengerti betapa pentingnya kehadiranku! Tapi, hmm... Kau ingin apa dariku? Jangan bilang ini hanya sapaan biasa!", |  | ||||||
|     }, |  | ||||||
|   ]); |  | ||||||
| 
 |  | ||||||
|   const onMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { |  | ||||||
|     if (isMobile) { |  | ||||||
|       if (isOpen) { |  | ||||||
|         setOpen(false); |  | ||||||
|       } |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (moveWindowStateRef.current.isMoving) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|     e.preventDefault(); |  | ||||||
|     e.stopPropagation(); |  | ||||||
| 
 |  | ||||||
|     moveWindowStateRef.current.isMoving = true; |  | ||||||
|     moveWindowStateRef.current.startX = |  | ||||||
|       e.clientX - moveWindowStateRef.current.x; |  | ||||||
|     moveWindowStateRef.current.startY = |  | ||||||
|       e.clientY - moveWindowStateRef.current.y; |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const onMouseMove = (e: MouseEvent) => { |  | ||||||
|     const container = containerRef.current; |  | ||||||
|     if (isMobile || !moveWindowStateRef.current.isMoving || !container) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const x = e.clientX - moveWindowStateRef.current.startX; |  | ||||||
|     const y = e.clientY - moveWindowStateRef.current.startY; |  | ||||||
|     moveWindowStateRef.current.x = x; |  | ||||||
|     moveWindowStateRef.current.y = y; |  | ||||||
|     container.style.transform = `translate(${x}px, ${y}px)`; |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const onMouseUp = () => { |  | ||||||
|     moveWindowStateRef.current.isMoving = false; |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { |  | ||||||
|     e.preventDefault(); |  | ||||||
|     const form = e.target as HTMLFormElement; |  | ||||||
|     const data = new FormData(form); |  | ||||||
| 
 |  | ||||||
|     try { |  | ||||||
|       setMessages([ |  | ||||||
|         { |  | ||||||
|           name: "Furina", |  | ||||||
|           role: "assistant", |  | ||||||
|           text: "...?", |  | ||||||
|         }, |  | ||||||
|         { |  | ||||||
|           name: "Me", |  | ||||||
|           role: "user", |  | ||||||
|           text: data.get("text") as string, |  | ||||||
|         }, |  | ||||||
|         ...messages, |  | ||||||
|       ]); |  | ||||||
| 
 |  | ||||||
|       form.reset(); |  | ||||||
|     } catch (err) { |  | ||||||
|       console.log(err); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     document.addEventListener("mousemove", onMouseMove); |  | ||||||
|     document.addEventListener("mouseup", onMouseUp); |  | ||||||
|     document.addEventListener("pointermove", onMouseMove); |  | ||||||
|     document.addEventListener("pointerup", onMouseUp); |  | ||||||
| 
 |  | ||||||
|     return () => { |  | ||||||
|       document.removeEventListener("mousemove", onMouseMove); |  | ||||||
|       document.removeEventListener("mouseup", onMouseUp); |  | ||||||
|       document.removeEventListener("pointermove", onMouseMove); |  | ||||||
|       document.removeEventListener("pointerup", onMouseUp); |  | ||||||
|     }; |  | ||||||
|   }, []); |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <> |  | ||||||
|       <button |  | ||||||
|         className={cn( |  | ||||||
|           "flex md:hidden absolute bottom-4 right-4 h-14 px-4 rounded-xl gap-x-2 bg-white text-slate-600 shadow-lg active:opacity-80 flex-row items-center justify-center", |  | ||||||
|           isOpen && "hidden" |  | ||||||
|         )} |  | ||||||
|         onClick={() => setOpen(!isOpen)} |  | ||||||
|       > |  | ||||||
|         <span>Chat</span> |  | ||||||
|         <MessageSquareQuote /> |  | ||||||
|       </button> |  | ||||||
| 
 |  | ||||||
|       <div |  | ||||||
|         ref={containerRef} |  | ||||||
|         className={cn( |  | ||||||
|           "bg-white/20 border border-white/20 shadow-lg rounded-lg backdrop-blur-md absolute bottom-[10px] sm:bottom-1/4 left-[10px] sm:left-[10%] w-[calc(100%-20px)] sm:max-w-[320px] h-[80vh] sm:h-[300px] flex flex-col items-stretch overflow-hidden transition-transform sm:transition-none translate-y-[110%] sm:translate-y-0", |  | ||||||
|           isOpen && "translate-y-0" |  | ||||||
|         )} |  | ||||||
|       > |  | ||||||
|         <div |  | ||||||
|           className="flex flex-row items-center gap-2 px-3 h-8 cursor-move" |  | ||||||
|           onMouseDown={onMouseDown} |  | ||||||
|           onPointerDown={onMouseDown} |  | ||||||
|         > |  | ||||||
|           <div className="size-3 rounded-full bg-red-500" /> |  | ||||||
|           <div className="size-3 rounded-full bg-yellow-500" /> |  | ||||||
|           <div className="size-3 rounded-full bg-green-500" /> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         <div className="flex-1 overflow-y-auto flex flex-col-reverse gap-y-2 p-2"> |  | ||||||
|           {messages.map((msg, idx) => { |  | ||||||
|             const isMe = msg.role === "user"; |  | ||||||
| 
 |  | ||||||
|             return ( |  | ||||||
|               <div |  | ||||||
|                 key={idx} |  | ||||||
|                 className={cn( |  | ||||||
|                   "flex items-start gap-2 w-full max-w-[90%]", |  | ||||||
|                   isMe && "justify-end self-end pr-1" |  | ||||||
|                 )} |  | ||||||
|               > |  | ||||||
|                 {msg.role === "assistant" && ( |  | ||||||
|                   <div className="size-8 rounded-full bg-white shrink-0 overflow-hidden"> |  | ||||||
|                     <img |  | ||||||
|                       src={furinaAvatar} |  | ||||||
|                       className="w-full h-full object-cover" |  | ||||||
|                     /> |  | ||||||
|                   </div> |  | ||||||
|                 )} |  | ||||||
| 
 |  | ||||||
|                 <div className={cn("flex flex-col", isMe && "items-end")}> |  | ||||||
|                   <p className="font-medium -mt-1 text-sm text-white"> |  | ||||||
|                     {msg.name} |  | ||||||
|                   </p> |  | ||||||
|                   <div |  | ||||||
|                     className={cn( |  | ||||||
|                       "bg-white text-slate-800 rounded-xl px-2 py-1 mt-0.5 text-sm", |  | ||||||
|                       isMe ? "rounded-tr-none" : "rounded-tl-none" |  | ||||||
|                     )} |  | ||||||
|                   > |  | ||||||
|                     <p>{msg.text}</p> |  | ||||||
|                   </div> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             ); |  | ||||||
|           })} |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         <form onSubmit={onSubmit}> |  | ||||||
|           <div className="p-2 flex flex-row items-center pt-1"> |  | ||||||
|             <input |  | ||||||
|               name="text" |  | ||||||
|               className="w-full border-none rounded-full text-sm px-3 h-8 focus:outline-none" |  | ||||||
|               placeholder="Write Message..." |  | ||||||
|               required |  | ||||||
|             /> |  | ||||||
|             <button className="text-white size-8 shrink-0 hover:bg-white/40 rounded-full flex items-center justify-center -mr-1"> |  | ||||||
|               <SendIcon size={18} /> |  | ||||||
|             </button> |  | ||||||
|           </div> |  | ||||||
|         </form> |  | ||||||
|       </div> |  | ||||||
|     </> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default App; | export default App; | ||||||
|  | |||||||
							
								
								
									
										263
									
								
								src/components/chat-window.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										263
									
								
								src/components/chat-window.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,263 @@ | |||||||
|  | import { useIsMobile } from "@/hooks/useScreen"; | ||||||
|  | import { cn } from "@/utils"; | ||||||
|  | import { MessageSquareQuote, SendIcon } from "lucide-react"; | ||||||
|  | import React, { useEffect, useRef, useState } from "react"; | ||||||
|  | import furinaAvatar from "@/assets/furina-avatar.webp"; | ||||||
|  | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||||||
|  | import { api } from "@/api"; | ||||||
|  | import { ThreeDots } from "react-loader-spinner"; | ||||||
|  | import Markdown from "react-markdown"; | ||||||
|  | 
 | ||||||
|  | const ChatWindow = () => { | ||||||
|  |   const containerRef = useRef<HTMLDivElement>(null); | ||||||
|  |   const moveWindowStateRef = useRef({ | ||||||
|  |     isMoving: false, | ||||||
|  |     x: 0, | ||||||
|  |     y: 0, | ||||||
|  |     startX: 0, | ||||||
|  |     startY: 0, | ||||||
|  |   }); | ||||||
|  |   const isMobile = useIsMobile(); | ||||||
|  |   const queryClient = useQueryClient(); | ||||||
|  |   const [isOpen, setOpen] = useState(false); | ||||||
|  | 
 | ||||||
|  |   const { data: messages } = useQuery({ | ||||||
|  |     queryKey: ["chats"], | ||||||
|  |     queryFn: () => api("/chats"), | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   const sendMessage = useMutation({ | ||||||
|  |     mutationFn: async (message: string) => { | ||||||
|  |       return api("/chats", { | ||||||
|  |         method: "POST", | ||||||
|  |         body: JSON.stringify({ message }), | ||||||
|  |         headers: { "Content-Type": "application/json" }, | ||||||
|  |       }); | ||||||
|  |     }, | ||||||
|  |     onMutate: async (data) => { | ||||||
|  |       await queryClient.cancelQueries({ queryKey: ["chats"] }); | ||||||
|  |       const prevData = queryClient.getQueryData(["chats"]); | ||||||
|  | 
 | ||||||
|  |       // optimistic update
 | ||||||
|  |       queryClient.setQueryData(["chats"], (prev: any) => [ | ||||||
|  |         { id: "-1", role: "user", content: data }, | ||||||
|  |         ...prev, | ||||||
|  |       ]); | ||||||
|  |       return { prevData }; | ||||||
|  |     }, | ||||||
|  |     onError: (_err, _data, ctx) => { | ||||||
|  |       queryClient.setQueryData(["chats"], ctx?.prevData); | ||||||
|  |     }, | ||||||
|  |     onSettled: () => { | ||||||
|  |       queryClient.invalidateQueries({ queryKey: ["chats"] }); | ||||||
|  |       const msgEl = document.querySelector('[name="message"]') as | ||||||
|  |         | HTMLInputElement | ||||||
|  |         | undefined; | ||||||
|  |       setTimeout(() => msgEl?.focus(), 100); | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   const onMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { | ||||||
|  |     if (isMobile) { | ||||||
|  |       if (isOpen) { | ||||||
|  |         setOpen(false); | ||||||
|  |       } | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (moveWindowStateRef.current.isMoving) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     e.preventDefault(); | ||||||
|  |     e.stopPropagation(); | ||||||
|  | 
 | ||||||
|  |     moveWindowStateRef.current.isMoving = true; | ||||||
|  |     moveWindowStateRef.current.startX = | ||||||
|  |       e.clientX - moveWindowStateRef.current.x; | ||||||
|  |     moveWindowStateRef.current.startY = | ||||||
|  |       e.clientY - moveWindowStateRef.current.y; | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const onMouseMove = (e: MouseEvent) => { | ||||||
|  |     const container = containerRef.current; | ||||||
|  |     if (isMobile || !moveWindowStateRef.current.isMoving || !container) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const x = e.clientX - moveWindowStateRef.current.startX; | ||||||
|  |     const y = e.clientY - moveWindowStateRef.current.startY; | ||||||
|  |     moveWindowStateRef.current.x = x; | ||||||
|  |     moveWindowStateRef.current.y = y; | ||||||
|  |     container.style.transform = `translate(${x}px, ${y}px)`; | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const onMouseUp = () => { | ||||||
|  |     moveWindowStateRef.current.isMoving = false; | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     if (sendMessage.isPending) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const form = e.target as HTMLFormElement; | ||||||
|  |     const data = new FormData(form); | ||||||
|  | 
 | ||||||
|  |     const message = data.get("message") as string; | ||||||
|  |     if (!message?.length) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     sendMessage.mutate(message, { | ||||||
|  |       onSuccess: () => form.reset(), | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     document.addEventListener("mousemove", onMouseMove); | ||||||
|  |     document.addEventListener("mouseup", onMouseUp); | ||||||
|  |     document.addEventListener("pointermove", onMouseMove); | ||||||
|  |     document.addEventListener("pointerup", onMouseUp); | ||||||
|  | 
 | ||||||
|  |     return () => { | ||||||
|  |       document.removeEventListener("mousemove", onMouseMove); | ||||||
|  |       document.removeEventListener("mouseup", onMouseUp); | ||||||
|  |       document.removeEventListener("pointermove", onMouseMove); | ||||||
|  |       document.removeEventListener("pointerup", onMouseUp); | ||||||
|  |     }; | ||||||
|  |   }, []); | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <button | ||||||
|  |         className={cn( | ||||||
|  |           "flex md:hidden absolute bottom-4 right-4 h-14 px-4 rounded-xl gap-x-2 bg-white text-slate-600 shadow-lg active:opacity-80 flex-row items-center justify-center", | ||||||
|  |           isOpen && "hidden" | ||||||
|  |         )} | ||||||
|  |         onClick={() => setOpen(!isOpen)} | ||||||
|  |       > | ||||||
|  |         <span>Chat</span> | ||||||
|  |         <MessageSquareQuote /> | ||||||
|  |       </button> | ||||||
|  | 
 | ||||||
|  |       <div | ||||||
|  |         ref={containerRef} | ||||||
|  |         className={cn( | ||||||
|  |           "bg-white/20 border border-white/20 shadow-lg rounded-lg backdrop-blur-md absolute bottom-[10px] sm:bottom-1/4 left-[10px] sm:left-[10%] w-[calc(100%-20px)] sm:max-w-[320px] h-[80vh] sm:h-[300px] lg:max-w-[400px] lg:h-[350px] flex flex-col items-stretch overflow-hidden transition-transform sm:transition-none translate-y-[110%] sm:translate-y-0", | ||||||
|  |           isOpen && "translate-y-0" | ||||||
|  |         )} | ||||||
|  |       > | ||||||
|  |         <div | ||||||
|  |           className="flex flex-row items-center gap-2 px-3 h-8 cursor-move" | ||||||
|  |           onMouseDown={onMouseDown} | ||||||
|  |           onPointerDown={onMouseDown} | ||||||
|  |         > | ||||||
|  |           <div className="size-3 rounded-full bg-red-500" /> | ||||||
|  |           <div className="size-3 rounded-full bg-yellow-500" /> | ||||||
|  |           <div className="size-3 rounded-full bg-green-500" /> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <div className="flex-1 overflow-y-auto flex flex-col-reverse gap-y-2 p-2"> | ||||||
|  |           {sendMessage.isPending && ( | ||||||
|  |             <Message | ||||||
|  |               name="Furina" | ||||||
|  |               role="model" | ||||||
|  |               children={ | ||||||
|  |                 <ThreeDots | ||||||
|  |                   visible={true} | ||||||
|  |                   height="16" | ||||||
|  |                   width="32" | ||||||
|  |                   color="#5381c7" | ||||||
|  |                   ariaLabel="writing.." | ||||||
|  |                 /> | ||||||
|  |               } | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|  | 
 | ||||||
|  |           {sendMessage.isError && ( | ||||||
|  |             <p className="text-xs text-center self-center text-black my-4 bg-white/10 backdrop-blur-md px-2 py-1 rounded-lg"> | ||||||
|  |               {getSendChatErrorMessage(sendMessage.error)} | ||||||
|  |             </p> | ||||||
|  |           )} | ||||||
|  | 
 | ||||||
|  |           {messages?.map((msg: any) => { | ||||||
|  |             return ( | ||||||
|  |               <Message | ||||||
|  |                 key={msg.id} | ||||||
|  |                 isMe={msg.role === "user"} | ||||||
|  |                 name={msg.role === "user" ? "Me" : "Furina"} | ||||||
|  |                 role={msg.role} | ||||||
|  |                 children={<Markdown>{msg.content}</Markdown>} | ||||||
|  |               /> | ||||||
|  |             ); | ||||||
|  |           })} | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <form onSubmit={onSubmit}> | ||||||
|  |           <div className="p-2 flex flex-row items-center pt-1"> | ||||||
|  |             <input | ||||||
|  |               name="message" | ||||||
|  |               className="w-full border-none rounded-full text-sm px-3 h-8 focus:outline-none" | ||||||
|  |               placeholder="Write Message..." | ||||||
|  |               required | ||||||
|  |               disabled={sendMessage.isPending} | ||||||
|  |             /> | ||||||
|  |             <button | ||||||
|  |               className="text-white size-8 shrink-0 hover:bg-white/40 rounded-full flex items-center justify-center -mr-1" | ||||||
|  |               disabled={sendMessage.isPending} | ||||||
|  |             > | ||||||
|  |               <SendIcon size={18} /> | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |         </form> | ||||||
|  |       </div> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | type MessageProps = { | ||||||
|  |   isMe?: boolean; | ||||||
|  |   role?: string; | ||||||
|  |   name: string; | ||||||
|  |   children?: React.ReactNode; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const Message = ({ isMe, role, name, children }: MessageProps) => { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       className={cn( | ||||||
|  |         "flex items-start gap-2 w-full max-w-[90%]", | ||||||
|  |         isMe && "justify-end self-end pr-1" | ||||||
|  |       )} | ||||||
|  |     > | ||||||
|  |       {role === "model" && ( | ||||||
|  |         <div className="size-8 rounded-full bg-white shrink-0 overflow-hidden"> | ||||||
|  |           <img src={furinaAvatar} className="w-full h-full object-cover" /> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  | 
 | ||||||
|  |       <div className={cn("flex flex-col", isMe && "items-end")}> | ||||||
|  |         <p className="font-medium -mt-1 text-sm text-white">{name}</p> | ||||||
|  |         <div | ||||||
|  |           className={cn( | ||||||
|  |             "bg-white/40 backdrop-blur-md text-slate-900 rounded-xl px-2 py-1 mt-0.5 text-sm", | ||||||
|  |             isMe ? "bg-white/80 rounded-tr-none" : "rounded-tl-none" | ||||||
|  |           )} | ||||||
|  |         > | ||||||
|  |           {children} | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const getSendChatErrorMessage = (error: Error) => { | ||||||
|  |   if (error?.message?.includes("FinishReasonSafety")) { | ||||||
|  |     return "Your message probably detected with blocked words, please try again."; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return "An error occured. Please try again."; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default ChatWindow; | ||||||
							
								
								
									
										35
									
								
								src/components/parallax-view.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/components/parallax-view.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | |||||||
|  | import { useIsMobile } from "@/hooks/useScreen"; | ||||||
|  | import { ComponentPropsWithoutRef, useEffect, useRef } from "react"; | ||||||
|  | 
 | ||||||
|  | const ParallaxView = ({ | ||||||
|  |   depth, | ||||||
|  |   style, | ||||||
|  |   ...props | ||||||
|  | }: ComponentPropsWithoutRef<"div"> & { depth: number }) => { | ||||||
|  |   const ref = useRef<HTMLDivElement>(null); | ||||||
|  |   const isMobile = useIsMobile(); | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     const el = ref.current; | ||||||
|  |     if (!el || isMobile) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const onMouseMove = (e: MouseEvent) => { | ||||||
|  |       const x = e.clientX * -depth; | ||||||
|  |       const y = e.clientY * -depth; | ||||||
|  |       el.style.transform = `${ | ||||||
|  |         style?.transform || "" | ||||||
|  |       } translateX(${x}px) translateY(${y}px)`;
 | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     document.addEventListener("mousemove", onMouseMove); | ||||||
|  |     return () => { | ||||||
|  |       document.removeEventListener("mousemove", onMouseMove); | ||||||
|  |     }; | ||||||
|  |   }, [isMobile]); | ||||||
|  | 
 | ||||||
|  |   return <div {...props} style={style} ref={ref} />; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default ParallaxView; | ||||||
| @ -1,10 +1,13 @@ | |||||||
| import { StrictMode } from "react"; | import { StrictMode } from "react"; | ||||||
| import { createRoot } from "react-dom/client"; | import { createRoot } from "react-dom/client"; | ||||||
| import App from "./app.tsx"; | import App from "./app.tsx"; | ||||||
|  | import Providers from "./providers.tsx"; | ||||||
| import "./main.css"; | import "./main.css"; | ||||||
| 
 | 
 | ||||||
| createRoot(document.getElementById("root")!).render( | createRoot(document.getElementById("root")!).render( | ||||||
|   <StrictMode> |   <StrictMode> | ||||||
|  |     <Providers> | ||||||
|       <App /> |       <App /> | ||||||
|  |     </Providers> | ||||||
|   </StrictMode> |   </StrictMode> | ||||||
| ); | ); | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								src/providers.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/providers.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | import { PropsWithChildren } from "react"; | ||||||
|  | import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; | ||||||
|  | 
 | ||||||
|  | const queryClient = new QueryClient(); | ||||||
|  | 
 | ||||||
|  | const Providers = ({ children }: PropsWithChildren) => { | ||||||
|  |   return ( | ||||||
|  |     <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default Providers; | ||||||
| @ -1,13 +1,20 @@ | |||||||
| import path from 'node:path' | import path from "node:path"; | ||||||
| import { defineConfig } from 'vite' | import { defineConfig } from "vite"; | ||||||
| import react from '@vitejs/plugin-react-swc' | import react from "@vitejs/plugin-react-swc"; | ||||||
| 
 | 
 | ||||||
| // https://vitejs.dev/config/
 | // https://vitejs.dev/config/
 | ||||||
| export default defineConfig({ | export default defineConfig({ | ||||||
|   plugins: [react()], |   plugins: [react()], | ||||||
|   resolve: { |   resolve: { | ||||||
|     alias: { |     alias: { | ||||||
|       '@': path.resolve(__dirname, './src') |       "@": path.resolve(__dirname, "./src"), | ||||||
|     } |     }, | ||||||
|   } |   }, | ||||||
| }) |   server: { | ||||||
|  |     proxy: { | ||||||
|  |       "/api": { | ||||||
|  |         target: "http://localhost:8100", | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user