Merge branch 'main' of https://github.com/dimuuu/haystack-app
This commit is contained in:
18
backend/.gen/haystack/haystack/model/image.go
Normal file
18
backend/.gen/haystack/haystack/model/image.go
Normal file
@ -0,0 +1,18 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
ImageName string
|
||||
Image []byte
|
||||
}
|
@ -12,6 +12,7 @@ import (
|
||||
)
|
||||
|
||||
type ImageTags struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
TagID uuid.UUID
|
||||
ImageID uuid.UUID
|
||||
}
|
||||
|
@ -12,8 +12,7 @@ import (
|
||||
)
|
||||
|
||||
type UserImages struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
ImageName string
|
||||
Image []byte
|
||||
UserID uuid.UUID
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
ImageID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
}
|
||||
|
@ -0,0 +1,18 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserImagesToProcess struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
ImageID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
}
|
81
backend/.gen/haystack/haystack/table/image.go
Normal file
81
backend/.gen/haystack/haystack/table/image.go
Normal file
@ -0,0 +1,81 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Image = newImageTable("haystack", "image", "")
|
||||
|
||||
type imageTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnString
|
||||
ImageName postgres.ColumnString
|
||||
Image postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ImageTable struct {
|
||||
imageTable
|
||||
|
||||
EXCLUDED imageTable
|
||||
}
|
||||
|
||||
// AS creates new ImageTable with assigned alias
|
||||
func (a ImageTable) AS(alias string) *ImageTable {
|
||||
return newImageTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ImageTable with assigned schema name
|
||||
func (a ImageTable) FromSchema(schemaName string) *ImageTable {
|
||||
return newImageTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ImageTable with assigned table prefix
|
||||
func (a ImageTable) WithPrefix(prefix string) *ImageTable {
|
||||
return newImageTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ImageTable with assigned table suffix
|
||||
func (a ImageTable) WithSuffix(suffix string) *ImageTable {
|
||||
return newImageTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newImageTable(schemaName, tableName, alias string) *ImageTable {
|
||||
return &ImageTable{
|
||||
imageTable: newImageTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newImageTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newImageTableImpl(schemaName, tableName, alias string) imageTable {
|
||||
var (
|
||||
IDColumn = postgres.StringColumn("id")
|
||||
ImageNameColumn = postgres.StringColumn("image_name")
|
||||
ImageColumn = postgres.StringColumn("image")
|
||||
allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, ImageColumn}
|
||||
mutableColumns = postgres.ColumnList{ImageNameColumn, ImageColumn}
|
||||
)
|
||||
|
||||
return imageTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
ImageName: ImageNameColumn,
|
||||
Image: ImageColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
}
|
||||
}
|
@ -17,6 +17,7 @@ type imageTagsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnString
|
||||
TagID postgres.ColumnString
|
||||
ImageID postgres.ColumnString
|
||||
|
||||
@ -59,9 +60,10 @@ func newImageTagsTable(schemaName, tableName, alias string) *ImageTagsTable {
|
||||
|
||||
func newImageTagsTableImpl(schemaName, tableName, alias string) imageTagsTable {
|
||||
var (
|
||||
IDColumn = postgres.StringColumn("id")
|
||||
TagIDColumn = postgres.StringColumn("tag_id")
|
||||
ImageIDColumn = postgres.StringColumn("image_id")
|
||||
allColumns = postgres.ColumnList{TagIDColumn, ImageIDColumn}
|
||||
allColumns = postgres.ColumnList{IDColumn, TagIDColumn, ImageIDColumn}
|
||||
mutableColumns = postgres.ColumnList{TagIDColumn, ImageIDColumn}
|
||||
)
|
||||
|
||||
@ -69,6 +71,7 @@ func newImageTagsTableImpl(schemaName, tableName, alias string) imageTagsTable {
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
TagID: TagIDColumn,
|
||||
ImageID: ImageIDColumn,
|
||||
|
||||
|
@ -10,10 +10,12 @@ package table
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
Image = Image.FromSchema(schema)
|
||||
ImageLinks = ImageLinks.FromSchema(schema)
|
||||
ImageTags = ImageTags.FromSchema(schema)
|
||||
ImageText = ImageText.FromSchema(schema)
|
||||
UserImages = UserImages.FromSchema(schema)
|
||||
UserImagesToProcess = UserImagesToProcess.FromSchema(schema)
|
||||
UserTags = UserTags.FromSchema(schema)
|
||||
Users = Users.FromSchema(schema)
|
||||
}
|
||||
|
@ -17,10 +17,9 @@ type userImagesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnString
|
||||
ImageName postgres.ColumnString
|
||||
Image postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
ID postgres.ColumnString
|
||||
ImageID postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@ -61,22 +60,20 @@ func newUserImagesTable(schemaName, tableName, alias string) *UserImagesTable {
|
||||
|
||||
func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable {
|
||||
var (
|
||||
IDColumn = postgres.StringColumn("id")
|
||||
ImageNameColumn = postgres.StringColumn("image_name")
|
||||
ImageColumn = postgres.StringColumn("image")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, ImageColumn, UserIDColumn}
|
||||
mutableColumns = postgres.ColumnList{ImageNameColumn, ImageColumn, UserIDColumn}
|
||||
IDColumn = postgres.StringColumn("id")
|
||||
ImageIDColumn = postgres.StringColumn("image_id")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn}
|
||||
mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn}
|
||||
)
|
||||
|
||||
return userImagesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
ImageName: ImageNameColumn,
|
||||
Image: ImageColumn,
|
||||
UserID: UserIDColumn,
|
||||
ID: IDColumn,
|
||||
ImageID: ImageIDColumn,
|
||||
UserID: UserIDColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
@ -0,0 +1,81 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var UserImagesToProcess = newUserImagesToProcessTable("haystack", "user_images_to_process", "")
|
||||
|
||||
type userImagesToProcessTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnString
|
||||
ImageID postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type UserImagesToProcessTable struct {
|
||||
userImagesToProcessTable
|
||||
|
||||
EXCLUDED userImagesToProcessTable
|
||||
}
|
||||
|
||||
// AS creates new UserImagesToProcessTable with assigned alias
|
||||
func (a UserImagesToProcessTable) AS(alias string) *UserImagesToProcessTable {
|
||||
return newUserImagesToProcessTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new UserImagesToProcessTable with assigned schema name
|
||||
func (a UserImagesToProcessTable) FromSchema(schemaName string) *UserImagesToProcessTable {
|
||||
return newUserImagesToProcessTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new UserImagesToProcessTable with assigned table prefix
|
||||
func (a UserImagesToProcessTable) WithPrefix(prefix string) *UserImagesToProcessTable {
|
||||
return newUserImagesToProcessTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new UserImagesToProcessTable with assigned table suffix
|
||||
func (a UserImagesToProcessTable) WithSuffix(suffix string) *UserImagesToProcessTable {
|
||||
return newUserImagesToProcessTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newUserImagesToProcessTable(schemaName, tableName, alias string) *UserImagesToProcessTable {
|
||||
return &UserImagesToProcessTable{
|
||||
userImagesToProcessTable: newUserImagesToProcessTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newUserImagesToProcessTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userImagesToProcessTable {
|
||||
var (
|
||||
IDColumn = postgres.StringColumn("id")
|
||||
ImageIDColumn = postgres.StringColumn("image_id")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn}
|
||||
mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn}
|
||||
)
|
||||
|
||||
return userImagesToProcessTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
ImageID: ImageIDColumn,
|
||||
UserID: UserIDColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
}
|
||||
}
|
3
backend/.gitignore
vendored
Normal file
3
backend/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
screenmark
|
||||
.env.docker
|
||||
.env
|
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM golang
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dependency management
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN go build -o /app/haystack
|
||||
|
||||
EXPOSE 3040
|
||||
|
||||
CMD ["/app/haystack"]
|
26
backend/README.md
Normal file
26
backend/README.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Running the backend
|
||||
|
||||
1. Create a `.env.docker` file, which must contain the following.
|
||||
|
||||
OPENAI_API_KEY=openai_key
|
||||
DB_CONNECTION=postgresql://postgres:password@database:5432/haystack_db?sslmode=disable
|
||||
|
||||
2. Use `docker-compose up` to spin up the containers.
|
||||
|
||||
You should be able to access the backend through port `3040`
|
||||
|
||||
# Methods
|
||||
|
||||
For now, we cheat and add a `userId` header which if os type `UUID`. Use the auto generated test one (fcc22dbb-7792-4595-be8e-d0439e13990a).
|
||||
|
||||
- `GET /image` | Returns all of the users image, including tags, links and text any image contains.
|
||||
- `GET /image/{imageId}` | Returns the actual image, use this to display images in the UI.
|
||||
- `POST /image/{imageNameWithExtension}` | Sends an image to the backend, saves it and sents it to open ai to later process.
|
||||
|
||||
# Architecture
|
||||
|
||||
1. The user posts an image, which gets saved on our database (all data, including images are saved on DB).
|
||||
2. We listen for table event creation, and we can process this image by sending it to OpenAI.
|
||||
3. After OpenAI responds, we write to the database.
|
||||
|
||||
This means that for now, we don't have a notification system to tell the user when their image is done processing. But will do in the future.
|
27
backend/docker-compose.yml
Normal file
27
backend/docker-compose.yml
Normal file
@ -0,0 +1,27 @@
|
||||
services:
|
||||
database:
|
||||
image: postgres
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
volumes:
|
||||
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||
ports:
|
||||
- 4321:5432
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d haystack_db"]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
timeout: 5s
|
||||
backend:
|
||||
build: .
|
||||
restart: always
|
||||
env_file: .env.docker
|
||||
ports:
|
||||
- 3040:3040
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
restart: true
|
@ -4,6 +4,7 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.1 // indirect
|
||||
github.com/go-jet/jet/v2 v2.12.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
|
@ -1,5 +1,7 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/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-jet/jet/v2 v2.12.0 h1:z2JfvBAZgsfxlQz6NXBYdZTXc7ep3jhbszTLtETv1JE=
|
||||
github.com/go-jet/jet/v2 v2.12.0/go.mod h1:ufQVRQeI1mbcO5R8uCEVcVf3Foej9kReBdwDx7YMWUM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
236
backend/main.go
236
backend/main.go
@ -1,29 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"screenmark/screenmark/models"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type TestAiClient struct {
|
||||
ImageInfo ImageInfo
|
||||
}
|
||||
|
||||
func (client TestAiClient) GetImageInfo(imageName string, imageData []byte) (ImageInfo, error) {
|
||||
return client.ImageInfo, nil
|
||||
}
|
||||
|
||||
func GetAiClient() (AiClient, error) {
|
||||
mode := os.Getenv("MODE")
|
||||
if mode == "TESTING" {
|
||||
return TestAiClient{
|
||||
ImageInfo: ImageInfo{
|
||||
Tags: []string{"tag"},
|
||||
Links: []string{"links"},
|
||||
Text: []string{"text"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return CreateOpenAiClient()
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mode := os.Getenv("MODE")
|
||||
log.Printf("Mode: %s\n", mode)
|
||||
|
||||
err = models.InitDatabase()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
listener := pq.NewListener(models.CONNECTION, time.Second, time.Second, func(event pq.ListenerEventType, err error) {
|
||||
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -36,42 +69,134 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case parameters := <-listener.Notify:
|
||||
log.Println("received notification, new image available: " + parameters.Extra)
|
||||
for {
|
||||
|
||||
go func() {
|
||||
openAiClient, err := CreateOpenAiClient()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
select {
|
||||
case parameters := <-listener.Notify:
|
||||
imageId := parameters.Extra
|
||||
|
||||
image, err := models.GetImage(parameters.Extra)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
log.Println("received notification, new image available: " + imageId)
|
||||
|
||||
imageInfo, err := openAiClient.GetImageInfo(image.ImageName, image.Image)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
openAiClient, err := GetAiClient()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log.Printf("Info: %+v\n", imageInfo)
|
||||
}()
|
||||
image, err := models.GetImageToProcessWithData(imageId)
|
||||
if err != nil {
|
||||
log.Println("1")
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
imageInfo, err := openAiClient.GetImageInfo(image.Image.ImageName, image.Image.Image)
|
||||
if err != nil {
|
||||
log.Println("2")
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
savedImage, err := models.SaveImage(image.ID)
|
||||
if err != nil {
|
||||
log.Println("3")
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Finished processing image " + imageId)
|
||||
log.Printf("Image attributes: %+v\n", imageInfo)
|
||||
|
||||
_, err = models.SaveImageTags(savedImage.ID.String(), imageInfo.Tags)
|
||||
if err != nil {
|
||||
log.Println("1")
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
_, err = models.SaveImageLinks(savedImage.ID.String(), imageInfo.Links)
|
||||
if err != nil {
|
||||
log.Println("2")
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
_, err = models.SaveImageTexts(savedImage.ID.String(), imageInfo.Text)
|
||||
if err != nil {
|
||||
log.Println("3")
|
||||
log.Println(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
r := chi.NewRouter()
|
||||
|
||||
mux.HandleFunc("OPTIONS /image/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
|
||||
r.Options("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Access-Control-Allow-Credentials", "*")
|
||||
w.Header().Add("Access-Control-Allow-Headers", "*")
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /image/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Get("/image", func(w http.ResponseWriter, r *http.Request) {
|
||||
userId := r.Header.Get("userId")
|
||||
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Access-Control-Allow-Credentials", "*")
|
||||
w.Header().Add("Access-Control-Allow-Headers", "*")
|
||||
|
||||
images, err := models.GetUserImages(userId)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
jsonImages, err := json.Marshal(images)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "Could not create JSON response for this image")
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(jsonImages)
|
||||
})
|
||||
|
||||
r.Get("/image/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
imageId := r.PathValue("id")
|
||||
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Access-Control-Allow-Credentials", "*")
|
||||
w.Header().Add("Access-Control-Allow-Headers", "*")
|
||||
|
||||
// TODO: really need authorization here!
|
||||
image, err := models.GetImage(imageId)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "Could not get image")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: this could be part of the db table
|
||||
extension := filepath.Ext(image.Image.ImageName)
|
||||
extension = extension[1:]
|
||||
|
||||
w.Header().Add("Content-Type", "image/"+extension)
|
||||
w.Write(image.Image.Image)
|
||||
})
|
||||
|
||||
r.Post("/image/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||
imageName := r.PathValue("name")
|
||||
|
||||
userId := r.Header.Get("userId")
|
||||
@ -86,24 +211,79 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
image, err := io.ReadAll(r.Body)
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
|
||||
log.Println(contentType)
|
||||
|
||||
// TODO: length checks on body
|
||||
// TODO: extract this shit out
|
||||
image := make([]byte, 0)
|
||||
if contentType == "application/base64" {
|
||||
decoder := base64.NewDecoder(base64.StdEncoding, r.Body)
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
decodedIamge, err := io.Copy(buf, decoder)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "bruh, base64 aint decoding")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(string(image))
|
||||
fmt.Println(decodedIamge)
|
||||
|
||||
image = buf.Bytes()
|
||||
} else if contentType == "application/oclet-stream" {
|
||||
bodyData, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "bruh, binary aint binaring")
|
||||
return
|
||||
}
|
||||
// TODO: check headers
|
||||
|
||||
image = bodyData
|
||||
} else {
|
||||
log.Println("bad stuff?")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "Bruh, you need oclet stream or base64")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Println("First case")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "Couldnt read the image from the request body")
|
||||
return
|
||||
}
|
||||
|
||||
err = models.SaveImage(userId, imageName, image)
|
||||
userImage, err := models.SaveImageToProcess(userId, imageName, image)
|
||||
if err != nil {
|
||||
log.Println("Second case")
|
||||
log.Println(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "Could not save image to DB")
|
||||
return
|
||||
}
|
||||
|
||||
jsonUserImage, err := json.Marshal(userImage)
|
||||
if err != nil {
|
||||
log.Println("Third case")
|
||||
log.Println(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "Could not create JSON response for this image")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
fmt.Fprint(w, string(jsonUserImage))
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
})
|
||||
|
||||
log.Println("Listening and serving.")
|
||||
log.Println("Listening and serving on port 3040.")
|
||||
|
||||
http.ListenAndServe(":3040", mux)
|
||||
http.ListenAndServe(":3040", r)
|
||||
}
|
||||
|
@ -2,16 +2,22 @@ package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
const CONNECTION = "postgresql://localhost:5432/haystack?sslmode=disable"
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
func InitDatabase() error {
|
||||
database, err := sql.Open("postgres", CONNECTION)
|
||||
connection := os.Getenv("DB_CONNECTION")
|
||||
|
||||
if len(connection) == 0 {
|
||||
return errors.New("DB_CONNECTION env was not found.")
|
||||
}
|
||||
|
||||
database, err := sql.Open("postgres", connection)
|
||||
|
||||
db = database
|
||||
|
||||
|
@ -3,31 +3,234 @@ package models
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
. "github.com/go-jet/jet/v2/postgres"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
. "screenmark/screenmark/.gen/haystack/haystack/table"
|
||||
|
||||
. "github.com/go-jet/jet/v2/postgres"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func SaveImage(userId string, imageName string, imageData []byte) error {
|
||||
stmt := UserImages.INSERT(UserImages.UserID, UserImages.ImageName, UserImages.Image).VALUES(userId, imageName, imageData)
|
||||
func SaveImageToProcess(userId string, imageName string, imageData []byte) (model.UserImagesToProcess, error) {
|
||||
insertImageStmt := Image.INSERT(Image.ImageName, Image.Image).VALUES(imageName, imageData).RETURNING(Image.ID)
|
||||
|
||||
// TODO: should be a transaction
|
||||
|
||||
image := model.Image{}
|
||||
err := insertImageStmt.Query(db, &image)
|
||||
if err != nil {
|
||||
return model.UserImagesToProcess{}, err
|
||||
}
|
||||
|
||||
stmt := UserImagesToProcess.INSERT(UserImagesToProcess.UserID, UserImagesToProcess.ImageID).VALUES(userId, image.ID).RETURNING(UserImagesToProcess.AllColumns)
|
||||
|
||||
fmt.Println(stmt.DebugSql())
|
||||
|
||||
userImage := model.UserImagesToProcess{}
|
||||
err = stmt.Query(db, &userImage)
|
||||
|
||||
return userImage, err
|
||||
}
|
||||
|
||||
func removeImageToProcess(imageId string) error {
|
||||
id := uuid.MustParse(imageId)
|
||||
|
||||
stmt := UserImagesToProcess.DELETE().WHERE(UserImagesToProcess.ID.EQ(UUID(id)))
|
||||
|
||||
_, err := stmt.Exec(db)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func GetImage(imageId string) (model.UserImages, error) {
|
||||
id := uuid.MustParse(imageId)
|
||||
stmt := UserImages.SELECT(UserImages.ImageName, UserImages.Image).WHERE(UserImages.ID.EQ(UUID(id)))
|
||||
func getUserId(imageId uuid.UUID) (uuid.UUID, error) {
|
||||
stmt := UserImages.SELECT(UserImages.UserID).WHERE(UserImages.ID.EQ(UUID(imageId)))
|
||||
|
||||
images := []model.UserImages{}
|
||||
fmt.Println(stmt.DebugSql())
|
||||
|
||||
userIds := make([]string, 0)
|
||||
|
||||
err := stmt.Query(db, &userIds)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
if len(userIds) != 1 {
|
||||
return uuid.Nil, errors.New("expect only one user id per image id")
|
||||
}
|
||||
|
||||
return uuid.Parse(userIds[0])
|
||||
}
|
||||
|
||||
func SaveImage(imageId uuid.UUID) (model.UserImages, error) {
|
||||
imageToProcess, err := GetImageToProcess(imageId.String())
|
||||
if err != nil {
|
||||
return model.UserImages{}, err
|
||||
}
|
||||
|
||||
stmt := UserImages.INSERT(UserImages.UserID, UserImages.ImageID).VALUES(imageToProcess.UserID, imageToProcess.ImageID).RETURNING(UserImages.ID, UserImages.UserID, UserImages.ImageID)
|
||||
|
||||
userImage := model.UserImages{}
|
||||
err = stmt.Query(db, &userImage)
|
||||
if err != nil {
|
||||
return model.UserImages{}, err
|
||||
}
|
||||
|
||||
err = removeImageToProcess(imageId.String())
|
||||
if err != nil {
|
||||
return model.UserImages{}, err
|
||||
}
|
||||
|
||||
return userImage, err
|
||||
}
|
||||
|
||||
type ImageData struct {
|
||||
model.UserImages
|
||||
|
||||
Image model.Image
|
||||
}
|
||||
|
||||
func GetImage(imageId string) (ImageData, error) {
|
||||
id := uuid.MustParse(imageId)
|
||||
stmt := SELECT(UserImages.AllColumns, Image.AllColumns).FROM(UserImages.INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID))).WHERE(UserImages.ID.EQ(UUID(id)))
|
||||
|
||||
images := []ImageData{}
|
||||
err := stmt.Query(db, &images)
|
||||
|
||||
if len(images) != 1 {
|
||||
return model.UserImages{}, errors.New(fmt.Sprintf("Expected 1, got %d\n", len(images)))
|
||||
return ImageData{}, errors.New(fmt.Sprintf("Expected 1, got %d\n", len(images)))
|
||||
}
|
||||
|
||||
return images[0], err
|
||||
}
|
||||
|
||||
type ImageToProcessData struct {
|
||||
model.UserImagesToProcess
|
||||
|
||||
Image model.Image
|
||||
}
|
||||
|
||||
func GetImageToProcessWithData(imageId string) (ImageToProcessData, error) {
|
||||
id := uuid.MustParse(imageId)
|
||||
// stmt := UserImagesToProcess.SELECT(UserImages.AllColumns).WHERE(UserImages.ID.EQ(UUID(id)))
|
||||
|
||||
// TODO: Image should be `Images`
|
||||
stmt := SELECT(UserImagesToProcess.AllColumns, Image.AllColumns).FROM(UserImagesToProcess.INNER_JOIN(Image, Image.ID.EQ(UserImagesToProcess.ImageID))).WHERE(UserImagesToProcess.ID.EQ(UUID(id)))
|
||||
|
||||
images := []ImageToProcessData{}
|
||||
err := stmt.Query(db, &images)
|
||||
|
||||
if len(images) != 1 {
|
||||
return ImageToProcessData{}, errors.New(fmt.Sprintf("Expected 1, got %d\n", len(images)))
|
||||
}
|
||||
|
||||
return images[0], err
|
||||
}
|
||||
|
||||
func GetImageToProcess(imageId string) (model.UserImagesToProcess, error) {
|
||||
id := uuid.MustParse(imageId)
|
||||
stmt := UserImagesToProcess.SELECT(UserImagesToProcess.AllColumns).WHERE(UserImagesToProcess.ID.EQ(UUID(id)))
|
||||
|
||||
images := []model.UserImagesToProcess{}
|
||||
err := stmt.Query(db, &images)
|
||||
|
||||
if len(images) != 1 {
|
||||
return model.UserImagesToProcess{}, errors.New(fmt.Sprintf("Expected 1, got %d\n", len(images)))
|
||||
}
|
||||
|
||||
return images[0], err
|
||||
}
|
||||
|
||||
type UserImagesWithInfo struct {
|
||||
ID uuid.UUID
|
||||
|
||||
// TODO: this shit
|
||||
Image model.Image
|
||||
|
||||
Tags []model.ImageTags
|
||||
Links []model.ImageLinks
|
||||
Text []model.ImageText
|
||||
}
|
||||
|
||||
func GetUserImages(userId string) ([]UserImagesWithInfo, error) {
|
||||
id := uuid.MustParse(userId)
|
||||
stmt := SELECT(UserImages.ID.AS("UserImagesWithInfo.ID"), Image.ID, Image.ImageName, ImageTags.AllColumns, ImageText.AllColumns, ImageLinks.AllColumns).FROM(UserImages.INNER_JOIN(Image, Image.ID.EQ(UserImages.ImageID)).LEFT_JOIN(ImageTags, ImageTags.ImageID.EQ(UserImages.ID)).LEFT_JOIN(ImageText, ImageText.ImageID.EQ(UserImages.ID)).LEFT_JOIN(ImageLinks, ImageLinks.ImageID.EQ(UserImages.ID))).WHERE(UserImages.UserID.EQ(UUID(id)))
|
||||
|
||||
images := []UserImagesWithInfo{}
|
||||
err := stmt.Query(db, &images)
|
||||
|
||||
return images, err
|
||||
}
|
||||
|
||||
func SaveImageTags(imageId string, tags []string) ([]model.ImageTags, error) {
|
||||
id := uuid.MustParse(imageId)
|
||||
|
||||
userId, err := getUserId(id)
|
||||
if err != nil {
|
||||
return []model.ImageTags{}, err
|
||||
}
|
||||
|
||||
err = CreateTags(userId, tags)
|
||||
if err != nil {
|
||||
return []model.ImageTags{}, err
|
||||
}
|
||||
|
||||
userTagsExpression := make([]Expression, 0)
|
||||
for _, tag := range tags {
|
||||
userTagsExpression = append(userTagsExpression, String(tag))
|
||||
}
|
||||
|
||||
userTags := make([]model.UserTags, 0)
|
||||
|
||||
getTagsStmt := UserTags.SELECT(UserTags.ID, UserTags.Tag).WHERE(UserTags.Tag.IN(userTagsExpression...))
|
||||
err = getTagsStmt.Query(db, &userTags)
|
||||
if err != nil {
|
||||
return []model.ImageTags{}, err
|
||||
}
|
||||
|
||||
stmt := ImageTags.INSERT(ImageTags.ImageID, ImageTags.TagID)
|
||||
|
||||
for _, t := range userTags {
|
||||
stmt = stmt.VALUES(id, t.ID)
|
||||
}
|
||||
|
||||
stmt.RETURNING(ImageTags.AllColumns)
|
||||
|
||||
imageTags := make([]model.ImageTags, 0)
|
||||
err = stmt.Query(db, &imageTags)
|
||||
|
||||
return imageTags, err
|
||||
}
|
||||
|
||||
func SaveImageLinks(imageId string, links []string) ([]model.ImageLinks, error) {
|
||||
id := uuid.MustParse(imageId)
|
||||
|
||||
stmt := ImageLinks.INSERT(ImageLinks.ImageID, ImageLinks.Link)
|
||||
|
||||
for _, t := range links {
|
||||
stmt = stmt.VALUES(id, t)
|
||||
}
|
||||
|
||||
stmt.RETURNING(ImageLinks.AllColumns)
|
||||
|
||||
imageLinks := []model.ImageLinks{}
|
||||
err := stmt.Query(db, &imageLinks)
|
||||
|
||||
return imageLinks, err
|
||||
}
|
||||
|
||||
func SaveImageTexts(imageId string, texts []string) ([]model.ImageText, error) {
|
||||
id := uuid.MustParse(imageId)
|
||||
|
||||
stmt := ImageText.INSERT(ImageText.ImageID, ImageText.ImageText)
|
||||
|
||||
for _, t := range texts {
|
||||
stmt = stmt.VALUES(id, t)
|
||||
}
|
||||
|
||||
stmt.RETURNING(ImageText.AllColumns)
|
||||
|
||||
imageTags := []model.ImageText{}
|
||||
err := stmt.Query(db, &imageTags)
|
||||
|
||||
return imageTags, err
|
||||
}
|
||||
|
116
backend/models/tags.go
Normal file
116
backend/models/tags.go
Normal file
@ -0,0 +1,116 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
. "screenmark/screenmark/.gen/haystack/haystack/table"
|
||||
|
||||
. "github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Raw dogging SQL is kinda based though?
|
||||
//
|
||||
// | nO, usE OrM!!
|
||||
//
|
||||
// | RAW - RAW
|
||||
// | SQL | \ SQL
|
||||
// | GOOD | \ GOOD
|
||||
// | - -
|
||||
// | -- --
|
||||
// | -- --
|
||||
// | ---- IQ ----
|
||||
func getNonExistantTags(userId uuid.UUID, tags []string) ([]string, error) {
|
||||
values := ""
|
||||
counter := 1
|
||||
// big big SQL injection problem here?
|
||||
for counter = 1; counter <= len(tags); counter++ {
|
||||
values += fmt.Sprintf("($%d),", counter)
|
||||
}
|
||||
values = values[0 : len(values)-1]
|
||||
|
||||
/*
|
||||
WITH given_tags
|
||||
AS (SELECT given_tags.tag FROM (VALUES ('c')) AS given_tags (tag)),
|
||||
this_user_tags as (
|
||||
SELECT id, tag
|
||||
FROM haystack.user_tags
|
||||
where user_tags.user_id = 'fcc22dbb-7792-4595-be8e-d0439e13990a'
|
||||
)
|
||||
select given_tags.tag from given_tags
|
||||
LEFT OUTER JOIN this_user_tags ON this_user_tags.tag = given_tags.tag
|
||||
where this_user_tags.tag is null;
|
||||
*/
|
||||
|
||||
withStuff := fmt.Sprintf(`WITH given_tags
|
||||
AS (SELECT given_tags.tag FROM (VALUES `+values+`) AS given_tags (tag)),
|
||||
this_user_tags AS
|
||||
(SELECT id, tag FROM haystack.user_tags WHERE user_tags.user_id = $%d)
|
||||
SELECT given_tags.tag
|
||||
FROM given_tags
|
||||
LEFT OUTER JOIN haystack.user_tags ON haystack.user_tags.tag = given_tags.tag
|
||||
where user_tags.tag is null`, counter)
|
||||
|
||||
stmt, err := db.Prepare(withStuff)
|
||||
fmt.Println(withStuff)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("failing to prepare stmt")
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
defer stmt.Close()
|
||||
|
||||
args := make([]any, counter)
|
||||
for i, v := range tags {
|
||||
args[i] = v
|
||||
}
|
||||
args[counter-1] = userId.String()
|
||||
|
||||
rows, err := stmt.Query(args...)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
nonExistantTags := make([]string, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var tag string
|
||||
rows.Scan(&tag)
|
||||
|
||||
nonExistantTags = append(nonExistantTags, tag)
|
||||
}
|
||||
|
||||
return nonExistantTags, nil
|
||||
}
|
||||
|
||||
func CreateTags(userId uuid.UUID, tags []string) error {
|
||||
tagsToInsert, err := getNonExistantTags(userId, tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tagsToInsert) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt := UserTags.INSERT(UserTags.UserID, UserTags.Tag)
|
||||
|
||||
for _, tag := range tagsToInsert {
|
||||
stmt = stmt.VALUES(UUID(userId), tag)
|
||||
}
|
||||
|
||||
_, err = stmt.Exec(db)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func GetTags(userId uuid.UUID) ([]model.UserTags, error) {
|
||||
stmt := UserTags.SELECT(UserTags.AllColumns).WHERE(UserTags.UserID.EQ(UUID(userId)))
|
||||
|
||||
userTags := []model.UserTags{}
|
||||
|
||||
err := stmt.Query(db, &userTags)
|
||||
|
||||
return userTags, err
|
||||
}
|
@ -73,9 +73,7 @@ func (content *OpenAiMessages) AddImage(imageName string, image []byte) error {
|
||||
arrayMessage := OpenAiArrayMessage{Role: ROLE_USER, Content: make([]OpenAiContent, 1)}
|
||||
arrayMessage.Content[0] = OpenAiImage{
|
||||
ImageType: IMAGE_TYPE,
|
||||
ImageUrl: ImageUrl{
|
||||
Url: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
|
||||
},
|
||||
ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
|
||||
}
|
||||
|
||||
content.Messages = append(content.Messages, arrayMessage)
|
||||
@ -105,8 +103,8 @@ type ImageUrl struct {
|
||||
}
|
||||
|
||||
type OpenAiImage struct {
|
||||
ImageType string `json:"type"`
|
||||
ImageUrl ImageUrl `json:"image_url"`
|
||||
ImageType string `json:"type"`
|
||||
ImageUrl string `json:"image_url"`
|
||||
}
|
||||
|
||||
func (imageMessage OpenAiImage) ToJson() ([]byte, error) {
|
||||
@ -114,6 +112,10 @@ func (imageMessage OpenAiImage) ToJson() ([]byte, error) {
|
||||
return json.Marshal(imageMessage)
|
||||
}
|
||||
|
||||
type AiClient interface {
|
||||
GetImageInfo(imageName string, imageData []byte) (ImageInfo, error)
|
||||
}
|
||||
|
||||
type OpenAiClient struct {
|
||||
url string
|
||||
apiKey string
|
||||
@ -137,8 +139,8 @@ const IMAGE_TYPE = "image_url"
|
||||
const PROMPT = `
|
||||
You are an image information extractor. The user will provide you with screenshots and your job is to extract any relevant links and text
|
||||
that the image might contain. You will also try your best to assign some tags to this image, avoid too many tags.
|
||||
|
||||
This system is part of a bookmark manager, who's main goal is to allow the user to search through various screenshots.
|
||||
Be sure to extract every link (URL) that you find.
|
||||
Use generic tags.
|
||||
`
|
||||
|
||||
const RESPONSE_FORMAT = `
|
||||
@ -189,7 +191,7 @@ func CreateOpenAiClient() (OpenAiClient, error) {
|
||||
|
||||
return OpenAiClient{
|
||||
apiKey: apiKey,
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
url: "https://api.mistral.ai/v1/chat/completions",
|
||||
systemPrompt: PROMPT,
|
||||
Do: func(req *http.Request) (*http.Response, error) {
|
||||
client := &http.Client{}
|
||||
@ -210,10 +212,14 @@ func (client OpenAiClient) getRequest(body []byte) (*http.Request, error) {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func getCompletionsForImage(model string, temperature float64, prompt, imageName string, imageData []byte) (OpenAiRequestBody, error) {
|
||||
func getCompletionsForImage(model string, temperature float64, prompt string, imageName string, jsonSchema string, imageData []byte) (OpenAiRequestBody, error) {
|
||||
request := OpenAiRequestBody{
|
||||
Model: model,
|
||||
Temperature: temperature,
|
||||
ResponseFormat: ResponseFormat{
|
||||
Type: "json_schema",
|
||||
JsonSchema: jsonSchema,
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: Add build pattern here that deals with errors in some internal state?
|
||||
@ -231,8 +237,49 @@ func getCompletionsForImage(model string, temperature float64, prompt, imageName
|
||||
return request, nil
|
||||
}
|
||||
|
||||
type ResponseChoiceMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ResponseChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message ResponseChoiceMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type OpenAiResponse struct {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Choices []ResponseChoice `json:"choices"`
|
||||
Created int `json:"created"`
|
||||
}
|
||||
|
||||
// TODO: add usage parsing
|
||||
func parseOpenAiResponse(jsonResponse []byte) (ImageInfo, error) {
|
||||
response := OpenAiResponse{}
|
||||
|
||||
err := json.Unmarshal(jsonResponse, &response)
|
||||
if err != nil {
|
||||
return ImageInfo{}, err
|
||||
}
|
||||
|
||||
if len(response.Choices) != 1 {
|
||||
log.Println(string(jsonResponse))
|
||||
return ImageInfo{}, errors.New("Expected exactly one choice.")
|
||||
}
|
||||
|
||||
imageInfo := ImageInfo{}
|
||||
err = json.Unmarshal([]byte(response.Choices[0].Message.Content), &imageInfo)
|
||||
if err != nil {
|
||||
return ImageInfo{}, errors.New("Could not parse content into image type.")
|
||||
}
|
||||
|
||||
return imageInfo, nil
|
||||
}
|
||||
|
||||
func (client OpenAiClient) GetImageInfo(imageName string, imageData []byte) (ImageInfo, error) {
|
||||
aiRequest, err := getCompletionsForImage("gpt-4o-mini", 1.0, client.systemPrompt, imageName, imageData)
|
||||
aiRequest, err := getCompletionsForImage("pixtral-12b-2409", 1.0, client.systemPrompt, imageName, RESPONSE_FORMAT, imageData)
|
||||
if err != nil {
|
||||
return ImageInfo{}, err
|
||||
}
|
||||
@ -268,13 +315,5 @@ func (client OpenAiClient) GetImageInfo(imageName string, imageData []byte) (Ima
|
||||
return ImageInfo{}, err
|
||||
}
|
||||
|
||||
info := ImageInfo{}
|
||||
err = json.Unmarshal(response, &info)
|
||||
if err != nil {
|
||||
return ImageInfo{}, err
|
||||
}
|
||||
|
||||
log.Println(string(response))
|
||||
|
||||
return info, nil
|
||||
return parseOpenAiResponse(response)
|
||||
}
|
||||
|
@ -89,7 +89,7 @@ func TestMessageBuilderImage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFullImageRequest(t *testing.T) {
|
||||
request, err := getCompletionsForImage("model", 0.1, "You are an assistant", "image.png", []byte("some data"))
|
||||
request, err := getCompletionsForImage("model", 0.1, "You are an assistant", "image.png", "", []byte("some data"))
|
||||
if err != nil {
|
||||
t.Log(request)
|
||||
t.FailNow()
|
||||
@ -101,7 +101,7 @@ func TestFullImageRequest(t *testing.T) {
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
expectedJson := `{"model":"model","temperature":0.1,"response_format":{"type":"","json_schema":""},"messages":[{"role":"system","content":"You are an assistant"},{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,c29tZSBkYXRh"}}]}]}`
|
||||
expectedJson := `{"model":"model","temperature":0.1,"response_format":{"type":"json_schema","json_schema":""},"messages":[{"role":"system","content":"You are an assistant"},{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,c29tZSBkYXRh"}}]}]}`
|
||||
|
||||
if string(jsonData) != expectedJson {
|
||||
t.Logf("Expected:\n%s\n Got:\n%s\n", expectedJson, string(jsonData))
|
||||
@ -149,3 +149,65 @@ func TestResponse(t *testing.T) {
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseParsing(t *testing.T) {
|
||||
response := `{
|
||||
"id": "chatcmpl-B4XgiHcd7A2nyK7eyARdggSvfFuWQ",
|
||||
"object": "chat.completion",
|
||||
"created": 1740422508,
|
||||
"model": "gpt-4o-mini-2024-07-18",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "{\"links\":[\"link\"],\"tags\":[\"tag\"],\"text\":[\"text\"]}",
|
||||
"refusal": null
|
||||
},
|
||||
"logprobs": null,
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 775,
|
||||
"completion_tokens": 33,
|
||||
"total_tokens": 808,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0,
|
||||
"audio_tokens": 0
|
||||
},
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 0,
|
||||
"audio_tokens": 0,
|
||||
"accepted_prediction_tokens": 0,
|
||||
"rejected_prediction_tokens": 0
|
||||
}
|
||||
},
|
||||
"service_tier": "default",
|
||||
"system_fingerprint": "fp_7fcd609668"
|
||||
}`
|
||||
|
||||
imageParsed, err := parseOpenAiResponse([]byte(response))
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
if len(imageParsed.Links) != 1 || imageParsed.Links[0] != "link" {
|
||||
t.Log(imageParsed)
|
||||
t.Log("Should have one link called 'link'.")
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
if len(imageParsed.Tags) != 1 || imageParsed.Tags[0] != "tag" {
|
||||
t.Log(imageParsed)
|
||||
t.Log("Should have one tag called 'tag'.")
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
if len(imageParsed.Text) != 1 || imageParsed.Text[0] != "text" {
|
||||
t.Log(imageParsed)
|
||||
t.Log("Should have one text called 'text'.")
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,6 @@
|
||||
DROP DATABASE IF EXISTS haystack_db;
|
||||
CREATE DATABASE haystack_db;
|
||||
|
||||
DROP SCHEMA IF EXISTS haystack CASCADE;
|
||||
|
||||
CREATE SCHEMA haystack;
|
||||
@ -8,21 +11,33 @@ CREATE TABLE haystack.users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.user_images (
|
||||
CREATE TABLE haystack.image (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
image_name TEXT NOT NULL,
|
||||
image BYTEA NOT NULL,
|
||||
image BYTEA NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.user_images_to_process (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
image_id uuid NOT NULL UNIQUE REFERENCES haystack.image (id),
|
||||
user_id uuid NOT NULL REFERENCES haystack.users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.user_images (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
image_id uuid NOT NULL UNIQUE REFERENCES haystack.image (id),
|
||||
user_id uuid NOT NULL REFERENCES haystack.users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.user_tags (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tag TEXT NOT NULL,
|
||||
tag VARCHAR(32) UNIQUE NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES haystack.users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.image_tags (
|
||||
tag_id UUID NOT NULL REFERENCES haystack.user_tags (id),
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tag_id UUID NOT NULL REFERENCES haystack.user_tags (id),
|
||||
image_id UUID NOT NULL REFERENCES haystack.user_images (id)
|
||||
);
|
||||
|
||||
@ -38,6 +53,10 @@ CREATE TABLE haystack.image_links (
|
||||
image_id UUID NOT NULL REFERENCES haystack.user_images (id)
|
||||
);
|
||||
|
||||
/* -----| Indexes |----- */
|
||||
|
||||
CREATE INDEX user_tags_index ON haystack.user_tags(tag);
|
||||
|
||||
/* -----| Stored Procedures |----- */
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_new_image()
|
||||
@ -51,6 +70,10 @@ $$ LANGUAGE plpgsql;
|
||||
/* -----| Triggers |----- */
|
||||
|
||||
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
|
||||
ON haystack.user_images
|
||||
ON haystack.user_images_to_process
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE notify_new_image()
|
||||
EXECUTE PROCEDURE notify_new_image();
|
||||
|
||||
/* -----| Test Data |----- */
|
||||
|
||||
INSERT INTO haystack.users VALUES ('fcc22dbb-7792-4595-be8e-d0439e13990a');
|
||||
|
@ -1,12 +1,12 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
@ -1,38 +1,41 @@
|
||||
{
|
||||
"name": "haystack",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"lint": "bunx @biomejs/biome lint .",
|
||||
"format": "bunx @biomejs/biome format . --write"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.9",
|
||||
"@kobalte/tailwindcss": "^0.9.0",
|
||||
"@tabler/icons-solidjs": "^3.30.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"clsx": "^2.1.1",
|
||||
"solid-js": "^1.9.3",
|
||||
"tailwind-scrollbar-hide": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"tailwindcss": "3.4.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-solid": "^2.11.0"
|
||||
}
|
||||
"name": "haystack",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"lint": "bunx @biomejs/biome lint .",
|
||||
"format": "bunx @biomejs/biome format . --write"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.9",
|
||||
"@kobalte/tailwindcss": "^0.9.0",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@tabler/icons-solidjs": "^3.30.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"solid-js": "^1.9.3",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"valibot": "^1.0.0-rc.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"tailwindcss": "3.4.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-solid": "^2.11.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
@ -1,12 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
|
@ -116,7 +116,7 @@ pub fn run() {
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.hidden_title(true)
|
||||
.inner_size(480.0, 360.0)
|
||||
.resizable(false);
|
||||
.resizable(true);
|
||||
// set transparent title bar only when building for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
let win_builder = win_builder.title_bar_style(TitleBarStyle::Transparent);
|
||||
|
@ -1,30 +1,30 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "haystack",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.haystack.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"macOSPrivateApi": true,
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "haystack",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.haystack.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"macOSPrivateApi": true,
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -1,108 +1,132 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { createEffect, createResource, createSignal, For } from "solid-js";
|
||||
import { Search } from "@kobalte/core/search";
|
||||
import { IconSearch, IconRefresh } from "@tabler/icons-solidjs";
|
||||
import clsx from "clsx";
|
||||
import { ImageViewer } from "./components/ImageViewer";
|
||||
import { getUserImages } from "./network";
|
||||
import { image } from "@tauri-apps/api";
|
||||
import { A, useNavigate } from "@solidjs/router";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
type Emoji = {
|
||||
emoji: string;
|
||||
name: string;
|
||||
};
|
||||
type UserImages = Awaited<ReturnType<typeof getUserImages>>;
|
||||
|
||||
function App() {
|
||||
const [options, setOptions] = createSignal<Emoji[]>([]);
|
||||
const [emoji, setEmoji] = createSignal<Emoji | null>(null);
|
||||
const [searchResults, setSearchResults] = createSignal<UserImages[number]['Text']>([]);
|
||||
|
||||
const emojiData: Emoji[] = [
|
||||
{ emoji: "😀", name: "Grinning Face" },
|
||||
{ emoji: "😃", name: "Grinning Face with Big Eyes" },
|
||||
{ emoji: "😄", name: "Grinning Face with Smiling Eyes" },
|
||||
{ emoji: "😁", name: "Beaming Face with Smiling Eyes" },
|
||||
{ emoji: "😆", name: "Grinning Squinting Face" },
|
||||
];
|
||||
const [images] = createResource(getUserImages);
|
||||
|
||||
const queryEmojiData = (query: string) => {
|
||||
return emojiData.filter((emoji) =>
|
||||
emoji.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
};
|
||||
const nav = useNavigate();
|
||||
|
||||
return (
|
||||
<main class="container pt-2">
|
||||
<div class="px-4">
|
||||
<Search
|
||||
triggerMode="focus"
|
||||
options={options()}
|
||||
onInputChange={(query) => setOptions(queryEmojiData(query))}
|
||||
onChange={(result) => setEmoji(result)}
|
||||
optionValue="name"
|
||||
optionLabel="name"
|
||||
placeholder="Search for stuff..."
|
||||
itemComponent={(props) => (
|
||||
<Search.Item
|
||||
item={props.item}
|
||||
class={clsx(
|
||||
"text-2xl leading-none text-gray-900 rounded-md p-2 select-none outline-none grid justify-items-center w-[calc(20%-5px)] box-border",
|
||||
"hover:bg-gray-100 ui-highlighted:bg-gray-100 ui-highlighted:shadow-[inset_0_0_0_2px_rgb(2,132,199)] ui-disabled:text-gray-400 ui-disabled:opacity-50 ui-disabled:pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Search.ItemLabel class="mx-[-100px]">
|
||||
{props.item.rawValue.emoji}
|
||||
</Search.ItemLabel>
|
||||
</Search.Item>
|
||||
)}
|
||||
>
|
||||
<Search.Control
|
||||
class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-gray-200 text-gray-900 transition-colors duration-250 ui-invalid:border-red-500 ui-invalid:text-red-500"
|
||||
aria-label="Emoji"
|
||||
>
|
||||
<Search.Indicator
|
||||
class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-l-md px-2.5 text-gray-900 text-base leading-none transition-colors duration-250"
|
||||
loadingComponent={
|
||||
<Search.Icon class="h-5 w-5 grid justify-items-center flex-none">
|
||||
<IconRefresh size={20} class="m-auto animate-spin" />
|
||||
</Search.Icon>
|
||||
}
|
||||
>
|
||||
<Search.Icon class="h-5 w-5 grid justify-items-center flex-none">
|
||||
<IconSearch class="m-auto size-5 text-gray-600" />
|
||||
</Search.Icon>
|
||||
</Search.Indicator>
|
||||
<Search.Input class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600" />
|
||||
</Search.Control>
|
||||
<Search.Portal>
|
||||
<Search.Content
|
||||
class="bg-white rounded-md border border-gray-200 shadow-md origin-[var(--kb-search-content-transform-origin)] w-[var(--kb-popper-anchor-width)] data-[expanded]:animate-contentShow"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Search.Listbox class="overflow-y-auto max-h-[360px] p-2 flex flex-row justify-start flex-wrap gap-1.5 leading-none focus:outline-none" />
|
||||
<Search.NoResult class="text-center p-2 pb-6 m-auto text-gray-600">
|
||||
😬 No emoji found
|
||||
</Search.NoResult>
|
||||
</Search.Content>
|
||||
</Search.Portal>
|
||||
</Search>
|
||||
</div>
|
||||
{/* <div class="mt-4 text-base leading-none">
|
||||
let fuze = new Fuse<NonNullable<UserImages[number]['Text']>[number]>([], { keys: ["Text.ImageText"] });
|
||||
|
||||
// TODO: there's probably a better way?
|
||||
createEffect(() => {
|
||||
const userImages = images();
|
||||
if (userImages == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageText = userImages.flatMap(i => i.Text ?? []);
|
||||
|
||||
fuze = new Fuse(imageText, { keys: ["ImageText"], threshold: 0.3 });
|
||||
});
|
||||
|
||||
const onInputChange = (query: string) => {
|
||||
// TODO: we can migrate this searching to Rust, so we don't abuse the main thread.
|
||||
// But, it's not too bad as is.
|
||||
setSearchResults(fuze.search(query).flatMap(s => s.item));
|
||||
}
|
||||
|
||||
return (
|
||||
<main class="container pt-2">
|
||||
<div class="px-4">
|
||||
<Search
|
||||
triggerMode="focus"
|
||||
options={searchResults() ?? []}
|
||||
onInputChange={onInputChange}
|
||||
onChange={(item) => {
|
||||
if (item?.ImageID == null) {
|
||||
console.error("ImageID was null");
|
||||
return;
|
||||
}
|
||||
|
||||
nav(`/image/${item.ImageID}`);
|
||||
}}
|
||||
optionValue="ID"
|
||||
optionLabel="ImageText"
|
||||
placeholder="Search for stuff..."
|
||||
itemComponent={(props) => (
|
||||
<Search.Item
|
||||
item={props.item}
|
||||
class={clsx(
|
||||
"text-2xl leading-none text-gray-900 rounded-md p-2 select-none outline-none grid justify-items-center w-full box-border",
|
||||
"hover:bg-gray-100 ui-highlighted:bg-gray-100 ui-highlighted:shadow-[inset_0_0_0_2px_rgb(2,132,199)] ui-disabled:text-gray-400 ui-disabled:opacity-50 ui-disabled:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Search.ItemLabel class="mx-[-100px]">
|
||||
{props.item.rawValue.ImageText ?? ''}
|
||||
</Search.ItemLabel>
|
||||
</Search.Item>
|
||||
)}
|
||||
>
|
||||
<Search.Control
|
||||
class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-gray-200 text-gray-900 transition-colors duration-250 ui-invalid:border-red-500 ui-invalid:text-red-500"
|
||||
aria-label="Emoji"
|
||||
>
|
||||
<Search.Indicator
|
||||
class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-l-md px-2.5 text-gray-900 text-base leading-none transition-colors duration-250"
|
||||
loadingComponent={
|
||||
<Search.Icon class="h-5 w-5 grid justify-items-center flex-none">
|
||||
<IconRefresh size={20} class="m-auto animate-spin" />
|
||||
</Search.Icon>
|
||||
}
|
||||
>
|
||||
<Search.Icon class="h-5 w-5 grid justify-items-center flex-none">
|
||||
<IconSearch class="m-auto size-5 text-gray-600" />
|
||||
</Search.Icon>
|
||||
</Search.Indicator>
|
||||
<Search.Input class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600" />
|
||||
</Search.Control>
|
||||
<Search.Portal>
|
||||
<Search.Content
|
||||
class="bg-white rounded-md border border-gray-200 shadow-md origin-[var(--kb-search-content-transform-origin)] w-[var(--kb-popper-anchor-width)] data-[expanded]:animate-contentShow"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Search.Listbox class="overflow-y-auto max-h-[360px] p-2 flex flex-col justify-start gap-1.5 leading-none focus:outline-none" />
|
||||
<Search.NoResult class="text-center p-2 pb-6 m-auto text-gray-600">
|
||||
😬 No emoji found
|
||||
</Search.NoResult>
|
||||
</Search.Content>
|
||||
</Search.Portal>
|
||||
</Search>
|
||||
</div>
|
||||
{/* <div class="mt-4 text-base leading-none">
|
||||
Emoji selected: {emoji()?.emoji} {emoji()?.name}
|
||||
</div> */}
|
||||
<div class="px-4 mt-4 bg-white rounded-t-2xl">
|
||||
<div class="h-[254px] overflow-scroll scrollbar-hide">
|
||||
<div class="w-full grid grid-cols-9 grid-rows-9 gap-2 h-[480px] grid-flow-row-dense py-4">
|
||||
<div class="col-span-3 row-span-3 bg-red-200 rounded-xl" />
|
||||
<div class="col-span-3 row-span-3 bg-green-200 rounded-xl" />
|
||||
<div class="col-span-6 row-span-3 bg-yellow-200 rounded-xl" />
|
||||
<div class="col-span-3 row-span-3 bg-green-200 rounded-xl" />
|
||||
<div class="col-span-3 row-span-3 bg-blue-200 rounded-xl" />
|
||||
<div class="col-span-3 row-span-3 bg-green-200 rounded-xl" />
|
||||
<div class="col-span-6 row-span-3 bg-yellow-200 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full border-t h-10 bg-white px-4 border-neutral-100">
|
||||
footer
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
<ImageViewer />
|
||||
<div class="px-4 mt-4 bg-white rounded-t-2xl">
|
||||
<div class="h-[254px] overflow-scroll scrollbar-hide">
|
||||
<div class="w-full grid grid-cols-9 grid-rows-9 gap-2 h-[480px] grid-flow-row-dense py-4">
|
||||
<div class="col-span-3 row-span-3 bg-red-200 rounded-xl" />
|
||||
<div class="col-span-3 row-span-3 bg-green-200 rounded-xl" />
|
||||
<div class="col-span-6 row-span-3 bg-yellow-200 rounded-xl" />
|
||||
<div class="col-span-3 row-span-3 bg-green-200 rounded-xl" />
|
||||
<div class="col-span-3 row-span-3 bg-blue-200 rounded-xl" />
|
||||
<div class="col-span-3 row-span-3 bg-green-200 rounded-xl" />
|
||||
<div class="col-span-6 row-span-3 bg-yellow-200 rounded-xl" />
|
||||
<For each={images()}>
|
||||
{(image) => (
|
||||
<A href={`/image/${image.ID}`}><img src={`http://localhost:3040/image/${image.ID}`} class="col-span-3 row-span-3 rounded-xl" /></A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full border-t h-10 bg-white px-4 border-neutral-100">
|
||||
footer
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
36
frontend/src/ImagePage.tsx
Normal file
36
frontend/src/ImagePage.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
import { createEffect, createResource, For, Suspense } from "solid-js"
|
||||
import { getUserImages } from "./network"
|
||||
|
||||
export function ImagePage() {
|
||||
const { imageId } = useParams<{ imageId: string }>()
|
||||
|
||||
const [image] = createResource(async () => {
|
||||
const userImages = await getUserImages();
|
||||
|
||||
const currentImage = userImages.find(image => image.ID === imageId);
|
||||
if (currentImage == null) {
|
||||
// TODO: this error handling.
|
||||
throw new Error("must be valid");
|
||||
}
|
||||
|
||||
return currentImage;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
console.log(image());
|
||||
})
|
||||
|
||||
return (<Suspense fallback={<>Loading...</>}>
|
||||
<A href="/">Back</A>
|
||||
<h1 class="text-2xl font-bold">{image()?.Image.ImageName}</h1>
|
||||
<img src={`http://localhost:3040/image/${image()?.ID}`} />
|
||||
<div class="flex flex-col">
|
||||
<For each={image()?.Tags ?? []}>
|
||||
{(tag) => (
|
||||
<div>{tag.Tag}</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Suspense>)
|
||||
}
|
@ -3,47 +3,47 @@ import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export function FolderPicker() {
|
||||
const [selectedPath, setSelectedPath] = createSignal<string>("");
|
||||
const [status, setStatus] = createSignal<string>("");
|
||||
const [selectedPath, setSelectedPath] = createSignal<string>("");
|
||||
const [status, setStatus] = createSignal<string>("");
|
||||
|
||||
const handleFolderSelect = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
const handleFolderSelect = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
setSelectedPath(selected as string);
|
||||
// Send the path to Rust
|
||||
const response = await invoke("handle_selected_folder", {
|
||||
path: selected,
|
||||
});
|
||||
setStatus(`Folder processed: ${response}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error}`);
|
||||
}
|
||||
};
|
||||
if (selected) {
|
||||
setSelectedPath(selected as string);
|
||||
// Send the path to Rust
|
||||
const response = await invoke("handle_selected_folder", {
|
||||
path: selected,
|
||||
});
|
||||
setStatus(`Folder processed: ${response}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFolderSelect}
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Select Folder
|
||||
</button>
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFolderSelect}
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Select Folder
|
||||
</button>
|
||||
|
||||
{selectedPath() && (
|
||||
<div class="text-left max-w-md">
|
||||
<p class="font-semibold">Selected folder:</p>
|
||||
<p class="text-sm break-all">{selectedPath()}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPath() && (
|
||||
<div class="text-left max-w-md">
|
||||
<p class="font-semibold">Selected folder:</p>
|
||||
<p class="text-sm break-all">{selectedPath()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status() && <p class="text-sm text-gray-600">{status()}</p>}
|
||||
</div>
|
||||
);
|
||||
{status() && <p class="text-sm text-gray-600">{status()}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,37 +1,40 @@
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { FolderPicker } from "./FolderPicker";
|
||||
import { sendImage } from "../network";
|
||||
|
||||
export function ImageViewer() {
|
||||
const [latestImage, setLatestImage] = createSignal<string | null>(null);
|
||||
const [latestImage, setLatestImage] = createSignal<string | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
// Listen for PNG processing events
|
||||
const unlisten = listen("png-processed", (event) => {
|
||||
console.log("Received processed PNG");
|
||||
const base64Data = event.payload as string;
|
||||
setLatestImage(`data:image/png;base64,${base64Data}`);
|
||||
createEffect(() => {
|
||||
// Listen for PNG processing events
|
||||
const unlisten = listen("png-processed", (event) => {
|
||||
console.log("Received processed PNG", event);
|
||||
const base64Data = event.payload as string;
|
||||
|
||||
setLatestImage(`data:image/png;base64,${base64Data}`);
|
||||
sendImage("test-image.png", base64Data);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn()); // Cleanup listener
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn()); // Cleanup listener
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<FolderPicker />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FolderPicker />
|
||||
|
||||
{latestImage() && (
|
||||
<div class="mt-4">
|
||||
<h3>Latest Processed Image:</h3>
|
||||
<img
|
||||
src={latestImage() || undefined}
|
||||
alt="Latest processed"
|
||||
class="max-w-md"
|
||||
/>
|
||||
{latestImage() && (
|
||||
<div class="mt-4">
|
||||
<h3>Latest Processed Image:</h3>
|
||||
<img
|
||||
src={latestImage() || undefined}
|
||||
alt="Latest processed"
|
||||
class="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
@ -3,21 +3,21 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Manrope";
|
||||
src: url("./assets/fonts/Manrope-VariableFont_wght.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
font-family: "Manrope";
|
||||
src: url("./assets/fonts/Manrope-VariableFont_wght.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
@apply bg-neutral-100 text-black rounded-xl;
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
@apply bg-neutral-100 text-black rounded-xl;
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
@ -2,5 +2,12 @@
|
||||
import { render } from "solid-js/web";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import { ImagePage } from "./ImagePage";
|
||||
|
||||
render(() => <App />, document.getElementById("root") as HTMLElement);
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route path="/" component={App} />
|
||||
<Route path="/image/:imageId" component={ImagePage} />
|
||||
</Router>
|
||||
), document.getElementById("root") as HTMLElement);
|
||||
|
96
frontend/src/network/index.ts
Normal file
96
frontend/src/network/index.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import {
|
||||
array,
|
||||
InferOutput,
|
||||
null as Null,
|
||||
nullable,
|
||||
object,
|
||||
parse,
|
||||
pipe,
|
||||
string,
|
||||
uuid,
|
||||
} from "valibot";
|
||||
|
||||
type BaseRequestParams = Partial<{
|
||||
path: string;
|
||||
body: RequestInit["body"];
|
||||
method: "GET" | "POST";
|
||||
}>;
|
||||
|
||||
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
|
||||
return new Request(`http://localhost:3040/${path}`, {
|
||||
headers: { userId: "fcc22dbb-7792-4595-be8e-d0439e13990a" },
|
||||
body,
|
||||
method,
|
||||
});
|
||||
};
|
||||
|
||||
const sendImageResponseValidator = object({
|
||||
ID: pipe(string(), uuid()),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
UserID: pipe(string(), uuid()),
|
||||
});
|
||||
|
||||
export const sendImage = async (
|
||||
imageName: string,
|
||||
base64Image: string,
|
||||
): Promise<InferOutput<typeof sendImageResponseValidator>> => {
|
||||
const request = getBaseRequest({
|
||||
path: `image/${imageName}`,
|
||||
body: base64Image,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
request.headers.set("Content-Type", "application/base64");
|
||||
|
||||
const res = await fetch(request).then((res) => res.json());
|
||||
|
||||
return parse(sendImageResponseValidator, res);
|
||||
};
|
||||
|
||||
const getUserImagesResponseValidator = array(
|
||||
object({
|
||||
ID: pipe(string(), uuid()),
|
||||
Image: object({
|
||||
ID: pipe(string(), uuid()),
|
||||
ImageName: string(),
|
||||
Image: Null(),
|
||||
}),
|
||||
Tags: nullable(
|
||||
array(
|
||||
object({
|
||||
ID: pipe(string(), uuid()),
|
||||
Tag: string(),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Links: nullable(
|
||||
array(
|
||||
object({
|
||||
ID: pipe(string(), uuid()),
|
||||
Links: string(),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Text: nullable(
|
||||
array(
|
||||
object({
|
||||
ID: pipe(string(), uuid()),
|
||||
ImageText: string(),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const getUserImages = async (): Promise<
|
||||
InferOutput<typeof getUserImagesResponseValidator>
|
||||
> => {
|
||||
const request = getBaseRequest({ path: "image" });
|
||||
|
||||
const res = await fetch(request).then((res) => res.json());
|
||||
|
||||
return parse(getUserImagesResponseValidator, res);
|
||||
};
|
@ -1,15 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Manrope", "sans-serif"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@kobalte/tailwindcss"),
|
||||
require("tailwind-scrollbar-hide"),
|
||||
],
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Manrope", "sans-serif"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@kobalte/tailwindcss"),
|
||||
require("tailwind-scrollbar-hide"),
|
||||
],
|
||||
};
|
||||
|
@ -1,26 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
@ -8,31 +8,31 @@ const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [solid()],
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
},
|
||||
},
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
plugins: [solid()],
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
},
|
||||
},
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
Reference in New Issue
Block a user