feat: add chatbot endpoint

This commit is contained in:
Sosokker 2025-04-04 15:24:14 +07:00
parent 8c63c8d047
commit df38e1c9f2
13 changed files with 1366 additions and 661 deletions

View File

@ -9,6 +9,7 @@ require (
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/generative-ai-go v0.19.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/joho/godotenv v1.5.1
@ -17,12 +18,28 @@ require (
github.com/rabbitmq/amqp091-go v1.10.0
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.36.0
google.golang.org/api v0.186.0
)
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/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // 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/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@ -41,11 +58,24 @@ require (
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.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
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.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.35.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -1,5 +1,22 @@
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/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
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/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/danielgtaylor/huma/v2 v2.28.0 h1:W+hIT52MigO73edJNJWXU896uC99xSBWpKoE2PRyybM=
github.com/danielgtaylor/huma/v2 v2.28.0/go.mod h1:67KO0zmYEkR+LVUs8uqrcvf44G1wXiMIu94LV/cH2Ek=
@ -9,6 +26,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@ -17,16 +40,52 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
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/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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/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.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
github.com/google/generative-ai-go v0.19.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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
@ -67,6 +126,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@ -94,27 +154,107 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
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=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
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.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -124,6 +264,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
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=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=

View File

@ -20,6 +20,7 @@ import (
"github.com/forfarm/backend/internal/domain"
m "github.com/forfarm/backend/internal/middlewares"
"github.com/forfarm/backend/internal/repository"
"github.com/forfarm/backend/internal/services"
"github.com/forfarm/backend/internal/services/weather"
"github.com/forfarm/backend/internal/utilities"
)
@ -38,12 +39,12 @@ type api struct {
analyticsRepo domain.AnalyticsRepository
weatherFetcher domain.WeatherFetcher
chatService *services.ChatService
}
var weatherFetcherInstance domain.WeatherFetcher
func GetWeatherFetcher() domain.WeatherFetcher {
return weatherFetcherInstance
func (a *api) GetWeatherFetcher() domain.WeatherFetcher {
return a.weatherFetcher
}
func NewAPI(
@ -60,8 +61,8 @@ func NewAPI(
client := &http.Client{}
userRepository := repository.NewPostgresUser(pool)
plantRepository := repository.NewPostgresPlant(pool)
harvestRepository := repository.NewPostgresHarvest(pool)
plantRepository := repository.NewPostgresPlant(pool)
owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger)
cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL)
@ -74,7 +75,12 @@ func NewAPI(
cleanupInterval = 5 * time.Minute
}
cachedWeatherFetcher := weather.NewCachedWeatherFetcher(owmFetcher, cacheTTL, cleanupInterval, logger)
weatherFetcherInstance = cachedWeatherFetcher
chatService, chatErr := services.NewChatService(logger, analyticsRepo, farmRepo, croplandRepo, inventoryRepo, plantRepository)
if chatErr != nil {
logger.Error("Failed to initialize ChatService", "error", chatErr)
chatService = nil
}
return &api{
logger: logger,
@ -90,6 +96,8 @@ func NewAPI(
analyticsRepo: analyticsRepo,
weatherFetcher: cachedWeatherFetcher,
chatService: chatService,
}
}
@ -131,6 +139,8 @@ func (a *api) Routes() *chi.Mux {
a.registerCropRoutes(r, api)
a.registerPlantRoutes(r, api)
a.registerOauthRoutes(r, api)
a.registerChatRoutes(r, api)
a.registerInventoryRoutes(r, api)
})
router.Group(func(r chi.Router) {
@ -138,7 +148,6 @@ func (a *api) Routes() *chi.Mux {
a.registerHelloRoutes(r, api)
a.registerFarmRoutes(r, api)
a.registerUserRoutes(r, api)
a.registerInventoryRoutes(r, api)
a.registerAnalyticsRoutes(r, api)
})

View File

@ -0,0 +1,142 @@
package api
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/forfarm/backend/internal/services"
"github.com/go-chi/chi/v5"
"github.com/google/generative-ai-go/genai"
)
func (a *api) registerChatRoutes(_ chi.Router, apiInstance huma.API) {
tags := []string{"chat"}
huma.Register(apiInstance, huma.Operation{
OperationID: "chatWithAssistantContextual",
Method: http.MethodPost,
Path: "/chat/specific",
Tags: tags,
Summary: "Send a message to the assistant with farm/crop context",
Description: "Allows users to interact with the AI chatbot, providing farm/crop context.",
}, a.chatHandler)
huma.Register(apiInstance, huma.Operation{
OperationID: "chatWithAssistantGeneral",
Method: http.MethodPost,
Path: "/chat",
Tags: tags,
Summary: "Send a message to the general farming assistant",
Description: "Allows users to interact with the AI chatbot without specific farm/crop context.",
}, a.generalChatHandler)
}
type HistoryItem struct {
Role string `json:"role" example:"user" enum:"user,model" doc:"Role of the sender (user or model)"`
Text string `json:"text" doc:"The content of the message"`
}
type ChatInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
Body struct {
Message string `json:"message" required:"true" doc:"The user's message to the assistant"`
FarmID string `json:"farmId,omitempty" doc:"Optional UUID of the farm context"`
CropID string `json:"cropId,omitempty" doc:"Optional UUID of the crop context"`
History []HistoryItem `json:"history,omitempty" doc:"Previous turns in the conversation"`
}
}
type ChatOutput struct {
Body struct {
Response string `json:"response" doc:"The assistant's response message"`
}
}
type GeneralChatInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
Body struct {
Message string `json:"message" required:"true" doc:"The user's message to the assistant"`
History []HistoryItem `json:"history,omitempty" doc:"Previous turns in the conversation"`
}
}
type GeneralChatOutput = ChatOutput
// convertInputHistory converts the API HistoryItem slice to genai.Content slice.
func convertInputHistory(apiHistory []HistoryItem) []*genai.Content {
if apiHistory == nil {
return nil
}
genaiHistory := make([]*genai.Content, 0, len(apiHistory))
for _, item := range apiHistory {
if (item.Role == "user" || item.Role == "model") && item.Text != "" {
content := &genai.Content{
Role: item.Role,
Parts: []genai.Part{genai.Text(item.Text)},
}
genaiHistory = append(genaiHistory, content)
}
}
return genaiHistory
}
func (a *api) chatHandler(ctx context.Context, input *ChatInput) (*ChatOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header)
if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err)
}
if a.chatService == nil {
a.logger.Error("Chat service is not initialized")
return nil, huma.Error500InternalServerError("Chat service is not available")
}
genaiHistory := convertInputHistory(input.Body.History)
serviceInput := services.GenerateResponseInput{
UserID: userID,
Message: input.Body.Message,
FarmID: input.Body.FarmID,
CropID: input.Body.CropID,
History: genaiHistory,
}
botResponse, err := a.chatService.GenerateResponse(ctx, serviceInput)
if err != nil {
a.logger.Error("Failed to get response from chat service (contextual)", "userId", userID, "farmId", input.Body.FarmID, "cropId", input.Body.CropID, "error", err)
return nil, huma.Error500InternalServerError("Failed to get response from assistant")
}
resp := &ChatOutput{}
resp.Body.Response = botResponse
return resp, nil
}
func (a *api) generalChatHandler(ctx context.Context, input *GeneralChatInput) (*GeneralChatOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header)
if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err)
}
if a.chatService == nil {
a.logger.Error("Chat service is not initialized")
return nil, huma.Error500InternalServerError("Chat service is not available")
}
genaiHistory := convertInputHistory(input.Body.History)
serviceInput := services.GenerateResponseInput{
UserID: userID,
Message: input.Body.Message,
History: genaiHistory,
}
botResponse, err := a.chatService.GenerateResponse(ctx, serviceInput)
if err != nil {
a.logger.Error("Failed to get response from chat service (general)", "userId", userID, "error", err)
return nil, huma.Error500InternalServerError("Failed to get response from assistant")
}
resp := &GeneralChatOutput{}
resp.Body.Response = botResponse
return resp, nil
}

