From 10cea769bfa45e0e83f3f214d12ccb527b1d21a5 Mon Sep 17 00:00:00 2001 From: John Costa Date: Wed, 20 Aug 2025 21:38:55 +0100 Subject: [PATCH] creating stacks using a user request --- .../haystack/model/processing_lists.go | 22 +++ backend/.gen/haystack/haystack/table/image.go | 7 +- .../haystack/haystack/table/image_lists.go | 3 + .../haystack/table/image_schema_items.go | 3 + backend/.gen/haystack/haystack/table/lists.go | 3 + backend/.gen/haystack/haystack/table/logs.go | 3 + .../haystack/table/processing_lists.go | 93 ++++++++++++ .../haystack/haystack/table/schema_items.go | 3 + .../.gen/haystack/haystack/table/schemas.go | 3 + .../haystack/table/table_use_schema.go | 1 + .../haystack/haystack/table/user_images.go | 3 + .../haystack/table/user_images_to_process.go | 3 + backend/.gen/haystack/haystack/table/users.go | 3 + backend/agents/client/chat.go | 9 ++ backend/agents/client/client.go | 35 +++++ backend/agents/create_list_agent.go | 140 +++++++++++++++++ backend/agents/list_agent.go | 8 +- backend/events.go | 51 +++++++ backend/main.go | 1 + backend/middleware/middleware.go | 31 +++- backend/models/lists.go | 141 ++++++++++++------ backend/schema.sql | 25 ++++ backend/stacks/handler.go | 83 ++++++++--- 23 files changed, 598 insertions(+), 76 deletions(-) create mode 100644 backend/.gen/haystack/haystack/model/processing_lists.go create mode 100644 backend/.gen/haystack/haystack/table/processing_lists.go create mode 100644 backend/agents/create_list_agent.go diff --git a/backend/.gen/haystack/haystack/model/processing_lists.go b/backend/.gen/haystack/haystack/model/processing_lists.go new file mode 100644 index 0000000..684ba6a --- /dev/null +++ b/backend/.gen/haystack/haystack/model/processing_lists.go @@ -0,0 +1,22 @@ +// +// 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" + "time" +) + +type ProcessingLists struct { + ID uuid.UUID `sql:"primary_key"` + UserID uuid.UUID + Title string + Fields string + Status Progress + CreatedAt *time.Time +} diff --git a/backend/.gen/haystack/haystack/table/image.go b/backend/.gen/haystack/haystack/table/image.go index f43c2b3..c7467a6 100644 --- a/backend/.gen/haystack/haystack/table/image.go +++ b/backend/.gen/haystack/haystack/table/image.go @@ -20,10 +20,11 @@ type imageTable struct { ID postgres.ColumnString ImageName postgres.ColumnString Description postgres.ColumnString - Image postgres.ColumnString + Image postgres.ColumnBytea AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type ImageTable struct { @@ -64,9 +65,10 @@ func newImageTableImpl(schemaName, tableName, alias string) imageTable { IDColumn = postgres.StringColumn("id") ImageNameColumn = postgres.StringColumn("image_name") DescriptionColumn = postgres.StringColumn("description") - ImageColumn = postgres.StringColumn("image") + ImageColumn = postgres.ByteaColumn("image") allColumns = postgres.ColumnList{IDColumn, ImageNameColumn, DescriptionColumn, ImageColumn} mutableColumns = postgres.ColumnList{ImageNameColumn, DescriptionColumn, ImageColumn} + defaultColumns = postgres.ColumnList{IDColumn} ) return imageTable{ @@ -80,5 +82,6 @@ func newImageTableImpl(schemaName, tableName, alias string) imageTable { AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/.gen/haystack/haystack/table/image_lists.go b/backend/.gen/haystack/haystack/table/image_lists.go index ec0f696..d4e9332 100644 --- a/backend/.gen/haystack/haystack/table/image_lists.go +++ b/backend/.gen/haystack/haystack/table/image_lists.go @@ -23,6 +23,7 @@ type imageListsTable struct { AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type ImageListsTable struct { @@ -65,6 +66,7 @@ func newImageListsTableImpl(schemaName, tableName, alias string) imageListsTable ListIDColumn = postgres.StringColumn("list_id") allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, ListIDColumn} mutableColumns = postgres.ColumnList{ImageIDColumn, ListIDColumn} + defaultColumns = postgres.ColumnList{IDColumn} ) return imageListsTable{ @@ -77,5 +79,6 @@ func newImageListsTableImpl(schemaName, tableName, alias string) imageListsTable AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/.gen/haystack/haystack/table/image_schema_items.go b/backend/.gen/haystack/haystack/table/image_schema_items.go index 8d3b344..e1e1a19 100644 --- a/backend/.gen/haystack/haystack/table/image_schema_items.go +++ b/backend/.gen/haystack/haystack/table/image_schema_items.go @@ -24,6 +24,7 @@ type imageSchemaItemsTable struct { AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type ImageSchemaItemsTable struct { @@ -67,6 +68,7 @@ func newImageSchemaItemsTableImpl(schemaName, tableName, alias string) imageSche ImageIDColumn = postgres.StringColumn("image_id") allColumns = postgres.ColumnList{IDColumn, ValueColumn, SchemaItemIDColumn, ImageIDColumn} mutableColumns = postgres.ColumnList{ValueColumn, SchemaItemIDColumn, ImageIDColumn} + defaultColumns = postgres.ColumnList{IDColumn} ) return imageSchemaItemsTable{ @@ -80,5 +82,6 @@ func newImageSchemaItemsTableImpl(schemaName, tableName, alias string) imageSche AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/.gen/haystack/haystack/table/lists.go b/backend/.gen/haystack/haystack/table/lists.go index 6c876c1..dc70a72 100644 --- a/backend/.gen/haystack/haystack/table/lists.go +++ b/backend/.gen/haystack/haystack/table/lists.go @@ -25,6 +25,7 @@ type listsTable struct { AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type ListsTable struct { @@ -69,6 +70,7 @@ func newListsTableImpl(schemaName, tableName, alias string) listsTable { CreatedAtColumn = postgres.TimestampzColumn("created_at") allColumns = postgres.ColumnList{IDColumn, UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn} mutableColumns = postgres.ColumnList{UserIDColumn, NameColumn, DescriptionColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{IDColumn, CreatedAtColumn} ) return listsTable{ @@ -83,5 +85,6 @@ func newListsTableImpl(schemaName, tableName, alias string) listsTable { AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/.gen/haystack/haystack/table/logs.go b/backend/.gen/haystack/haystack/table/logs.go index fccc691..2539860 100644 --- a/backend/.gen/haystack/haystack/table/logs.go +++ b/backend/.gen/haystack/haystack/table/logs.go @@ -23,6 +23,7 @@ type logsTable struct { AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type LogsTable struct { @@ -65,6 +66,7 @@ func newLogsTableImpl(schemaName, tableName, alias string) logsTable { CreatedAtColumn = postgres.TimestampzColumn("created_at") allColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn} mutableColumns = postgres.ColumnList{LogColumn, ImageIDColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn} ) return logsTable{ @@ -77,5 +79,6 @@ func newLogsTableImpl(schemaName, tableName, alias string) logsTable { AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/.gen/haystack/haystack/table/processing_lists.go b/backend/.gen/haystack/haystack/table/processing_lists.go new file mode 100644 index 0000000..3b3af88 --- /dev/null +++ b/backend/.gen/haystack/haystack/table/processing_lists.go @@ -0,0 +1,93 @@ +// +// 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 ProcessingLists = newProcessingListsTable("haystack", "processing_lists", "") + +type processingListsTable struct { + postgres.Table + + // Columns + ID postgres.ColumnString + UserID postgres.ColumnString + Title postgres.ColumnString + Fields postgres.ColumnString + Status postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type ProcessingListsTable struct { + processingListsTable + + EXCLUDED processingListsTable +} + +// AS creates new ProcessingListsTable with assigned alias +func (a ProcessingListsTable) AS(alias string) *ProcessingListsTable { + return newProcessingListsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new ProcessingListsTable with assigned schema name +func (a ProcessingListsTable) FromSchema(schemaName string) *ProcessingListsTable { + return newProcessingListsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new ProcessingListsTable with assigned table prefix +func (a ProcessingListsTable) WithPrefix(prefix string) *ProcessingListsTable { + return newProcessingListsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new ProcessingListsTable with assigned table suffix +func (a ProcessingListsTable) WithSuffix(suffix string) *ProcessingListsTable { + return newProcessingListsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newProcessingListsTable(schemaName, tableName, alias string) *ProcessingListsTable { + return &ProcessingListsTable{ + processingListsTable: newProcessingListsTableImpl(schemaName, tableName, alias), + EXCLUDED: newProcessingListsTableImpl("", "excluded", ""), + } +} + +func newProcessingListsTableImpl(schemaName, tableName, alias string) processingListsTable { + var ( + IDColumn = postgres.StringColumn("id") + UserIDColumn = postgres.StringColumn("user_id") + TitleColumn = postgres.StringColumn("title") + FieldsColumn = postgres.StringColumn("fields") + StatusColumn = postgres.StringColumn("status") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{IDColumn, UserIDColumn, TitleColumn, FieldsColumn, StatusColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{UserIDColumn, TitleColumn, FieldsColumn, StatusColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{IDColumn, StatusColumn, CreatedAtColumn} + ) + + return processingListsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ID: IDColumn, + UserID: UserIDColumn, + Title: TitleColumn, + Fields: FieldsColumn, + Status: StatusColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/.gen/haystack/haystack/table/schema_items.go b/backend/.gen/haystack/haystack/table/schema_items.go index d075b4e..f7eb9f4 100644 --- a/backend/.gen/haystack/haystack/table/schema_items.go +++ b/backend/.gen/haystack/haystack/table/schema_items.go @@ -25,6 +25,7 @@ type schemaItemsTable struct { AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type SchemaItemsTable struct { @@ -69,6 +70,7 @@ func newSchemaItemsTableImpl(schemaName, tableName, alias string) schemaItemsTab SchemaIDColumn = postgres.StringColumn("schema_id") allColumns = postgres.ColumnList{IDColumn, ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn} mutableColumns = postgres.ColumnList{ItemColumn, ValueColumn, DescriptionColumn, SchemaIDColumn} + defaultColumns = postgres.ColumnList{IDColumn} ) return schemaItemsTable{ @@ -83,5 +85,6 @@ func newSchemaItemsTableImpl(schemaName, tableName, alias string) schemaItemsTab AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/.gen/haystack/haystack/table/schemas.go b/backend/.gen/haystack/haystack/table/schemas.go index aef50dc..847e015 100644 --- a/backend/.gen/haystack/haystack/table/schemas.go +++ b/backend/.gen/haystack/haystack/table/schemas.go @@ -22,6 +22,7 @@ type schemasTable struct { AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type SchemasTable struct { @@ -63,6 +64,7 @@ func newSchemasTableImpl(schemaName, tableName, alias string) schemasTable { ListIDColumn = postgres.StringColumn("list_id") allColumns = postgres.ColumnList{IDColumn, ListIDColumn} mutableColumns = postgres.ColumnList{ListIDColumn} + defaultColumns = postgres.ColumnList{IDColumn} ) return schemasTable{ @@ -74,5 +76,6 @@ func newSchemasTableImpl(schemaName, tableName, alias string) schemasTable { AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/.gen/haystack/haystack/table/table_use_schema.go b/backend/.gen/haystack/haystack/table/table_use_schema.go index d3d245f..67b0d9f 100644 --- a/backend/.gen/haystack/haystack/table/table_use_schema.go +++ b/backend/.gen/haystack/haystack/table/table_use_schema.go @@ -15,6 +15,7 @@ func UseSchema(schema string) { ImageSchemaItems = ImageSchemaItems.FromSchema(schema) Lists = Lists.FromSchema(schema) Logs = Logs.FromSchema(schema) + ProcessingLists = ProcessingLists.FromSchema(schema) SchemaItems = SchemaItems.FromSchema(schema) Schemas = Schemas.FromSchema(schema) UserImages = UserImages.FromSchema(schema) diff --git a/backend/.gen/haystack/haystack/table/user_images.go b/backend/.gen/haystack/haystack/table/user_images.go index e820699..bd5cb49 100644 --- a/backend/.gen/haystack/haystack/table/user_images.go +++ b/backend/.gen/haystack/haystack/table/user_images.go @@ -24,6 +24,7 @@ type userImagesTable struct { AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type UserImagesTable struct { @@ -67,6 +68,7 @@ func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable CreatedAtColumn = postgres.TimestampzColumn("created_at") allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn, CreatedAtColumn} mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{IDColumn, CreatedAtColumn} ) return userImagesTable{ @@ -80,5 +82,6 @@ func newUserImagesTableImpl(schemaName, tableName, alias string) userImagesTable AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/.gen/haystack/haystack/table/user_images_to_process.go b/backend/.gen/haystack/haystack/table/user_images_to_process.go index 508ca60..1da2f3e 100644 --- a/backend/.gen/haystack/haystack/table/user_images_to_process.go +++ b/backend/.gen/haystack/haystack/table/user_images_to_process.go @@ -24,6 +24,7 @@ type userImagesToProcessTable struct { AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type UserImagesToProcessTable struct { @@ -67,6 +68,7 @@ func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userIm UserIDColumn = postgres.StringColumn("user_id") allColumns = postgres.ColumnList{IDColumn, StatusColumn, ImageIDColumn, UserIDColumn} mutableColumns = postgres.ColumnList{StatusColumn, ImageIDColumn, UserIDColumn} + defaultColumns = postgres.ColumnList{IDColumn, StatusColumn} ) return userImagesToProcessTable{ @@ -80,5 +82,6 @@ func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userIm AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/.gen/haystack/haystack/table/users.go b/backend/.gen/haystack/haystack/table/users.go index 412b46f..2880683 100644 --- a/backend/.gen/haystack/haystack/table/users.go +++ b/backend/.gen/haystack/haystack/table/users.go @@ -22,6 +22,7 @@ type usersTable struct { AllColumns postgres.ColumnList MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList } type UsersTable struct { @@ -63,6 +64,7 @@ func newUsersTableImpl(schemaName, tableName, alias string) usersTable { EmailColumn = postgres.StringColumn("email") allColumns = postgres.ColumnList{IDColumn, EmailColumn} mutableColumns = postgres.ColumnList{EmailColumn} + defaultColumns = postgres.ColumnList{IDColumn} ) return usersTable{ @@ -74,5 +76,6 @@ func newUsersTableImpl(schemaName, tableName, alias string) usersTable { AllColumns: allColumns, MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, } } diff --git a/backend/agents/client/chat.go b/backend/agents/client/chat.go index 32ccebd..29e7124 100644 --- a/backend/agents/client/chat.go +++ b/backend/agents/client/chat.go @@ -187,6 +187,15 @@ func (chat *Chat) AddSystem(prompt string) { }) } +func (chat *Chat) AddUser(msg string) { + chat.Messages = append(chat.Messages, ChatUserMessage{ + Role: User, + MessageContent: SingleMessage{ + Content: msg, + }, + }) +} + func (chat *Chat) AddImage(imageName string, image []byte, query *string) error { extension := filepath.Ext(imageName) if len(extension) == 0 { diff --git a/backend/agents/client/client.go b/backend/agents/client/client.go index 584c8be..73349a0 100644 --- a/backend/agents/client/client.go +++ b/backend/agents/client/client.go @@ -270,3 +270,38 @@ func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageNa return client.ToolLoop(toolHandlerInfo, &request) } + +func (client *AgentClient) RunAgentAlone(userID uuid.UUID, userReq string) error { + var tools any + err := json.Unmarshal([]byte(client.Options.JsonTools), &tools) + if err != nil { + return err + } + + toolChoice := "auto" + seed := 42 + + request := AgentRequestBody{ + Tools: &tools, + ToolChoice: &toolChoice, + Model: "google/gemini-2.5-flash", + RandomSeed: &seed, + Temperature: 0.3, + EndToolCall: client.Options.EndToolCall, + ResponseFormat: ResponseFormat{ + Type: "text", + }, + Chat: &Chat{ + Messages: make([]ChatMessage, 0), + }, + } + + request.Chat.AddSystem(client.Options.SystemPrompt) + request.Chat.AddUser(userReq) + + toolHandlerInfo := ToolHandlerInfo{ + UserId: userID, + } + + return client.ToolLoop(toolHandlerInfo, &request) +} diff --git a/backend/agents/create_list_agent.go b/backend/agents/create_list_agent.go new file mode 100644 index 0000000..75901f4 --- /dev/null +++ b/backend/agents/create_list_agent.go @@ -0,0 +1,140 @@ +package agents + +import ( + "context" + "encoding/json" + "fmt" + "screenmark/screenmark/.gen/haystack/haystack/model" + "screenmark/screenmark/agents/client" + "screenmark/screenmark/models" + + "github.com/charmbracelet/log" + "github.com/google/uuid" +) + +const createListAgentPrompt = ` +You are an agent who's job is to produce a reasonable output for an unstructured input. + +Your job is to create lists for the user, the user will give you a title and some fields they want +as part of the list. Your job is to take these fields, adjust their names so they have good names, +and add a good description for each one. + +You can add fields if you think they make a lot of sense. +You can remove fields if they are not correct, but be sure before you do this. +` + +const listJsonSchema = ` +{ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "the title of the list" + }, + "description": { + "type": "string", + "description": "the description of the list" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the field." + }, + "description": { + "type": "string", + "description": "A description of the field." + } + }, + "required": [ + "name", + "description" + ] + }, + "description": "An array of field objects." + } + }, + "required": [ + "fields" + ] +} +` + +type createNewListArguments struct { + Title string `json:"title"` + Description string `json:"description"` + + Fields []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"fields"` +} + +type CreateListAgent struct { + client client.AgentClient + + listModel models.ListModel +} + +func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, userReq string) error { + request := client.AgentRequestBody{ + Model: "google/gemini-2.5-flash", + Temperature: 0.3, + ResponseFormat: client.ResponseFormat{ + Type: "json_object", + JsonSchema: listJsonSchema, + }, + Chat: &client.Chat{ + Messages: make([]client.ChatMessage, 0), + }, + } + + request.Chat.AddSystem(agent.client.Options.SystemPrompt) + request.Chat.AddUser(userReq) + + resp, err := agent.client.Request(&request) + if err != nil { + return fmt.Errorf("request: %w", err) + } + + ctx := context.Background() + + structuredOutput := resp.Choices[0].Message.Content + + var createListArgs createNewListArguments + err = json.Unmarshal([]byte(structuredOutput), &createListArgs) + if err != nil { + return err + } + + schemaItems := make([]model.SchemaItems, 0) + for _, field := range createListArgs.Fields { + schemaItems = append(schemaItems, model.SchemaItems{ + Item: field.Name, + Description: field.Description, + + Value: "string", // keep it simple for now. + }) + } + + agent.listModel.Save(ctx, userID, createListArgs.Title, createListArgs.Description, schemaItems) + + return nil +} + +func NewCreateListAgent(log *log.Logger, listModel models.ListModel) CreateListAgent { + client := client.CreateAgentClient(client.CreateAgentClientOptions{ + SystemPrompt: createListAgentPrompt, + Log: log, + }) + + agent := CreateListAgent{ + client, + listModel, + } + + return agent +} diff --git a/backend/agents/list_agent.go b/backend/agents/list_agent.go index d2a2d6d..e200336 100644 --- a/backend/agents/list_agent.go +++ b/backend/agents/list_agent.go @@ -186,10 +186,6 @@ func NewListAgent(log *log.Logger, listModel models.ListModel) client.AgentClien return "Thought", nil }) - agentClient.ToolHandler.AddTool("listLists", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { - return listModel.List(context.Background(), info.UserId) - }) - agentClient.ToolHandler.AddTool("createList", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) { args := createListArguments{} err := json.Unmarshal([]byte(_args), &args) @@ -210,6 +206,10 @@ func NewListAgent(log *log.Logger, listModel models.ListModel) client.AgentClien return savedList, nil }) + agentClient.ToolHandler.AddTool("listLists", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { + return listModel.List(context.Background(), info.UserId) + }) + agentClient.ToolHandler.AddTool("addToList", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) { args := addToListArguments{} err := json.Unmarshal([]byte(_args), &args) diff --git a/backend/events.go b/backend/events.go index 5f983d9..27593fe 100644 --- a/backend/events.go +++ b/backend/events.go @@ -140,6 +140,57 @@ func ListenProcessingImageStatus(db *sql.DB, images models.ImageModel, notifier } } +func ListenNewStackEvents(db *sql.DB) { + listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) { + if err != nil { + panic(err) + } + }) + defer listener.Close() + + stackModel := models.NewListModel(db) + + newStacksLogger := createLogger("New Stacks 🤖", os.Stdout) + newStacksLogger.SetLevel(log.DebugLevel) + + err := listener.Listen("new_stack") + if err != nil { + panic(err) + } + + for parameters := range listener.Notify { + stackID := uuid.MustParse(parameters.Extra) + + newStacksLogger.Debug("Starting processing stack", "StackID", stackID) + + ctx := context.Background() + + go func() { + stack, err := stackModel.GetProcessing(ctx, stackID) + if err != nil { + newStacksLogger.Error("failed to get processing", "error", err) + return + } + + if err := stackModel.StartProcessing(ctx, stackID); err != nil { + newStacksLogger.Error("failed to start processing", "error", err) + return + } + + listAgent := agents.NewCreateListAgent(newStacksLogger, stackModel) + userListRequest := fmt.Sprintf("title=%s,fields=%s", stack.Title, stack.Fields) + + err = listAgent.CreateList(newStacksLogger, stack.UserID, userListRequest) + if err != nil { + newStacksLogger.Error("running agent", "err", err) + return + } + + newStacksLogger.Debug("Finished processing stack", "StackID", stackID) + }() + } +} + /* * TODO: We have channels open every a user sends an image. * We never close these channels. diff --git a/backend/main.go b/backend/main.go index 9e1bd4c..9db64e9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -57,6 +57,7 @@ func main() { go ListenNewImageEvents(db, ¬ifier) go ListenProcessingImageStatus(db, imageModel, ¬ifier) + go ListenNewStackEvents(db) r := chi.NewRouter() diff --git a/backend/middleware/middleware.go b/backend/middleware/middleware.go index bb8754d..46d53b1 100644 --- a/backend/middleware/middleware.go +++ b/backend/middleware/middleware.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" + "github.com/charmbracelet/log" "github.com/google/uuid" ) @@ -15,7 +16,7 @@ func CorsMiddleware(next http.Handler) http.Handler { w.Header().Add("Access-Control-Allow-Headers", "*") // Access-Control-Allow-Methods is often needed for preflight OPTIONS requests - w.Header().Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Add("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") // The client makes an OPTIONS preflight request before a complex request. // We must handle this and respond with the appropriate headers. @@ -30,15 +31,19 @@ func CorsMiddleware(next http.Handler) http.Handler { const USER_ID = "UserID" -func GetUserID(ctx context.Context) (uuid.UUID, error) { +func GetUserID(ctx context.Context, logger *log.Logger, w http.ResponseWriter) (uuid.UUID, error) { userId := ctx.Value(USER_ID) if userId == nil { + w.WriteHeader(http.StatusUnauthorized) + logger.Warn("UserID not present in request") return uuid.Nil, errors.New("context does not contain a user id") } userIdUuid, ok := userId.(uuid.UUID) if !ok { + w.WriteHeader(http.StatusUnauthorized) + logger.Warn("UserID not of correct type") return uuid.Nil, fmt.Errorf("context user id is not of type uuid, got: %t", userId) } @@ -87,3 +92,25 @@ func GetUserIdFromUrl(next http.Handler) http.Handler { next.ServeHTTP(w, newR) }) } + +func GetPathParamID(logger *log.Logger, param string, w http.ResponseWriter, r *http.Request) (uuid.UUID, error) { + pathParam := r.PathValue(param) + if len(pathParam) == 0 { + w.WriteHeader(http.StatusBadRequest) + + err := fmt.Errorf("%s was not present", param) + logger.Warn(err) + return uuid.Nil, err + } + + uuidParam, err := uuid.Parse(pathParam) + if len(pathParam) == 0 { + w.WriteHeader(http.StatusBadRequest) + + err := fmt.Errorf("could not parse param: %w", err) + logger.Warn(err) + return uuid.Nil, err + } + + return uuidParam, nil +} diff --git a/backend/models/lists.go b/backend/models/lists.go index b9111d5..758ab9b 100644 --- a/backend/models/lists.go +++ b/backend/models/lists.go @@ -26,6 +26,90 @@ type ListWithItems struct { } } +type ImageWithSchema struct { + model.ImageLists + + Items []model.ImageSchemaItems +} + +type IDValue struct { + ID string `json:"id"` + Value string `json:"value"` +} + +// ======================================== +// SELECT for lists +// ======================================== + +func (m ListModel) List(ctx context.Context, userId uuid.UUID) ([]ListWithItems, error) { + getListsWithItems := SELECT( + Lists.AllColumns, + Schemas.AllColumns, + SchemaItems.AllColumns, + ). + FROM( + Lists. + INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)). + INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)), + ). + WHERE(Lists.UserID.EQ(UUID(userId))) + + lists := []ListWithItems{} + err := getListsWithItems.QueryContext(ctx, m.dbPool, &lists) + + return lists, err +} + +func (m ListModel) ListItems(ctx context.Context, listID uuid.UUID) ([]ImageWithSchema, error) { + getListItems := SELECT( + ImageLists.AllColumns, + ImageSchemaItems.AllColumns, + ). + FROM( + ImageLists. + INNER_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageLists.ImageID)), + ). + WHERE(ImageLists.ListID.EQ(UUID(listID))) + + listItems := make([]ImageWithSchema, 0) + err := getListItems.QueryContext(ctx, m.dbPool, &listItems) + + return listItems, err +} + +// ======================================== +// SELECT for specific items +// ======================================== + +func (m ListModel) GetProcessing(ctx context.Context, processingListID uuid.UUID) (model.ProcessingLists, error) { + getProcessingListStmt := ProcessingLists. + SELECT(ProcessingLists.AllColumns). + WHERE(ProcessingLists.ID.EQ(UUID(processingListID))) + + list := model.ProcessingLists{} + err := getProcessingListStmt.QueryContext(ctx, m.dbPool, &list) + + return list, err +} + +// ======================================== +// UPDATE +// ======================================== + +func (m ListModel) StartProcessing(ctx context.Context, processingListID uuid.UUID) error { + startProcessingStmt := ProcessingLists. + UPDATE(ProcessingLists.Status). + SET(model.Progress_InProgress). + WHERE(ProcessingLists.ID.EQ(UUID(processingListID))) + + _, err := startProcessingStmt.ExecContext(ctx, m.dbPool) + return err +} + +// ======================================== +// INSERT methods +// ======================================== + func (m ListModel) Save(ctx context.Context, userId uuid.UUID, name string, description string, schemaItems []model.SchemaItems) (ListWithItems, error) { tx, err := m.dbPool.BeginTx(ctx, nil) @@ -86,53 +170,6 @@ func (m ListModel) Save(ctx context.Context, userId uuid.UUID, name string, desc return listWithItems, err } -func (m ListModel) List(ctx context.Context, userId uuid.UUID) ([]ListWithItems, error) { - getListsWithItems := SELECT( - Lists.AllColumns, - Schemas.AllColumns, - SchemaItems.AllColumns, - ). - FROM( - Lists. - INNER_JOIN(Schemas, Schemas.ListID.EQ(Lists.ID)). - INNER_JOIN(SchemaItems, SchemaItems.SchemaID.EQ(Schemas.ID)), - ). - WHERE(Lists.UserID.EQ(UUID(userId))) - - lists := []ListWithItems{} - err := getListsWithItems.QueryContext(ctx, m.dbPool, &lists) - - return lists, err -} - -type ImageWithSchema struct { - model.ImageLists - - Items []model.ImageSchemaItems -} - -func (m ListModel) ListItems(ctx context.Context, listID uuid.UUID) ([]ImageWithSchema, error) { - getListItems := SELECT( - ImageLists.AllColumns, - ImageSchemaItems.AllColumns, - ). - FROM( - ImageLists. - INNER_JOIN(ImageSchemaItems, ImageSchemaItems.ImageID.EQ(ImageLists.ImageID)), - ). - WHERE(ImageLists.ListID.EQ(UUID(listID))) - - listItems := make([]ImageWithSchema, 0) - err := getListItems.QueryContext(ctx, m.dbPool, &listItems) - - return listItems, err -} - -type IDValue struct { - ID string `json:"id"` - Value string `json:"value"` -} - func (m ListModel) SaveInto(ctx context.Context, listId uuid.UUID, imageId uuid.UUID, schemaValues []IDValue) error { imageSchemaItems := make([]model.ImageSchemaItems, len(schemaValues)) @@ -175,6 +212,16 @@ func (m ListModel) SaveInto(ctx context.Context, listId uuid.UUID, imageId uuid. return err } +func (m ListModel) SaveProcessing(ctx context.Context, userID uuid.UUID, title string, fields string) error { + insertListToProcess := ProcessingLists. + INSERT(ProcessingLists.UserID, ProcessingLists.Title, ProcessingLists.Fields). + VALUES(userID, title, fields) + + _, err := insertListToProcess.ExecContext(ctx, m.dbPool) + + return err +} + func NewListModel(db *sql.DB) ListModel { return ListModel{dbPool: db} } diff --git a/backend/schema.sql b/backend/schema.sql index 9f41ef2..7c875dc 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -52,6 +52,18 @@ CREATE TABLE haystack.lists ( created_at TIMESTAMP WITH TIME ZONE DEFAULT now() ); +CREATE TABLE haystack.processing_lists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES haystack.users (id), + + title TEXT NOT NULL, + fields TEXT NOT NULL, + + status haystack.progress NOT NULL DEFAULT 'not-started', + + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + CREATE TABLE haystack.image_lists ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -104,6 +116,14 @@ PERFORM pg_notify('new_processing_image_status', NEW.id::text || NEW.status::tex END $$ LANGUAGE plpgsql; +CREATE OR REPLACE FUNCTION notify_new_stacks() +RETURNS TRIGGER AS $$ +BEGIN +PERFORM pg_notify('new_stack', NEW.id::text); + RETURN NEW; +END +$$ LANGUAGE plpgsql; + /* -----| Triggers |----- */ CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT @@ -117,4 +137,9 @@ ON haystack.user_images_to_process FOR EACH ROW EXECUTE PROCEDURE notify_new_processing_image_status(); +CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT +ON haystack.processing_lists +FOR EACH ROW +EXECUTE PROCEDURE notify_new_stacks(); + /* -----| Test Data |----- */ diff --git a/backend/stacks/handler.go b/backend/stacks/handler.go index 29ed0a6..510c0fd 100644 --- a/backend/stacks/handler.go +++ b/backend/stacks/handler.go @@ -3,6 +3,7 @@ package stacks import ( "database/sql" "encoding/json" + "io" "net/http" "os" "screenmark/screenmark/middleware" @@ -10,7 +11,6 @@ import ( "github.com/charmbracelet/log" "github.com/go-chi/chi/v5" - "github.com/google/uuid" ) func writeJsonOrError[K any](logger *log.Logger, object K, w http.ResponseWriter) { @@ -30,26 +30,36 @@ type StackHandler struct { stackModel models.ListModel } -func (h *StackHandler) withUserID( - fn func(userID uuid.UUID, w http.ResponseWriter, r *http.Request), +func withValidatedPost[K any]( + fn func(request K, w http.ResponseWriter, r *http.Request), ) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() + request := new(K) - userID, err := middleware.GetUserID(ctx) + body, err := io.ReadAll(r.Body) if err != nil { - h.logger.Warn("could not get users in get all stacks", "err", err) - w.WriteHeader(http.StatusUnauthorized) + w.WriteHeader(http.StatusBadRequest) return } - fn(userID, w, r) + err = json.Unmarshal(body, request) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + fn(*request, w, r) } } -func (h *StackHandler) getAllStacks(userID uuid.UUID, w http.ResponseWriter, r *http.Request) { +func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + userID, err := middleware.GetUserID(ctx, h.logger, w) + if err != nil { + return + } + lists, err := h.stackModel.List(ctx, userID) if err != nil { h.logger.Warn("could not get stacks", "err", err) @@ -60,26 +70,21 @@ func (h *StackHandler) getAllStacks(userID uuid.UUID, w http.ResponseWriter, r * writeJsonOrError(h.logger, lists, w) } -func (h *StackHandler) getStackItems(userID uuid.UUID, w http.ResponseWriter, r *http.Request) { +func (h *StackHandler) getStackItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - - listID := r.PathValue("listID") - if len(listID) == 0 { - h.logger.Warn("listID is not present in path") - w.WriteHeader(http.StatusBadRequest) + _, err := middleware.GetUserID(ctx, h.logger, w) + if err != nil { return } - uuidListID, err := uuid.Parse(listID) + listID, err := middleware.GetPathParamID(h.logger, "listID", w, r) if err != nil { - h.logger.Warn("could not parse list id uuid", "err", err) - w.WriteHeader(http.StatusUnauthorized) return } // TODO: must check for permission here. - lists, err := h.stackModel.ListItems(ctx, uuidListID) + lists, err := h.stackModel.ListItems(ctx, listID) if err != nil { h.logger.Warn("could not get list items", "err", err) w.WriteHeader(http.StatusBadRequest) @@ -89,6 +94,38 @@ func (h *StackHandler) getStackItems(userID uuid.UUID, w http.ResponseWriter, r writeJsonOrError(h.logger, lists, w) } +type EditStack struct { + Hello string `json:"hello"` +} + +func (h *StackHandler) editStack(req EditStack, w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +type CreateStackBody struct { + Title string `json:"title"` + + // We want a regular string because AI will take care of creating these for us. + Fields string `json:"fields"` +} + +func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userID, err := middleware.GetUserID(ctx, h.logger, w) + if err != nil { + return + } + + err = h.stackModel.SaveProcessing(ctx, userID, body.Title, body.Fields) + if err != nil { + h.logger.Warn("could not save processing", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + func (h *StackHandler) CreateRoutes(r chi.Router) { h.logger.Info("Mounting stack router") @@ -96,8 +133,12 @@ func (h *StackHandler) CreateRoutes(r chi.Router) { r.Use(middleware.ProtectedRoute) r.Use(middleware.SetJson) - r.Get("/", h.withUserID(h.getAllStacks)) - r.Get("/{listID}", h.withUserID(h.getStackItems)) + r.Get("/", h.getAllStacks) + r.Get("/{listID}", h.getStackItems) + + r.Post("/", withValidatedPost(h.createStack)) + + r.Patch("/{listID}", withValidatedPost(h.editStack)) }) }