View File

@ -68,18 +68,21 @@ func APICmd(ctx context.Context) *cobra.Command {
}()
logger.Info("Farm Analytics Projection started")
weatherFetcher := api.GetWeatherFetcher() // Get fetcher instance from API setup
apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, inventoryRepo, croplandRepo, farmRepo)
weatherFetcher := apiInstance.GetWeatherFetcher()
weatherInterval, err := time.ParseDuration(config.WEATHER_FETCH_INTERVAL)
if err != nil {
logger.Warn("Invalid WEATHER_FETCH_INTERVAL, using default 15m", "value", config.WEATHER_FETCH_INTERVAL, "error", err)
weatherInterval = 15 * time.Minute
}
weatherUpdater := workers.NewWeatherUpdater(farmRepo, weatherFetcher, eventBus, logger, weatherInterval)
weatherUpdater.Start(ctx) // Pass the main context
weatherUpdater, err := workers.NewWeatherUpdater(farmRepo, weatherFetcher, eventBus, logger, weatherInterval)
if err != nil {
logger.Error("failed to create WeatherUpdater", "error", err)
}
weatherUpdater.Start(ctx)
logger.Info("Weather Updater worker started", "interval", weatherInterval)
apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, inventoryRepo, croplandRepo, farmRepo) // Pass new repo
server := apiInstance.Server(port)
serverErrChan := make(chan error, 1)
@ -87,7 +90,7 @@ func APICmd(ctx context.Context) *cobra.Command {
logger.Info("starting API server", "port", port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("API server failed", "error", err)
serverErrChan <- err // Send error to channel
serverErrChan <- err
}
close(serverErrChan)
}()
@ -98,11 +101,10 @@ func APICmd(ctx context.Context) *cobra.Command {
case <-ctx.Done():
logger.Info("Shutdown signal received, initiating graceful shutdown...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) // 15-second grace period
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
weatherUpdater.Stop() // Signal and wait
weatherUpdater.Stop()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTP server graceful shutdown failed", "error", err)
} else {

View File

@ -20,6 +20,7 @@ var (
OPENWEATHER_API_KEY string
OPENWEATHER_CACHE_TTL string
WEATHER_FETCH_INTERVAL string
GEMINI_API_KEY string
)
func Load() {
@ -36,6 +37,7 @@ func Load() {
viper.SetDefault("OPENWEATHER_API_KEY", "openweather_api_key")
viper.SetDefault("OPENWEATHER_CACHE_TTL", "15m")
viper.SetDefault("WEATHER_FETCH_INTERVAL", "15m")
viper.SetDefault("GEMINI_API_KEY", "gemini_api_key")
viper.SetConfigFile(".env")
viper.AddConfigPath("../../.")
@ -59,4 +61,5 @@ func Load() {
OPENWEATHER_API_KEY = viper.GetString("OPENWEATHER_API_KEY")
OPENWEATHER_CACHE_TTL = viper.GetString("OPENWEATHER_CACHE_TTL")
WEATHER_FETCH_INTERVAL = viper.GetString("WEATHER_FETCH_INTERVAL")
GEMINI_API_KEY = viper.GetString("GEMINI_API_KEY")
}

View File

@ -0,0 +1,445 @@
package services
import (
"context"
"errors"
"fmt"
"log/slog"
"math/rand"
"strings"
"time"
"github.com/forfarm/backend/internal/config"
"github.com/forfarm/backend/internal/domain"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
)
type ChatService struct {
client *genai.Client
logger *slog.Logger
analyticsRepo domain.AnalyticsRepository
farmRepo domain.FarmRepository
cropRepo domain.CroplandRepository
inventoryRepo domain.InventoryRepository
plantRepo domain.PlantRepository
}
func NewChatService(
logger *slog.Logger,
analyticsRepo domain.AnalyticsRepository,
farmRepo domain.FarmRepository,
cropRepo domain.CroplandRepository,
inventoryRepo domain.InventoryRepository,
plantRepo domain.PlantRepository,
) (*ChatService, error) {
if config.GEMINI_API_KEY == "" {
logger.Warn("GEMINI_API_KEY not set, ChatService will not function.")
return &ChatService{client: nil, logger: logger}, nil
}
ctx := context.Background()
client, err := genai.NewClient(ctx, option.WithAPIKey(config.GEMINI_API_KEY))
if err != nil {
logger.Error("Failed to create Gemini client", "error", err)
return nil, fmt.Errorf("error creating genai client: %w", err)
}
logger.Info("Gemini client initialized successfully")
return &ChatService{
client: client,
logger: logger,
analyticsRepo: analyticsRepo,
farmRepo: farmRepo,
cropRepo: cropRepo,
inventoryRepo: inventoryRepo,
plantRepo: plantRepo,
}, nil
}
type GenerateResponseInput struct {
UserID string
Message string
FarmID string
CropID string
History []*genai.Content
}
// --- Context Building Helpers ---
func (s *ChatService) buildCropContextString(ctx context.Context, cropID, userID string) (string, error) {
var contextBuilder strings.Builder
contextBuilder.WriteString("## Current Crop & Plant Context ##\n")
cropAnalytics, err := s.analyticsRepo.GetCropAnalytics(ctx, cropID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
s.logger.Warn("Crop analytics not found for context", "cropId", cropID)
return "", fmt.Errorf("crop not found")
}
s.logger.Error("Failed to fetch crop analytics context", "cropId", cropID, "error", err)
contextBuilder.WriteString(fmt.Sprintf("Error fetching crop details for ID %s.\n", cropID))
return "", fmt.Errorf("failed to fetch crop details")
}
farm, err := s.farmRepo.GetByID(ctx, cropAnalytics.FarmID)
if err != nil || farm.OwnerID != userID {
s.logger.Warn("Ownership check failed for crop context", "cropId", cropID, "farmId", cropAnalytics.FarmID, "userId", userID)
return "", fmt.Errorf("unauthorized access to crop data")
}
fmt.Fprintf(&contextBuilder, "Crop Name: %s (ID: %s)\n", cropAnalytics.CropName, cropAnalytics.CropID)
fmt.Fprintf(&contextBuilder, "Farm: %s (ID: %s)\n", farm.Name, cropAnalytics.FarmID)
fmt.Fprintf(&contextBuilder, "Plant: %s (Variety: %s)\n", cropAnalytics.PlantName, safeString(cropAnalytics.Variety))
fmt.Fprintf(&contextBuilder, "Status: %s\n", cropAnalytics.CurrentStatus)
fmt.Fprintf(&contextBuilder, "Growth Stage: %s\n", cropAnalytics.GrowthStage)
fmt.Fprintf(&contextBuilder, "Land Size: %.2f ha\n", cropAnalytics.LandSize)
fmt.Fprintf(&contextBuilder, "Growth Progress: %d%%\n", cropAnalytics.GrowthProgress)
if cropAnalytics.PlantHealth != nil {
fmt.Fprintf(&contextBuilder, "Health Status: %s\n", *cropAnalytics.PlantHealth)
}
if cropAnalytics.Temperature != nil {
fmt.Fprintf(&contextBuilder, "Temperature: %.1f°C\n", *cropAnalytics.Temperature)
}
if cropAnalytics.Humidity != nil {
fmt.Fprintf(&contextBuilder, "Humidity: %.0f%%\n", *cropAnalytics.Humidity)
}
if cropAnalytics.SoilMoisture != nil {
fmt.Fprintf(&contextBuilder, "Soil Moisture: %.0f%%\n", *cropAnalytics.SoilMoisture)
}
if cropAnalytics.Rainfall != nil {
fmt.Fprintf(&contextBuilder, "Rainfall (1h): %.1f mm\n", *cropAnalytics.Rainfall)
}
if cropAnalytics.WindSpeed != nil {
fmt.Fprintf(&contextBuilder, "Wind Speed: %.1f m/s\n", *cropAnalytics.WindSpeed)
}
if cropAnalytics.Sunlight != nil {
fmt.Fprintf(&contextBuilder, "Sunlight Exposure: %.0f%%\n", *cropAnalytics.Sunlight)
}
if cropAnalytics.NutrientLevels != nil {
contextBuilder.WriteString("Nutrients: ")
nutrients := []string{}
if cropAnalytics.NutrientLevels.Nitrogen != nil {
nutrients = append(nutrients, fmt.Sprintf("N=%.0f%%", *cropAnalytics.NutrientLevels.Nitrogen))
}
if cropAnalytics.NutrientLevels.Phosphorus != nil {
nutrients = append(nutrients, fmt.Sprintf("P=%.0f%%", *cropAnalytics.NutrientLevels.Phosphorus))
}
if cropAnalytics.NutrientLevels.Potassium != nil {
nutrients = append(nutrients, fmt.Sprintf("K=%.0f%%", *cropAnalytics.NutrientLevels.Potassium))
}
if len(nutrients) > 0 {
contextBuilder.WriteString(strings.Join(nutrients, ", "))
} else {
contextBuilder.WriteString("Not Available")
}
contextBuilder.WriteString("\n")
}
if cropAnalytics.NextAction != nil {
dueStr := ""
if cropAnalytics.NextActionDue != nil {
dueStr = fmt.Sprintf(" (Due: %s)", cropAnalytics.NextActionDue.Format(time.RFC1123))
}
fmt.Fprintf(&contextBuilder, "Suggested Next Action: %s%s\n", *cropAnalytics.NextAction, dueStr)
}
contextBuilder.WriteString("\n")
contextBuilder.WriteString("Plant Details:\n")
plant, err := s.plantRepo.GetByName(ctx, cropAnalytics.PlantName)
if err != nil {
s.logger.Warn("Could not fetch plant details for context", "plantId", cropAnalytics.PlantName, "error", err)
fmt.Fprintf(&contextBuilder, " - Could not retrieve plant details.\n")
} else {
fmt.Fprintf(&contextBuilder, " - Type: %s (Variety: %s)\n", plant.Name, safeString(plant.Variety))
if plant.DaysToMaturity != nil {
fmt.Fprintf(&contextBuilder, " - Days to Maturity: ~%d\n", *plant.DaysToMaturity)
}
if plant.OptimalTemp != nil {
fmt.Fprintf(&contextBuilder, " - Optimal Temp: %.1f°C\n", *plant.OptimalTemp)
}
if plant.WaterNeeds != nil {
fmt.Fprintf(&contextBuilder, " - Water Needs: %.1f (units unspecified)\n", *plant.WaterNeeds)
}
if plant.PHValue != nil {
fmt.Fprintf(&contextBuilder, " - Soil pH: %.1f\n", *plant.PHValue)
}
if plant.RowSpacing != nil {
fmt.Fprintf(&contextBuilder, " - Row Spacing: %.1f (units unspecified)\n", *plant.RowSpacing)
}
if plant.PlantingDepth != nil {
fmt.Fprintf(&contextBuilder, " - Planting Depth: %.1f (units unspecified)\n", *plant.PlantingDepth)
}
}
contextBuilder.WriteString("\n")
inventoryContext, _ := s.buildInventoryContextString(ctx, userID)
contextBuilder.WriteString(inventoryContext)
return contextBuilder.String(), nil
}
func (s *ChatService) buildFarmContextString(ctx context.Context, farmID, userID string) (string, error) {
var contextBuilder strings.Builder
contextBuilder.WriteString("## Current Farm Context ##\n")
farmAnalytics, err := s.analyticsRepo.GetFarmAnalytics(ctx, farmID)
if err != nil || farmAnalytics.OwnerID != userID {
if errors.Is(err, domain.ErrNotFound) || farmAnalytics == nil || farmAnalytics.OwnerID != userID {
s.logger.Warn("Farm analytics not found or ownership mismatch for context", "farmId", farmID, "userId", userID)
return "", fmt.Errorf("farm not found or access denied")
}
s.logger.Error("Failed to fetch farm analytics context", "farmId", farmID, "error", err)
return "", fmt.Errorf("failed to fetch farm details")
}
fmt.Fprintf(&contextBuilder, "Farm Name: %s (ID: %s)\n", farmAnalytics.FarmName, farmAnalytics.FarmID)
if farmAnalytics.FarmType != nil {
fmt.Fprintf(&contextBuilder, "Type: %s\n", *farmAnalytics.FarmType)
}
if farmAnalytics.TotalSize != nil {
fmt.Fprintf(&contextBuilder, "Size: %s\n", *farmAnalytics.TotalSize)
}
fmt.Fprintf(&contextBuilder, "Location: Lat %.4f, Lon %.4f\n", farmAnalytics.Latitude, farmAnalytics.Longitude)
if farmAnalytics.OverallStatus != nil {
fmt.Fprintf(&contextBuilder, "Overall Status: %s\n", *farmAnalytics.OverallStatus)
}
if farmAnalytics.Weather != nil {
contextBuilder.WriteString("Weather:\n")
if farmAnalytics.Weather.TempCelsius != nil {
fmt.Fprintf(&contextBuilder, " - Temp: %.1f°C\n", *farmAnalytics.Weather.TempCelsius)
}
if farmAnalytics.Weather.Humidity != nil {
fmt.Fprintf(&contextBuilder, " - Humidity: %.0f%%\n", *farmAnalytics.Weather.Humidity)
}
if farmAnalytics.Weather.Description != nil {
fmt.Fprintf(&contextBuilder, " - Condition: %s\n", *farmAnalytics.Weather.Description)
}
if farmAnalytics.Weather.WindSpeed != nil {
fmt.Fprintf(&contextBuilder, " - Wind: %.1f m/s\n", *farmAnalytics.Weather.WindSpeed)
}
if farmAnalytics.Weather.RainVolume1h != nil {
fmt.Fprintf(&contextBuilder, " - Rain (1h): %.1f mm\n", *farmAnalytics.Weather.RainVolume1h)
}
if farmAnalytics.Weather.WeatherLastUpdated != nil {
fmt.Fprintf(&contextBuilder, " - Last Updated: %s\n", farmAnalytics.Weather.WeatherLastUpdated.Format(time.RFC1123))
}
}
crops, err := s.cropRepo.GetByFarmID(ctx, farmID)
if err == nil && len(crops) > 0 {
contextBuilder.WriteString("Crops on Farm:\n")
for i, crop := range crops {
if i >= 5 {
fmt.Fprintf(&contextBuilder, " - ... and %d more\n", len(crops)-5)
break
}
fmt.Fprintf(&contextBuilder, " - %s (Status: %s, Stage: %s)\n", crop.Name, crop.Status, crop.GrowthStage)
}
} else if err != nil {
s.logger.Warn("Failed to fetch crops for farm context", "farmId", farmID, "error", err)
}
contextBuilder.WriteString("\n")
inventoryContext, _ := s.buildInventoryContextString(ctx, userID)
contextBuilder.WriteString(inventoryContext)
return contextBuilder.String(), nil
}
func (s *ChatService) buildInventoryContextString(ctx context.Context, userID string) (string, error) {
var contextBuilder strings.Builder
contextBuilder.WriteString("## Inventory Summary ##\n")
filter := domain.InventoryFilter{UserID: userID}
sort := domain.InventorySort{Field: "name", Direction: "asc"}
items, err := s.inventoryRepo.GetByUserID(ctx, userID, filter, sort)
if err != nil {
s.logger.Warn("Failed to fetch inventory for context", "userId", userID, "error", err)
fmt.Fprintf(&contextBuilder, "Could not retrieve inventory details.\n")
return contextBuilder.String(), err
}
if len(items) == 0 {
fmt.Fprintf(&contextBuilder, "No inventory items found.\n")
return contextBuilder.String(), nil
}
lowStockCount := 0
fmt.Fprintf(&contextBuilder, "Items (%d total):\n", len(items))
limit := 10
for i, item := range items {
if i >= limit {
fmt.Fprintf(&contextBuilder, "- ... and %d more\n", len(items)-limit)
break
}
statusName := item.Status.Name
if statusName == "" && item.StatusID != 0 {
statusName = fmt.Sprintf("StatusID %d", item.StatusID)
}
unitName := item.Unit.Name
if unitName == "" && item.UnitID != 0 {
unitName = fmt.Sprintf("UnitID %d", item.UnitID)
}
fmt.Fprintf(&contextBuilder, "- %s: %.2f %s (Status: %s)\n", item.Name, item.Quantity, unitName, statusName)
if strings.Contains(strings.ToLower(statusName), "low") {
lowStockCount++
}
}
if lowStockCount > 0 {
fmt.Fprintf(&contextBuilder, "Note: %d item(s) are low on stock.\n", lowStockCount)
}
return contextBuilder.String(), nil
}
func (s *ChatService) buildGeneralContextString(ctx context.Context, userID string) (string, error) {
var contextBuilder strings.Builder
contextBuilder.WriteString("## General Farming Context ##\n")
farms, err := s.farmRepo.GetByOwnerID(ctx, userID)
if err == nil && len(farms) > 0 {
contextBuilder.WriteString("Your Farms:\n")
for i, farm := range farms {
if i >= 5 {
fmt.Fprintf(&contextBuilder, "- ... and %d more\n", len(farms)-5)
break
}
fmt.Fprintf(&contextBuilder, "- %s (Type: %s, Size: %s)\n", farm.Name, farm.FarmType, farm.TotalSize)
}
} else if err != nil {
s.logger.Warn("Failed to fetch farms for general context", "userId", userID, "error", err)
} else {
contextBuilder.WriteString("No farms found for your account.\n")
}
contextBuilder.WriteString("\n")
inventoryContext, _ := s.buildInventoryContextString(ctx, userID)
contextBuilder.WriteString(inventoryContext)
return contextBuilder.String(), nil
}
func (s *ChatService) GenerateResponse(ctx context.Context, input GenerateResponseInput) (string, error) {
var contextString string
var contextErr error
startTime := time.Now()
if input.CropID != "" {
s.logger.Debug("Building context for CROP", "cropId", input.CropID)
contextString, contextErr = s.buildCropContextString(ctx, input.CropID, input.UserID)
} else if input.FarmID != "" {
s.logger.Debug("Building context for FARM", "farmId", input.FarmID)
contextString, contextErr = s.buildFarmContextString(ctx, input.FarmID, input.UserID)
} else {
s.logger.Debug("Building GENERAL context", "userId", input.UserID)
contextString, contextErr = s.buildGeneralContextString(ctx, input.UserID)
}
newsContext, _ := s.retrieveDummyNews(ctx)
weatherOutlook, _ := s.retrieveDummyWeatherOutlook(ctx, input.FarmID)
fullContext := strings.Builder{}
fullContext.WriteString(contextString)
if contextErr != nil {
s.logger.Warn("Error building primary context string", "error", contextErr, "userId", input.UserID, "farmId", input.FarmID, "cropId", input.CropID)
fullContext.WriteString(fmt.Sprintf("Warning: Could not retrieve specific data (%s).\n\n", contextErr))
}
fullContext.WriteString(newsContext)
fullContext.WriteString(weatherOutlook)
contextDuration := time.Since(startTime)
s.logger.Debug("Context retrieval duration", "duration", contextDuration)
model := s.client.GenerativeModel("gemini-1.5-flash")
systemInstruction := `You are ForFarm Assistant, an expert AI specialized in agriculture and farming practices.
Your goal is to provide helpful, accurate, and concise advice to farmers using the ForFarm platform.
Use the provided context data about the user's specific farm, crops, or inventory when available to tailor your response.
If context is provided, prioritize answering based on that context.
If no specific context is available or relevant, provide general best-practice farming advice.
Focus on actionable recommendations where appropriate.
Keep responses focused on farming, agriculture, crop management, pest control, soil health, weather impacts, and inventory management.`
model.SystemInstruction = &genai.Content{Parts: []genai.Part{genai.Text(systemInstruction)}}
fullPrompt := fmt.Sprintf("%s\nUser Question: %s", strings.TrimSpace(fullContext.String()), input.Message)
session := model.StartChat()
session.History = input.History
s.logger.Info("Sending message to LLM", "userId", input.UserID, "historyLength", len(session.History), "contextLength", len(fullContext.String()))
resp, err := session.SendMessage(ctx, genai.Text(fullPrompt))
llmDuration := time.Since(startTime) - contextDuration
s.logger.Debug("LLM response duration", "duration", llmDuration)
if err != nil {
s.logger.Error("Error sending message to Gemini", "error", err)
return "Sorry, I encountered an error while generating a response.", fmt.Errorf("LLM communication failed: %w", err)
}
var responseText strings.Builder
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
for _, part := range resp.Candidates[0].Content.Parts {
if txt, ok := part.(genai.Text); ok {
responseText.WriteString(string(txt))
}
}
}
if responseText.Len() == 0 {
s.logger.Warn("Received no valid text content from Gemini", "finishReason", resp.Candidates[0].FinishReason)
if resp.Candidates[0].FinishReason != genai.FinishReasonStop {
return fmt.Sprintf("My response generation was interrupted (%s). Could you please try rephrasing?", resp.Candidates[0].FinishReason), nil
}
return "I apologize, I couldn't generate a response for that request.", nil
}
s.logger.Info("Successfully generated chat response", "userId", input.UserID, "responseLength", responseText.Len())
return responseText.String(), nil
}
// Simulates fetching news with artificial delay and random selection
func (s *ChatService) retrieveDummyNews(ctx context.Context) (string, error) {
s.logger.Debug("Retrieving dummy news context")
time.Sleep(50 * time.Millisecond)
if rand.Intn(10) > 2 {
newsItems := []string{
"Global wheat prices show slight increase.",
"New organic pest control method using beneficial nematodes gaining traction.",
"Research highlights drought-resistant corn variety performance.",
"Government announces new subsidies for sustainable farming practices.",
}
return fmt.Sprintf("## Recent Agricultural News ##\n- %s\n\n", newsItems[rand.Intn(len(newsItems))]), nil
}
return "", nil
}
// Simulates fetching weather forecast with artificial delay and random selection
func (s *ChatService) retrieveDummyWeatherOutlook(ctx context.Context, farmID string) (string, error) {
s.logger.Debug("Retrieving dummy weather outlook context", "farmId", farmID)
time.Sleep(80 * time.Millisecond)
forecasts := []string{
"Clear skies expected for the next 3 days.",
"Chance of scattered showers tomorrow afternoon.",
"Temperature expected to rise towards the weekend.",
"Slightly higher winds predicted for Thursday.",
}
return fmt.Sprintf("## Weather Outlook ##\n- %s\n\n", forecasts[rand.Intn(len(forecasts))]), nil
}
func safeString(s *string) string {
if s == nil {
return "N/A"
}
return *s
}
func (s *ChatService) Close() {
if s.client != nil {
if err := s.client.Close(); err != nil {
s.logger.Error("Failed to close Gemini client", "error", err)
} else {
s.logger.Info("Gemini client closed.")
}
}
}

73
frontend/api/chat.ts Normal file
View File

@ -0,0 +1,73 @@
import axiosInstance from "./config";
interface HistoryItem {
role: "user" | "model" | "assistant";
text: string;
}
interface ChatResponse {
response: string;
// Include history if backend returns it, otherwise frontend manages it
}
// Map frontend 'assistant' role to backend 'model' role if needed
const mapRoleForApi = (role: "user" | "assistant"): "user" | "model" => {
return role === "assistant" ? "model" : role;
};
/**
* Sends a chat message to the backend.
* Determines the endpoint based on the presence of farmId and cropId.
*
* @param message The user's message.
* @param history The conversation history.
* @param farmId Optional farm ID for context.
* @param cropId Optional crop ID for context.
* @returns The assistant's response.
*/
export async function sendChatMessage(
message: string,
history: HistoryItem[],
farmId?: string | null,
cropId?: string | null
): Promise<ChatResponse> {
const endpoint = farmId || cropId ? "/chat/specific" : "/chat"; // Use /chatbot if no IDs
const apiHistory = history.map((item) => ({
role: mapRoleForApi(item.role as "user" | "assistant"),
text: item.text,
}));
const payload: {
message: string;
farmId?: string;
cropId?: string;
history: { role: "user" | "model"; text: string }[];
} = {
message,
history: apiHistory,
};
// Only include IDs if calling the contextual endpoint
if (farmId || cropId) {
if (farmId) payload.farmId = farmId;
if (cropId) payload.cropId = cropId;
}
console.log(`Sending chat message to ${endpoint} with payload:`, payload);
try {
const response = await axiosInstance.post<ChatResponse>(endpoint, payload);
console.log("Received chat response:", response.data);
return response.data;
} catch (error) {
console.error(`Error sending chat message to ${endpoint}:`, error);
// Provide a user-friendly error message
const errorMessage =
(error as any).response?.data?.message ||
"Sorry, I couldn't connect to the assistant right now. Please try again later.";
// Throw an error with a useful message or return a specific error structure
// throw new Error(errorMessage);
// Or return an error response structure:
return { response: errorMessage };
}
}

View File

@ -2,204 +2,69 @@
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import {
ChevronLeft,
Send,
Clock,
X,
Leaf,
MessageSquare,
History,
PanelRightClose,
PanelRightOpen,
Search,
Sparkles,
} from "lucide-react";
import { Send, MessageSquare, Sparkles, Loader2, User, Bot } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar } from "@/components/ui/avatar";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import type { Farm, Crop } from "@/types";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; // Assuming Avatar is in ui folder
import { sendChatMessage } from "@/api/chat"; // Import the API function
// Mock data for farms and crops
const mockFarms: Farm[] = [
{
id: "farm1",
name: "Green Valley Farm",
location: "California",
type: "Organic",
createdAt: new Date("2023-01-15"),
area: "120 acres",
crops: 8,
weather: {
temperature: 24,
humidity: 65,
rainfall: "2mm",
sunlight: 80,
},
},
{
id: "farm2",
name: "Sunrise Fields",
location: "Iowa",
type: "Conventional",
createdAt: new Date("2022-11-05"),
area: "350 acres",
crops: 5,
weather: {
temperature: 22,
humidity: 58,
rainfall: "0mm",
sunlight: 90,
},
},
];
const mockCrops: Crop[] = [
{
id: "crop1",
farmId: "farm1",
name: "Organic Tomatoes",
plantedDate: new Date("2023-03-10"),
status: "Growing",
variety: "Roma",
area: "15 acres",
healthScore: 92,
progress: 65,
},
{
id: "crop2",
farmId: "farm1",
name: "Sweet Corn",
plantedDate: new Date("2023-04-05"),
status: "Growing",
variety: "Golden Bantam",
area: "25 acres",
healthScore: 88,
progress: 45,
},
{
id: "crop3",
farmId: "farm2",
name: "Soybeans",
plantedDate: new Date("2023-05-15"),
status: "Growing",
variety: "Pioneer",
area: "120 acres",
healthScore: 95,
progress: 30,
},
];
// Mock chat history
// Interface for chat messages
interface ChatMessage {
id: string;
role: "user" | "assistant"; // Changed sender to role
content: string;
sender: "user" | "bot";
timestamp: Date;
relatedTo?: {
type: "farm" | "crop";
id: string;
name: string;
};
}
const mockChatHistory: ChatMessage[] = [
{
id: "msg1",
content: "When should I harvest my tomatoes?",
sender: "user",
timestamp: new Date("2023-07-15T10:30:00"),
relatedTo: {
type: "crop",
id: "crop1",
name: "Organic Tomatoes",
},
},
{
id: "msg2",
content:
"Based on the current growth stage of your Roma tomatoes, they should be ready for harvest in approximately 2-3 weeks. The ideal time to harvest is when they've developed their full red color but are still firm to the touch. Keep monitoring the soil moisture levels as consistent watering during the final ripening stage is crucial for flavor development.",
sender: "bot",
timestamp: new Date("2023-07-15T10:30:30"),
},
{
id: "msg3",
content: "What's the best fertilizer for corn?",
sender: "user",
timestamp: new Date("2023-07-16T14:22:00"),
relatedTo: {
type: "crop",
id: "crop2",
name: "Sweet Corn",
},
},
{
id: "msg4",
content:
"For your Sweet Corn at Green Valley Farm, I recommend a nitrogen-rich fertilizer with an NPK ratio of approximately 16-4-8. Corn is a heavy nitrogen feeder, especially during its growth phase. Apply the fertilizer when the plants are knee-high and again when they begin to tassel. Based on your soil analysis, consider supplementing with sulfur to address the slight deficiency detected in your last soil test.",
sender: "bot",
timestamp: new Date("2023-07-16T14:22:45"),
},
];
// Recommended prompts
// Recommended prompts (keep or adjust as needed)
const recommendedPrompts = [
{
id: "prompt1",
text: "When should I water my crops?",
category: "Irrigation",
text: "What are common signs of nutrient deficiency in plants?",
category: "Plant Health",
},
{
id: "prompt2",
text: "How can I improve soil health?",
text: "How can I improve soil drainage?",
category: "Soil Management",
},
{
id: "prompt3",
text: "What pests might affect my crops this season?",
text: "Explain integrated pest management (IPM).",
category: "Pest Control",
},
{
id: "prompt4",
text: "Recommend a crop rotation plan",
text: "What are the benefits of crop rotation?",
category: "Planning",
},
{
id: "prompt5",
text: "How to maximize yield for my current crops?",
category: "Optimization",
text: "Tell me about sustainable farming practices.",
category: "Sustainability",
},
{
id: "prompt6",
text: "What's the best time to harvest?",
category: "Harvesting",
text: "How does weather affect crop yield?",
category: "Weather",
},
];
export default function ChatbotPage() {
export default function GeneralChatbotPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState("");
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedFarm, setSelectedFarm] = useState<string | null>(null);
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(false); // Loading state for API call
const messagesEndRef = useRef<HTMLDivElement>(null);
const router = useRouter();
// Initialize with a welcome message
useEffect(() => {
setMessages([
{
id: "welcome",
id: `assistant-${Date.now()}`,
role: "assistant",
content:
"👋 Hello! I'm ForFarm Assistant, your farming AI companion. How can I help you today? You can ask me about crop management, pest control, weather impacts, or select a specific farm or crop to get tailored advice.",
sender: "bot",
"👋 Hello! I'm ForFarm Assistant, your general farming AI companion. Ask me anything about agriculture, crops, soil, weather, or best practices!",
timestamp: new Date(),
},
]);
@ -210,483 +75,181 @@ export default function ChatbotPage() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Filter crops based on selected farm
const filteredCrops = selectedFarm ? mockCrops.filter((crop) => crop.farmId === selectedFarm) : mockCrops;
// Handle sending a message
const handleSendMessage = (content: string = inputValue) => {
if (!content.trim()) return;
const handleSendMessage = async (content: string = inputValue) => {
if (!content.trim() || isLoading) return;
// Create user message
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content,
sender: "user",
timestamp: new Date(),
...(selectedFarm || selectedCrop
? {
relatedTo: {
type: selectedCrop ? "crop" : "farm",
id: selectedCrop || selectedFarm || "",
name: selectedCrop
? mockCrops.find((c) => c.id === selectedCrop)?.name || ""
: mockFarms.find((f) => f.id === selectedFarm)?.name || "",
},
}
: {}),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue("");
setIsLoading(true);
// Simulate bot response after a delay
setTimeout(() => {
const botResponse: ChatMessage = {
id: `bot-${Date.now()}`,
content: generateBotResponse(content, selectedFarm, selectedCrop),
sender: "bot",
// Prepare history for the API call
const apiHistory = messages
.filter((msg) => msg.role === "user" || msg.role === "assistant")
.map((msg) => ({ role: msg.role, text: msg.content }));
try {
// Call the API function *without* farmId and cropId
const response = await sendChatMessage(userMessage.content, apiHistory);
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: response.response,
timestamp: new Date(),
};
setMessages((prev) => [...prev, botResponse]);
setMessages((prev) => [...prev, assistantMessage]);
} catch (error) {
console.error("Error sending general chat message:", error);
const errorMessage: ChatMessage = {
id: `error-${Date.now()}`,
role: "assistant",
content: `Sorry, I encountered an issue. ${(error as Error).message || "Please try again later."}`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}, 1500);
};
// Generate a bot response based on the user's message and selected farm/crop
const generateBotResponse = (message: string, farmId: string | null, cropId: string | null): string => {
const lowerMessage = message.toLowerCase();
// Get farm and crop details if selected
const farm = farmId ? mockFarms.find((f) => f.id === farmId) : null;
const crop = cropId ? mockCrops.find((c) => c.id === cropId) : null;
// Personalize response based on selected farm/crop
let contextPrefix = "";
if (crop) {
contextPrefix = `For your ${crop.name} (${crop.variety}) at ${farm?.name || "your farm"}, `;
} else if (farm) {
contextPrefix = `For ${farm.name}, `;
}
// Generate response based on message content
if (lowerMessage.includes("water") || lowerMessage.includes("irrigation")) {
return `${contextPrefix}I recommend watering deeply but infrequently to encourage strong root growth. Based on the current weather conditions${
farm ? ` in ${farm.location}` : ""
} (${farm?.weather?.rainfall || "minimal"} rainfall recently), you should water ${
crop ? `your ${crop.name}` : "your crops"
} approximately 2-3 times per week, ensuring the soil remains moist but not waterlogged.`;
} else if (lowerMessage.includes("fertiliz") || lowerMessage.includes("nutrient")) {
return `${contextPrefix}a balanced NPK fertilizer with a ratio of 10-10-10 would be suitable for general application. ${
crop
? `For ${crop.name} specifically, consider increasing ${
crop.name.toLowerCase().includes("tomato")
? "potassium"
: crop.name.toLowerCase().includes("corn")
? "nitrogen"
: "phosphorus"
} for optimal growth during the current ${
crop.progress && crop.progress < 30 ? "early" : crop.progress && crop.progress < 70 ? "middle" : "late"
} growth stage.`
: ""
}`;
} else if (lowerMessage.includes("pest") || lowerMessage.includes("insect") || lowerMessage.includes("disease")) {
return `${contextPrefix}monitor for ${
crop
? crop.name.toLowerCase().includes("tomato")
? "tomato hornworms, aphids, and early blight"
: crop.name.toLowerCase().includes("corn")
? "corn borers, rootworms, and rust"
: "common agricultural pests"
: "common agricultural pests like aphids, beetles, and fungal diseases"
}. I recommend implementing integrated pest management (IPM) practices, including regular scouting, beneficial insects, and targeted treatments only when necessary.`;
} else if (lowerMessage.includes("harvest") || lowerMessage.includes("yield")) {
return `${contextPrefix}${
crop
? `your ${crop.name} should be ready to harvest in approximately ${Math.max(
1,
Math.round((100 - (crop.progress || 50)) / 10)
)} weeks based on the current growth stage. Look for ${
crop.name.toLowerCase().includes("tomato")
? "firm, fully colored fruits"
: crop.name.toLowerCase().includes("corn")
? "full ears with dried silk and plump kernels"
: "signs of maturity specific to this crop type"
}`
: "harvest timing depends on the specific crops you're growing, but generally you should look for visual cues of ripeness and maturity"
}.`;
} else if (lowerMessage.includes("soil") || lowerMessage.includes("compost")) {
return `${contextPrefix}improving soil health is crucial for sustainable farming. I recommend regular soil testing, adding organic matter through compost or cover crops, practicing crop rotation, and minimizing soil disturbance. ${
farm
? `Based on the soil type common in ${farm.location}, you might also consider adding ${
farm.location.includes("California") ? "gypsum to improve drainage" : "lime to adjust pH levels"
}.`
: ""
}`;
} else if (lowerMessage.includes("weather") || lowerMessage.includes("forecast") || lowerMessage.includes("rain")) {
return `${contextPrefix}${
farm
? `the current conditions show temperature at ${farm.weather?.temperature}°C with ${farm.weather?.humidity}% humidity. There's been ${farm.weather?.rainfall} of rainfall recently, and sunlight levels are at ${farm.weather?.sunlight}% of optimal.`
: "I recommend checking your local agricultural weather service for the most accurate forecast for your specific location."
} ${
crop
? `For your ${crop.name}, ${
farm?.weather?.rainfall === "0mm"
? "the dry conditions mean you should increase irrigation"
: "the recent rainfall means you can reduce irrigation temporarily"
}.`
: ""
}`;
} else {
return `${contextPrefix}I understand you're asking about "${message}". To provide the most helpful advice, could you provide more specific details about your farming goals or challenges? I'm here to help with crop management, pest control, irrigation strategies, and more.`;
}
};
// Handle selecting a farm
const handleFarmSelect = (farmId: string) => {
setSelectedFarm(farmId);
setSelectedCrop(null); // Reset crop selection when farm changes
};
// Handle selecting a crop
const handleCropSelect = (cropId: string) => {
setSelectedCrop(cropId);
};
// Handle clicking a recommended prompt
const handlePromptClick = (promptText: string) => {
setInputValue(promptText);
handleSendMessage(promptText);
};
// Handle loading a chat history item
const handleLoadChatHistory = (messageId: string) => {
// Find the message in history
const historyItem = mockChatHistory.find((msg) => msg.id === messageId);
if (!historyItem) return;
// Set related farm/crop if available
if (historyItem.relatedTo) {
if (historyItem.relatedTo.type === "farm") {
setSelectedFarm(historyItem.relatedTo.id);
setSelectedCrop(null);
} else if (historyItem.relatedTo.type === "crop") {
const crop = mockCrops.find((c) => c.id === historyItem.relatedTo?.id);
if (crop) {
setSelectedFarm(crop.farmId);
setSelectedCrop(historyItem.relatedTo.id);
}
}
}
// Load the conversation
const conversation = mockChatHistory.filter(
(msg) =>
msg.id === messageId ||
(msg.timestamp >= historyItem.timestamp && msg.timestamp <= new Date(historyItem.timestamp.getTime() + 60000))
);
setMessages(conversation);
setIsHistoryOpen(false);
// Directly send message after setting input
// The handleSendMessage function will pick up the new inputValue
// No need to call handleSendMessage here if the button triggers submit or calls it
// Let's assume the button just sets the input and the user clicks send
// OR: uncomment the line below if the button should send immediately
// handleSendMessage(promptText);
};
return (
<div className="flex flex-col min-h-screen bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950">
{/* Header */}
<header className="border-b bg-white dark:bg-gray-950 shadow-sm py-4 px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => router.push("/farms")} aria-label="Back to farms">
<ChevronLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
<h1 className="text-xl font-semibold">ForFarm Assistant</h1>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
aria-label={isHistoryOpen ? "Close history" : "Open history"}>
{isHistoryOpen ? <PanelRightClose className="h-5 w-5" /> : <PanelRightOpen className="h-5 w-5" />}
</Button>
</div>
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950">
{/* Header (Optional - can be simplified as it's part of the main layout) */}
<header className="border-b bg-white dark:bg-gray-950 shadow-sm py-4 px-6 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
<h1 className="text-xl font-semibold">General Farming Assistant</h1>
</header>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
{/* Chat area */}
<div className="flex-1 flex flex-col h-full overflow-hidden">
{/* Farm/Crop selector */}
<div className="bg-white dark:bg-gray-900 p-4 border-b">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="text-sm font-medium mb-1 block text-gray-700 dark:text-gray-300">
Select Farm (Optional)
</label>
<Select value={selectedFarm || ""} onValueChange={handleFarmSelect}>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Farms" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Farms</SelectItem>
{mockFarms.map((farm) => (
<SelectItem key={farm.id} value={farm.id}>
{farm.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<label className="text-sm font-medium mb-1 block text-gray-700 dark:text-gray-300">
Select Crop (Optional)
</label>
<Select value={selectedCrop || ""} onValueChange={handleCropSelect} disabled={!selectedFarm}>
<SelectTrigger className="w-full">
<SelectValue placeholder={selectedFarm ? "All Crops" : "Select a farm first"} />
</SelectTrigger>
<SelectContent>
{selectedFarm && <SelectItem value="all">All Crops</SelectItem>}
{filteredCrops.map((crop) => (
<SelectItem key={crop.id} value={crop.id}>
{crop.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Messages */}
<ScrollArea className="flex-1 p-4">
<div className="space-y-4 max-w-3xl mx-auto">
{messages.map((message) => (
<div key={message.id} className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-lg p-4 ${
message.sender === "user"
? "bg-green-600 text-white dark:bg-green-700"
: "bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm"
}`}>
{message.relatedTo && (
<div className="mb-1">
<Badge variant="outline" className="text-xs font-normal">
{message.relatedTo.type === "farm" ? "🏡 " : "🌱 "}
{message.relatedTo.name}
</Badge>
</div>
)}
<div className="text-sm">{message.content}</div>
<div className="mt-1 text-xs opacity-70 text-right">
{message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-lg p-4 bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm">
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-150"></div>
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-300"></div>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-1">
ForFarm Assistant is typing...
</span>
</div>
</div>
</div>
{/* Chat Area */}
<ScrollArea className="flex-1 p-4">
<div className="space-y-6 max-w-4xl mx-auto pb-4">
{messages.map((message, i) => (
<div
key={message.id || i}
className={`flex items-start gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
{message.role === "assistant" && (
<Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0">
<AvatarFallback>
<Bot className="h-5 w-5 text-green-600" />
</AvatarFallback>
</Avatar>
)}
<div
className={`max-w-[80%] rounded-lg p-3 shadow-sm ${
message.role === "user"
? "bg-green-600 text-white dark:bg-green-700"
: "bg-white dark:bg-gray-800 border dark:border-gray-700"
}`}>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
<p
className={`mt-1 text-xs opacity-70 ${
message.role === "user" ? "text-green-100" : "text-gray-500 dark:text-gray-400"
} text-right`}>
{message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
{message.role === "user" && (
<Avatar className="h-8 w-8 border bg-gray-200 dark:bg-gray-600 flex-shrink-0">
<AvatarFallback>
<User className="h-5 w-5 text-gray-600 dark:text-gray-300" />
</AvatarFallback>
</Avatar>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* Recommended prompts */}
<div className="bg-white dark:bg-gray-900 border-t p-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<Sparkles className="h-4 w-4 text-green-500" />
Recommended Questions
</h3>
<div className="flex flex-wrap gap-2">
{recommendedPrompts.map((prompt) => (
<Button
key={prompt.id}
variant="outline"
size="sm"
className="text-xs rounded-full bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40 text-green-800 dark:text-green-300"
onClick={() => handlePromptClick(prompt.text)}>
{prompt.text}
</Button>
))}
</div>
</div>
{/* Input area */}
<div className="bg-white dark:bg-gray-900 border-t p-4">
<div className="flex gap-2 max-w-3xl mx-auto">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Ask about your farm or crops..."
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
/>
<Button
onClick={() => handleSendMessage()}
disabled={!inputValue.trim() || isLoading}
className="bg-green-600 hover:bg-green-700 text-white">
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Chat history sidebar */}
{isHistoryOpen && (
<div className="w-80 border-l bg-white dark:bg-gray-900 flex flex-col">
<div className="p-4 border-b flex items-center justify-between">
<h2 className="font-medium flex items-center gap-1">
<History className="h-4 w-4" />
Chat History
</h2>
<Button variant="ghost" size="icon" onClick={() => setIsHistoryOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="p-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input placeholder="Search conversations..." className="pl-9" />
))}
{isLoading && (
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0">
<AvatarFallback>
<Bot className="h-5 w-5 text-green-600" />
</AvatarFallback>
</Avatar>
<div className="max-w-[80%] rounded-lg p-3 bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm">
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-150"></div>
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-300"></div>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-1">Assistant is thinking...</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
<Tabs defaultValue="recent" className="flex-1 flex flex-col">
<TabsList className="mx-3 mb-2">
<TabsTrigger value="recent" className="flex-1">
Recent
</TabsTrigger>
<TabsTrigger value="farms" className="flex-1">
By Farm
</TabsTrigger>
<TabsTrigger value="crops" className="flex-1">
By Crop
</TabsTrigger>
</TabsList>
{/* Recommended prompts */}
<div className="bg-white dark:bg-gray-900 border-t p-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<Sparkles className="h-4 w-4 text-green-500" />
Try asking...
</h3>
<div className="flex flex-wrap gap-2">
{recommendedPrompts.slice(0, 5).map(
(
prompt // Limit displayed prompts
) => (
<Button
key={prompt.id}
variant="outline"
size="sm"
className="text-xs rounded-full bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40 text-green-800 dark:text-green-300"
onClick={() => handlePromptClick(prompt.text)}>
{prompt.text}
</Button>
)
)}
</div>
</div>
<ScrollArea className="flex-1">
<TabsContent value="recent" className="m-0 p-0">
<div className="space-y-1 p-2">
{mockChatHistory
.filter((msg) => msg.sender === "user")
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.map((message) => (
<Card
key={message.id}
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
onClick={() => handleLoadChatHistory(message.id)}>
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8 bg-green-100 dark:bg-green-900">
<div className="text-xs font-medium text-green-700 dark:text-green-300">
{message.relatedTo?.name.substring(0, 2) || "Me"}
</div>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium truncate">
{message.relatedTo ? message.relatedTo.name : "General Question"}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Clock className="h-3 w-3" />
{message.timestamp.toLocaleDateString()}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-300 truncate mt-1">
{message.content}
</p>
</div>
</div>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="farms" className="m-0 p-0">
<div className="space-y-3 p-3">
{mockFarms.map((farm) => (
<div key={farm.id}>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{farm.name}</h3>
<div className="space-y-1">
{mockChatHistory
.filter(
(msg) =>
msg.sender === "user" && msg.relatedTo?.type === "farm" && msg.relatedTo.id === farm.id
)
.map((message) => (
<Card
key={message.id}
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
onClick={() => handleLoadChatHistory(message.id)}>
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">{message.content}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{message.timestamp.toLocaleDateString()}
</p>
</Card>
))}
</div>
<Separator className="my-3" />
</div>
))}
</div>
</TabsContent>
<TabsContent value="crops" className="m-0 p-0">
<div className="space-y-3 p-3">
{mockCrops.map((crop) => (
<div key={crop.id}>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<Leaf className="h-3 w-3 text-green-600" />
{crop.name}
<span className="text-xs text-gray-500 dark:text-gray-400">
({mockFarms.find((f) => f.id === crop.farmId)?.name})
</span>
</h3>
<div className="space-y-1">
{mockChatHistory
.filter(
(msg) =>
msg.sender === "user" && msg.relatedTo?.type === "crop" && msg.relatedTo.id === crop.id
)
.map((message) => (
<Card
key={message.id}
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
onClick={() => handleLoadChatHistory(message.id)}>
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">{message.content}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{message.timestamp.toLocaleDateString()}
</p>
</Card>
))}
</div>
<Separator className="my-3" />
</div>
))}
</div>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
)}
{/* Input area */}
<div className="bg-white dark:bg-gray-900 border-t p-4 sticky bottom-0">
<form
onSubmit={(e) => {
e.preventDefault();
handleSendMessage();
}}
className="flex gap-2 max-w-4xl mx-auto">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Ask the farming assistant..."
className="flex-1 h-11"
disabled={isLoading}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
/>
<Button
type="submit"
disabled={!inputValue.trim() || isLoading}
className="bg-green-600 hover:bg-green-700 text-white h-11 px-5">
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
<span className="sr-only">Send</span>
</Button>
</form>
</div>
</div>
);

View File

@ -1,16 +1,21 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Send } from "lucide-react";
import { Send, Bot, Loader2, User } from "lucide-react";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useParams } from "next/navigation";
import { sendChatMessage } from "@/api/chat";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
}
interface ChatbotDialogProps {
@ -20,52 +25,158 @@ interface ChatbotDialogProps {
}
export function ChatbotDialog({ open, onOpenChange, cropName }: ChatbotDialogProps) {
const [messages, setMessages] = useState<Message[]>([
{
role: "assistant",
content: `Hello! I'm your farming assistant. How can I help you with your ${cropName} today?`,
},
]);
const params = useParams<{ farmId: string; cropId: string }>();
const { farmId, cropId } = params;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false); // Loading state for API call
const messagesEndRef = useRef<HTMLDivElement>(null);
const handleSend = () => {
if (!input.trim()) return;
// Initialize with a welcome message when dialog opens
useEffect(() => {
if (open) {
setMessages([
{
id: `assistant-${Date.now()}`,
role: "assistant",
content: `Hello! How can I help you with ${cropName} (Crop ID: ${cropId}) today?`,
timestamp: new Date(),
},
]);
// Reset input when opening
setInput("");
setIsLoading(false);
}
}, [open, cropName, cropId]); // Add dependencies
const newMessages: Message[] = [
...messages,
{ role: "user", content: input },
{ role: "assistant", content: `Here's some information about ${cropName}: [AI response placeholder]` },
];
setMessages(newMessages);
// Scroll to bottom of messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isLoading]); // Add isLoading to scroll when loading appears/disappears
const handleSend = async () => {
const messageText = input.trim();
if (!messageText || isLoading) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: "user",
content: messageText,
timestamp: new Date(),
};
// Add user message immediately and clear input, set loading
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true); // Show loading indicator *now*
// Prepare history for API call (use messages *before* adding the placeholder)
const apiHistory = [...messages, userMessage] // Include the just-added user message
.map((msg) => ({ role: msg.role, text: msg.content }));
try {
// Call the API function with context
const response = await sendChatMessage(
userMessage.content,
apiHistory,
farmId, // Pass farmId
cropId // Pass cropId
);
const assistantMessage: Message = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: response.response,
timestamp: new Date(),
};
// Replace loading state with the actual response
setMessages((prev) => [...prev, assistantMessage]);
} catch (error) {
console.error("Error sending chat message:", error);
const errorMessage: Message = {
id: `error-${Date.now()}`,
role: "assistant",
content: `Sorry, something went wrong. ${(error as Error).message || "Please try again."}`,
timestamp: new Date(),
};
// Replace loading state with the error message
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false); // Hide loading indicator
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<VisuallyHidden>
<DialogTitle>Farming Assistant Chat</DialogTitle>
<DialogTitle>Farming Assistant Chat for {cropName}</DialogTitle>
</VisuallyHidden>
<DialogContent className="sm:max-w-[500px] p-0 dark:bg-background">
<div className="flex flex-col h-[600px]">
<div className="p-4 border-b dark:border-slate-700">
<h2 className="text-lg font-semibold">Farming Assistant</h2>
<p className="text-sm text-muted-foreground">Ask questions about your {cropName}</p>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Bot className="h-5 w-5 text-green-600" />
Farming Assistant
</h2>
<p className="text-sm text-muted-foreground">Ask about {cropName}</p>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{messages.map((message, i) => (
<div key={i} className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}>
{messages.map(
(
message // Render existing messages
) => (
<div
className={`rounded-lg px-4 py-2 max-w-[80%] ${
message.role === "user"
? "bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground"
: "bg-muted dark:bg-muted dark:text-muted-foreground"
}`}>
{message.content}
key={message.id}
className={`flex items-start gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
{message.role === "assistant" && (
<Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0 shadow-sm">
<AvatarFallback>
<Bot className="h-5 w-5 text-green-600" />
</AvatarFallback>
</Avatar>
)}
<div
className={`rounded-lg px-4 py-2 max-w-[80%] shadow-sm ${
message.role === "user"
? "bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground"
: "bg-muted dark:bg-muted dark:text-muted-foreground"
}`}>
<p className="text-sm whitespace-pre-wrap break-words">{message.content}</p>
<p
className={`mt-1 text-xs opacity-70 ${
message.role === "user" ? "text-green-100" : "text-gray-500 dark:text-gray-400"
} text-right`}>
{message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
{message.role === "user" && (
<Avatar className="h-8 w-8 border bg-gray-200 dark:bg-gray-600 flex-shrink-0 shadow-sm">
<AvatarFallback>
<User className="h-5 w-5 text-gray-600 dark:text-gray-300" />
</AvatarFallback>
</Avatar>
)}
</div>
)
)}
{/* Conditional Loading Indicator */}
{isLoading && (
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0">
<AvatarFallback>
<Bot className="h-5 w-5 text-green-600" />
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted dark:bg-muted">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Assistant is thinking...</span>
</div>
</div>
))}
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
@ -76,9 +187,22 @@ export function ChatbotDialog({ open, onOpenChange, cropName }: ChatbotDialogPro
handleSend();
}}
className="flex gap-2">
<Input placeholder="Type your message..." value={input} onChange={(e) => setInput(e.target.value)} />
<Button type="submit" size="icon">
<Send className="h-4 w-4" />
<Input
placeholder="Type your message..."
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
className="h-11"
/>
<Button type="submit" size="icon" disabled={isLoading || !input.trim()} className="h-11 w-11">
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
<span className="sr-only">Send</span>
</Button>
</form>
</div>

View File

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@hookform/resolvers": "^4.0.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",

View File

@ -11,6 +11,9 @@ importers:
'@hookform/resolvers':
specifier: ^4.0.0
version: 4.1.3(react-hook-form@7.54.2(react@19.0.0))
'@radix-ui/react-alert-dialog':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-avatar':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -476,6 +479,19 @@ packages:
'@radix-ui/primitive@1.1.1':
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
'@radix-ui/react-alert-dialog@1.1.6':
resolution: {integrity: sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.2':
resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==}
peerDependencies:
@ -2999,6 +3015,20 @@ snapshots:
'@radix-ui/primitive@1.1.1': {}
'@radix-ui/react-alert-dialog@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-dialog': 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot': 1.1.2(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-arrow@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)