66 Commits

Author SHA1 Message Date
6b0fcf3005 wip(agent-builder): im not sure sure this is actually a good idea 2025-04-18 14:34:29 +01:00
1b1f957e01 wip 2025-04-18 14:21:23 +01:00
49969b0608 feat(location-agent): using createLocation instead of updateLocation to simplify 2025-04-18 13:26:42 +01:00
9b95ffb59e feat(contact-agent): using createContact with an ID field to provide updates 2025-04-17 18:57:13 +01:00
c9560f6881 feat(event-agent): update events function 2025-04-17 18:19:54 +01:00
c5535a5b3b feat(location-agent): seperating the tool to allow for replying
This means it makes less mistakes and doesnt get as confused.
2025-04-17 18:09:00 +01:00
5ab0d13b21 fix(location-events): adding location id to the database from agent call 2025-04-17 15:32:50 +01:00
15289e4965 feat(prompts): adding better prompts & restoring tool_stop
Mistral's models seem to do something really strange if you allow for
`tool_choice` to be anything but `any`. They start putting the tool call
inside the `content` instead of an actual tool call. This means that I
need this `stop` mechanism using a tool call instead because I cannot
trust the model to do it by itself.

I quite like this model though, it's cheap, it's fast and it's open
source. And all the answers are pretty good!
2025-04-17 15:24:21 +01:00
181da1f09d feat(orchestrator): removing the end tool call
fix
2025-04-17 13:00:39 +01:00
90b90a8185 chore: removing unnecessary logging 2025-04-17 13:00:24 +01:00
fb30eb4ad6 wip(orchestrator): improving orchestrator system prompt and tool description 2025-04-17 12:52:54 +01:00
5454a1cfaf feat(event-location): communicating using tool calls correctly 2025-04-17 11:15:02 +01:00
3716d22eca fix(logger): nil pointer error + log debug level clean 2025-04-17 11:07:37 +01:00
6d2f0c6108 refactor(agents): not returning an error on factory method 2025-04-17 11:02:11 +01:00
61c158d5b6 refactor(agents): encapsulating prompt and calls inside factory method 2025-04-17 10:58:19 +01:00
82331c0833 fix: using correct eventAgent instead of orchestrator bug + better logging 2025-04-17 10:48:30 +01:00
e42aa75639 refactor(agents): no need to wrap them in another struct 2025-04-17 10:36:11 +01:00
fa486153b4 feat: event agent calling location agent about location ID
This is pretty nice. We can now have agents spawn other agents and
actually get super cool functionality from it.

The pattern might be a little fragile.
2025-04-16 14:43:07 +01:00
aacecfffac wip(agents): allowing event agent to call location agent 2025-04-15 16:44:00 +01:00
e89a342751 feat: Adding text message to describe an action3 2025-04-15 16:43:27 +01:00
e16b6f4529 fix 2025-04-14 20:08:07 +01:00
6ddae3426d rollback: not using link functions as they are very problematic 2025-04-14 10:59:08 +01:00
67468bddb6 fix(network): restore conditional base URL for development environment
- Reintroduced conditional logic for the base URL to switch between local and production endpoints based on the environment.
2025-04-14 11:41:29 +02:00
10bc0a04a2 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 11:41:11 +02:00
8a57236f04 ffix 2025-04-14 10:40:02 +01:00
b138661991 prompt 2025-04-14 10:38:25 +01:00
6db9bb2ab3 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 11:37:34 +02:00
6ae2458186 refactor(network): simplify base URL and clean up validators
- Updated the base URL to a fixed production endpoint, removing the conditional logic for development.
- Commented out Location and Organizer validations in the eventValidator for future consideration.
- Added a console log in getUserImages to assist with backend response tracking.
2025-04-14 11:37:29 +02:00
51d36bf15b more prompt 2025-04-14 10:36:21 +01:00
ecc2da5f86 fix more prompt 2025-04-14 10:33:56 +01:00
d7ab3f56dc stupid 2025-04-14 10:30:46 +01:00
55aa1e67ba horrible 2025-04-14 10:30:21 +01:00
1f83b721a6 fix: prompts 2025-04-14 10:28:31 +01:00
0596ea2b1e debug 2025-04-14 10:22:54 +01:00
3c1f6ba40f fix(network): update base URL for development and production environments
- Changed the base URL to use localhost for development and the production URL for other environments.
- Added Location validation back into the eventValidator and removed commented-out code for clarity.
- Cleaned up debugging logs in getUserImages function.
2025-04-14 10:56:32 +02:00
0eff145f02 push 2025-04-14 09:55:50 +01:00
1fa1db7d1b Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 10:54:24 +02:00
a1369719d7 feat(search): improve Search component with conditional rendering and debugging logs
- Added conditional rendering for the "No results found" message using the Show component.
- Introduced debugging logs in getUserImages and Search component to track data flow.
- Cleaned up the data mapping process in getUserImages for better readability.
2025-04-14 10:54:18 +02:00
40ddf737c8 pushhh 2025-04-14 09:54:09 +01:00
ad14254ecb debnug 2025-04-14 09:50:03 +01:00
e8d996cec5 debug 2025-04-14 09:44:29 +01:00
0ed6b4c123 debug 2025-04-14 09:44:19 +01:00
0bc556f47c Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 10:36:58 +02:00
5a530b2e39 feat(login): implement logout functionality and redirect after login
- Added a logout function in the Settings component to clear user session data and redirect to the login page.
- Updated the Login component to redirect to the home page upon successful login.
- Adjusted styling in the Search component for better spacing in the "No results found" message.
2025-04-14 10:36:55 +02:00
868c8e6409 fix 2025-04-14 09:31:27 +01:00
30143019d6 feat: making all codes upper case + fetching fixes 2025-04-14 09:28:08 +01:00
cd5dd347d3 fix 2025-04-14 09:15:48 +01:00
ab09378fcd chore: removing SQL debug 2025-04-14 09:12:16 +01:00
18f85a8929 feat(search): enhance Search component with shortcuts and item modal
- Added functionality to fetch and display global shortcuts in the Search component.
- Introduced ItemModal for displaying detailed information about selected items.
- Updated SearchCard components to improve layout and information presentation.
- Enhanced user experience with better styling and accessibility features.
2025-04-14 10:03:37 +02:00
55614b34c7 feat(image-viewer): integrate ImageViewer component and update FolderPicker layout
- Added ImageViewer component to the App for displaying processed images.
- Updated FolderPicker layout for improved user guidance and aesthetics.
- Refactored ShortcutItem and Shortcuts components for better structure and clarity.
- Introduced ItemModal component for future use.
2025-04-14 09:25:53 +02:00
664918f431 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-14 08:55:42 +02:00
048fc38032 refactor(settings): reorganize FolderPicker component and update layout
- Moved FolderPicker to a new folder structure for better organization.
- Updated the Settings page layout to enhance visual hierarchy by increasing the title size.
- Removed the old FolderPicker component file after restructuring.
2025-04-14 08:55:36 +02:00
2f26b5dfd9 feat(app): restructure routing and implement Search component
- Refactored the App component to streamline routing using the Router and Route components.
- Introduced a new Search component to handle search functionality, including input handling and result display.
- Removed inline search logic from the App component for better separation of concerns.
- Updated index.tsx to render the App component directly, simplifying the routing structure.
2025-04-14 08:47:57 +02:00
4f6c198307 feat: registering users if their email is not known 2025-04-13 22:29:25 +01:00
c99d6e4e6b feat(app): refactor App component and add Settings page
- Refactored the App component to utilize a new SearchCard component for rendering search results.
- Introduced a Settings page with FolderPicker and Shortcuts components for user configuration.
- Removed the ImagePage component as it was no longer needed.
- Updated routing to include the new Settings page and adjusted imports accordingly.
- Added a settings button to the main interface for easy access to the new settings functionality.
2025-04-13 22:48:26 +02:00
b97cf63484 feat(search): add autofocus to search input and emit focus event on shortcut
- Added autofocus attribute to the search input field for improved user experience.
- Updated global shortcut handling to emit a "focus-search" event when the shortcut is triggered, enhancing the application's responsiveness to user actions.
- Updated dependencies in Cargo.toml to specific beta versions for better compatibility.
2025-04-13 21:57:36 +02:00
7af536bd9c feat(capabilities): add localhost URL to default permissions
- Updated the default capabilities configuration to allow access to http://localhost:3040 in addition to the existing https://haystack.johncosta.tech URL.
2025-04-13 21:28:14 +02:00
5406e79fc8 chore: update dependencies and add new packages
- Updated several dependencies in Cargo.lock to their latest versions, including `anyhow`, `ashpd`, `bitflags`, `chrono`, and `http`.
- Added new dependencies such as `tauri-plugin-http`, `cookie_store`, and `h2`.
- Removed outdated dependencies related to `wayland` and updated the `windows` related packages for better compatibility.
2025-04-13 21:24:20 +02:00
0e88f77474 Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-13 21:21:43 +02:00
878a47ffd1 refactor: using tauri http client 2025-04-13 19:34:02 +01:00
eba4268718 fix 2025-04-13 19:18:07 +01:00
4d903f40bf feat: add global shortcut functionality and update dependencies
- Introduced global shortcut management in the Tauri application, allowing users to set, change, and unregister shortcuts.
- Added new dependencies for global shortcut functionality in Cargo.toml and updated package.json.
- Enhanced the default capabilities to include global shortcut permissions.
- Refactored the main application logic to integrate the new shortcut features.
2025-04-13 16:40:04 +02:00
24bed2aafb Merge branch 'main' of https://git.johncosta.tech/JohnCosta27/Haystack 2025-04-13 16:20:32 +02:00
349dcc2275 feat: update app description and enhance folder watching functionality
- Updated the app description in package.json and Cargo.toml to "Screenshots that organize themselves".
- Refactored the Tauri backend to introduce a new command for handling folder selection and watching for PNG file changes.
- Added utility functions for processing PNG files and managing the watcher state.
- Improved the frontend by integrating an ImageViewer component and setting up event listeners for search input focus.
2025-04-13 16:19:51 +02:00
dcfed6a746 Revert "FIXUP wip: notifications on starting progress"
This reverts commit 91b9e5402e9f153348f1326ee269533e1e47f777.
2025-04-12 15:57:36 +01:00
91b9e5402e FIXUP wip: notifications on starting progress 2025-04-12 15:55:58 +01:00
59 changed files with 3186 additions and 1736 deletions

View File

@ -0,0 +1,17 @@
//
// 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 Agents struct {
ID uuid.UUID `sql:"primary_key"`
Name string
}

View 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 SystemPrompts struct {
ID uuid.UUID `sql:"primary_key"`
Prompt string
AgentID uuid.UUID
}

View 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 Tools struct {
ID uuid.UUID `sql:"primary_key"`
Tool string
AgentID uuid.UUID
}

View File

@ -0,0 +1,78 @@
//
// 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 Agents = newAgentsTable("agents", "agents", "")
type agentsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
Name postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
}
type AgentsTable struct {
agentsTable
EXCLUDED agentsTable
}
// AS creates new AgentsTable with assigned alias
func (a AgentsTable) AS(alias string) *AgentsTable {
return newAgentsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AgentsTable with assigned schema name
func (a AgentsTable) FromSchema(schemaName string) *AgentsTable {
return newAgentsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AgentsTable with assigned table prefix
func (a AgentsTable) WithPrefix(prefix string) *AgentsTable {
return newAgentsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AgentsTable with assigned table suffix
func (a AgentsTable) WithSuffix(suffix string) *AgentsTable {
return newAgentsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAgentsTable(schemaName, tableName, alias string) *AgentsTable {
return &AgentsTable{
agentsTable: newAgentsTableImpl(schemaName, tableName, alias),
EXCLUDED: newAgentsTableImpl("", "excluded", ""),
}
}
func newAgentsTableImpl(schemaName, tableName, alias string) agentsTable {
var (
IDColumn = postgres.StringColumn("id")
NameColumn = postgres.StringColumn("name")
allColumns = postgres.ColumnList{IDColumn, NameColumn}
mutableColumns = postgres.ColumnList{NameColumn}
)
return agentsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
Name: NameColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
}
}

View 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 SystemPrompts = newSystemPromptsTable("agents", "system_prompts", "")
type systemPromptsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
Prompt postgres.ColumnString
AgentID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
}
type SystemPromptsTable struct {
systemPromptsTable
EXCLUDED systemPromptsTable
}
// AS creates new SystemPromptsTable with assigned alias
func (a SystemPromptsTable) AS(alias string) *SystemPromptsTable {
return newSystemPromptsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new SystemPromptsTable with assigned schema name
func (a SystemPromptsTable) FromSchema(schemaName string) *SystemPromptsTable {
return newSystemPromptsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new SystemPromptsTable with assigned table prefix
func (a SystemPromptsTable) WithPrefix(prefix string) *SystemPromptsTable {
return newSystemPromptsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new SystemPromptsTable with assigned table suffix
func (a SystemPromptsTable) WithSuffix(suffix string) *SystemPromptsTable {
return newSystemPromptsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newSystemPromptsTable(schemaName, tableName, alias string) *SystemPromptsTable {
return &SystemPromptsTable{
systemPromptsTable: newSystemPromptsTableImpl(schemaName, tableName, alias),
EXCLUDED: newSystemPromptsTableImpl("", "excluded", ""),
}
}
func newSystemPromptsTableImpl(schemaName, tableName, alias string) systemPromptsTable {
var (
IDColumn = postgres.StringColumn("id")
PromptColumn = postgres.StringColumn("prompt")
AgentIDColumn = postgres.StringColumn("agent_id")
allColumns = postgres.ColumnList{IDColumn, PromptColumn, AgentIDColumn}
mutableColumns = postgres.ColumnList{PromptColumn, AgentIDColumn}
)
return systemPromptsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
Prompt: PromptColumn,
AgentID: AgentIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
}
}

View File

@ -0,0 +1,16 @@
//
// 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
// 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) {
Agents = Agents.FromSchema(schema)
SystemPrompts = SystemPrompts.FromSchema(schema)
Tools = Tools.FromSchema(schema)
}

View 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 Tools = newToolsTable("agents", "tools", "")
type toolsTable struct {
postgres.Table
// Columns
ID postgres.ColumnString
Tool postgres.ColumnString
AgentID postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
}
type ToolsTable struct {
toolsTable
EXCLUDED toolsTable
}
// AS creates new ToolsTable with assigned alias
func (a ToolsTable) AS(alias string) *ToolsTable {
return newToolsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ToolsTable with assigned schema name
func (a ToolsTable) FromSchema(schemaName string) *ToolsTable {
return newToolsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ToolsTable with assigned table prefix
func (a ToolsTable) WithPrefix(prefix string) *ToolsTable {
return newToolsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ToolsTable with assigned table suffix
func (a ToolsTable) WithSuffix(suffix string) *ToolsTable {
return newToolsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newToolsTable(schemaName, tableName, alias string) *ToolsTable {
return &ToolsTable{
toolsTable: newToolsTableImpl(schemaName, tableName, alias),
EXCLUDED: newToolsTableImpl("", "excluded", ""),
}
}
func newToolsTableImpl(schemaName, tableName, alias string) toolsTable {
var (
IDColumn = postgres.StringColumn("id")
ToolColumn = postgres.StringColumn("tool")
AgentIDColumn = postgres.StringColumn("agent_id")
allColumns = postgres.ColumnList{IDColumn, ToolColumn, AgentIDColumn}
mutableColumns = postgres.ColumnList{ToolColumn, AgentIDColumn}
)
return toolsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
Tool: ToolColumn,
AgentID: AgentIDColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
}
}

View File

@ -65,8 +65,8 @@ func (m ChatUserMessage) MarshalJSON() ([]byte, error) {
}) })
case ArrayMessage: case ArrayMessage:
return json.Marshal(&struct { return json.Marshal(&struct {
Role UserRole `json:"role"` Role UserRole `json:"role"`
Content []ImageMessageContent `json:"content"` Content []MessageContentMessage `json:"content"`
}{ }{
Role: User, Role: User,
Content: t.Content, Content: t.Content,
@ -121,18 +121,35 @@ func (m SingleMessage) IsSingleMessage() bool {
} }
type ArrayMessage struct { type ArrayMessage struct {
Content []ImageMessageContent `json:"content"` Content []MessageContentMessage `json:"content"`
} }
func (m ArrayMessage) IsSingleMessage() bool { func (m ArrayMessage) IsSingleMessage() bool {
return false return false
} }
type MessageContentMessage interface {
IsImageMessage() bool
}
type TextMessageContent struct {
TextType string `json:"type"`
Text string `json:"text"`
}
func (m TextMessageContent) IsImageMessage() bool {
return false
}
type ImageMessageContent struct { type ImageMessageContent struct {
ImageType string `json:"type"` ImageType string `json:"type"`
ImageUrl string `json:"image_url"` ImageUrl string `json:"image_url"`
} }
func (m ImageMessageContent) IsImageMessage() bool {
return true
}
type ImageContentUrl struct { type ImageContentUrl struct {
Url string `json:"url"` Url string `json:"url"`
} }
@ -165,7 +182,7 @@ func (chat *Chat) AddSystem(prompt string) {
}) })
} }
func (chat *Chat) AddImage(imageName string, image []byte) error { func (chat *Chat) AddImage(imageName string, image []byte, query *string) error {
extension := filepath.Ext(imageName) extension := filepath.Ext(imageName)
if len(extension) == 0 { if len(extension) == 0 {
// TODO: could also validate for image types we support. // TODO: could also validate for image types we support.
@ -173,14 +190,28 @@ func (chat *Chat) AddImage(imageName string, image []byte) error {
} }
extension = extension[1:] extension = extension[1:]
encodedString := base64.StdEncoding.EncodeToString(image) encodedString := base64.StdEncoding.EncodeToString(image)
messageContent := ArrayMessage{ contentLength := 1
Content: make([]ImageMessageContent, 1), if query != nil {
contentLength += 1
} }
messageContent.Content[0] = ImageMessageContent{ messageContent := ArrayMessage{
Content: make([]MessageContentMessage, contentLength),
}
index := 0
if query != nil {
messageContent.Content[index] = TextMessageContent{
TextType: "text",
Text: *query,
}
index += 1
}
messageContent.Content[index] = ImageMessageContent{
ImageType: "image_url", ImageType: "image_url",
ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString), ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
} }

View File

@ -73,16 +73,28 @@ type AgentClient struct {
Log *log.Logger Log *log.Logger
Reply string
Do func(req *http.Request) (*http.Response, error) Do func(req *http.Request) (*http.Response, error)
Options CreateAgentClientOptions
} }
const OPENAI_API_KEY = "OPENAI_API_KEY" const OPENAI_API_KEY = "OPENAI_API_KEY"
func CreateAgentClient(log *log.Logger) (AgentClient, error) { type CreateAgentClientOptions struct {
Log *log.Logger
SystemPrompt string
JsonTools string
EndToolCall string
Query *string
}
func CreateAgentClient(options CreateAgentClientOptions) AgentClient {
apiKey := os.Getenv(OPENAI_API_KEY) apiKey := os.Getenv(OPENAI_API_KEY)
if len(apiKey) == 0 { if len(apiKey) == 0 {
return AgentClient{}, errors.New(OPENAI_API_KEY + " was not found.") panic("No api key")
} }
return AgentClient{ return AgentClient{
@ -93,12 +105,14 @@ func CreateAgentClient(log *log.Logger) (AgentClient, error) {
return client.Do(req) return client.Do(req)
}, },
Log: log, Log: options.Log,
ToolHandler: ToolsHandlers{ ToolHandler: ToolsHandlers{
handlers: map[string]ToolHandler{}, handlers: map[string]ToolHandler{},
}, },
}, nil
Options: options,
}
} }
func (client AgentClient) getRequest(body []byte) (*http.Request, error) { func (client AgentClient) getRequest(body []byte) (*http.Request, error) {
@ -146,39 +160,32 @@ func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error)
return AgentResponse{}, errors.New("Unsupported. We currently only accept 1 choice from AI.") return AgentResponse{}, errors.New("Unsupported. We currently only accept 1 choice from AI.")
} }
client.Log.SetLevel(log.DebugLevel)
msg := agentResponse.Choices[0].Message msg := agentResponse.Choices[0].Message
req.Chat.AddAiResponse(msg)
if len(msg.Content) > 0 {
client.Log.Debugf("Content: %s", msg.Content)
}
if msg.ToolCalls != nil && len(*msg.ToolCalls) > 0 {
client.Log.Debugf("Tool Call: %s", (*msg.ToolCalls)[0].Function.Name)
prettyJson, err := json.MarshalIndent((*msg.ToolCalls)[0].Function.Arguments, "", " ")
if err != nil {
return AgentResponse{}, err
}
client.Log.Debugf("Arguments: %s", string(prettyJson))
}
req.Chat.AddAiResponse(agentResponse.Choices[0].Message)
return agentResponse, nil return agentResponse, nil
} }
func (client AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error { func (client *AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error {
for { for {
err := client.Process(info, req) response, err := client.Request(req)
if err != nil { if err != nil {
return err return err
} }
_, err = client.Request(req) if response.Choices[0].FinishReason == "stop" {
client.Log.Debug("Agent is finished")
return nil
}
err = client.Process(info, req)
if err != nil { if err != nil {
if err == FinishedCall {
client.Log.Debug("Agent is finished")
}
return err return err
} }
} }
@ -186,7 +193,7 @@ func (client AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody)
var FinishedCall = errors.New("Last tool tool was called") var FinishedCall = errors.New("Last tool tool was called")
func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) error { func (client *AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) error {
var err error var err error
message, err := req.Chat.GetLatest() message, err := req.Chat.GetLatest()
@ -211,8 +218,11 @@ func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) e
toolResponse := client.ToolHandler.Handle(info, toolCall) toolResponse := client.ToolHandler.Handle(info, toolCall)
client.Log.SetLevel(log.DebugLevel) if toolCall.Function.Name == "reply" {
client.Log.Debugf("Response: %s", toolResponse.Content) client.Reply = toolCall.Function.Arguments
}
client.Log.Debug("Tool call", "name", toolCall.Function.Name, "arguments", toolCall.Function.Arguments, "response", toolResponse.Content)
req.Chat.AddToolResponse(toolResponse) req.Chat.AddToolResponse(toolResponse)
} }
@ -220,9 +230,12 @@ func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) e
return err return err
} }
func (client AgentClient) RunAgent(systemPrompt string, jsonTools string, endToolCall string, userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error { func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error {
var tools any var tools any
err := json.Unmarshal([]byte(jsonTools), &tools) err := json.Unmarshal([]byte(client.Options.JsonTools), &tools)
if err != nil {
panic(err)
}
toolChoice := "any" toolChoice := "any"
@ -231,7 +244,7 @@ func (client AgentClient) RunAgent(systemPrompt string, jsonTools string, endToo
ToolChoice: &toolChoice, ToolChoice: &toolChoice,
Model: "pixtral-12b-2409", Model: "pixtral-12b-2409",
Temperature: 0.3, Temperature: 0.3,
EndToolCall: endToolCall, EndToolCall: client.Options.EndToolCall,
ResponseFormat: ResponseFormat{ ResponseFormat: ResponseFormat{
Type: "text", Type: "text",
}, },
@ -240,17 +253,14 @@ func (client AgentClient) RunAgent(systemPrompt string, jsonTools string, endToo
}, },
} }
request.Chat.AddSystem(systemPrompt) request.Chat.AddSystem(client.Options.SystemPrompt)
request.Chat.AddImage(imageName, imageData) request.Chat.AddImage(imageName, imageData, client.Options.Query)
_, err = client.Request(&request)
if err != nil {
return err
}
toolHandlerInfo := ToolHandlerInfo{ toolHandlerInfo := ToolHandlerInfo{
ImageId: imageId, ImageId: imageId,
UserId: userId, ImageName: imageName,
UserId: userId,
Image: &imageData,
} }
return client.ToolLoop(toolHandlerInfo, &request) return client.ToolLoop(toolHandlerInfo, &request)

View File

@ -8,8 +8,12 @@ import (
) )
type ToolHandlerInfo struct { type ToolHandlerInfo struct {
UserId uuid.UUID UserId uuid.UUID
ImageId uuid.UUID ImageId uuid.UUID
ImageName string
// Pointer because we don't want to copy this around too much.
Image *[]byte
} }
type ToolHandler struct { type ToolHandler struct {

View File

@ -3,136 +3,139 @@ package agents
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"os"
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/google/uuid" "github.com/google/uuid"
) )
const contactPrompt = ` const contactPrompt = `
You are an agent that performs actions on contacts and people you find on an image. **Role:** You are an AI assistant specialized in processing contact information from images. Your primary function is to use the provided tools (listContacts, createContact, stopAgent) to manage contacts based on image analysis and signal when processing is complete.
You can use tools to achieve your task. **Primary Goal:** To accurately identify potential contacts in an image, check against existing contacts using the provided tools, create new contact entries when necessary (meticulously avoiding duplicates), and explicitly stop processing when finished or if no action is needed.
You should use listContacts to make sure that you don't create duplicate contacts. **Input:** You will be given an image that may contain contact information.
Call createContact when you see there is a new contact on this image. Do not create duplicate contacts. **Output Behavior (CRITICAL):**
Or call linkContact when you think this image contains an existing contact. * **If providing a text response:** Generate only the conversational text intended for the user in the response content. (Note: This should generally not happen in this workflow, as actions are handled by tools).
* **If using a tool:** Generate **only** the structured tool call request in the designated tool call section of the response. **Do NOT include the tool call JSON, parameters, or any description of your intention to call the tool within the main text/content response.** Your output must be strictly one or the other for a given turn: either text content OR a tool call structure.
Call finish if you dont think theres anything else to do. **Core Workflow:**
1. **Image Analysis:**
* Carefully scan the provided image to identify and extract any visible contact details (Name, Phone Number, Email Address, Physical Address). Extract *all* available information for each potential contact.
* **If NO potential contact information is found in the image, proceed directly to Step 5 (call stopAgent).**
2. **Duplicate Check (Mandatory First Step if contacts found):**
* If potential contact(s) were found in Step 1, you **must** call the listContacts tool first. **Generate only the listContacts tool call structure.**
* Once you receive the list, compare the extracted information against the existing contacts to determine if each identified person is already present.
* **If *all* identified potential contacts already exist in the list, proceed directly to Step 5 (call stopAgent).**
3. **Create New Contact (Conditional):**
* For each potential contact identified in Step 1 that your check in Step 2 confirms is *new*:
* Call the createContact tool with *all* corresponding extracted information (name, phoneNumber, address, email). name is mandatory. **Generate only the createContact tool call structure.**
* Process *one new contact creation per turn*. If multiple new contacts need creating, you will call createContact sequentially (one call per turn).
4. **Handling Multiple Contacts:**
* The workflow intrinsically handles multiple contacts by requiring a listContacts check first, followed by potential sequential createContact calls for each new individual found.
5. **Task Completion / No Action Needed:**
* Call the stopAgent tool **only** when one of the following conditions is met:
* No potential contact information was found in the initial image analysis (Step 1).
* The listContacts check confirmed that *all* potential contacts identified in the image already exist (Step 2).
* You have successfully processed all identified contacts (i.e., performed the listContacts check and called createContact for *all* new individuals found).
* **Generate only the stopAgent tool call structure.**
**Available Tools:**
* **listContacts**: Retrieves the existing contact list. **Must** be called first if potential contacts are found in the image, to enable duplicate checking.
* **createContact**: Adds a *new*, non-duplicate contact. Only call *after* listContacts confirms the person is new. name is mandatory.
* **stopAgent**: Signals that processing for the current image is complete (either action was taken, no action was needed, or all identified contacts already existed). Call this as the final step or when no other action is applicable based on the workflow.
` `
const contactTools = ` const contactTools = `
[ [
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "listContacts", "name": "listContacts",
"description": "List the users existing contacts", "description": "Retrieves the complete list of the user's currently saved contacts (e.g., names, phone numbers, emails if available in the stored data). This tool is essential and **must** be called *before* attempting to create a new contact if potential contact info is found in the image, to check if the person already exists and prevent duplicate entries.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
"required": [] "required": []
} }
}
},
{
"type": "function",
"function": {
"name": "createContact",
"description": "Creates a new contact",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "the name of the person"
},
"phoneNumber": {
"type": "string"
},
"address": {
"type": "string",
"description": "their physical address"
},
"email": {
"type": "string"
}
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "linkContact",
"description": "Links an existing contact with this image",
"parameters": {
"type": "object",
"properties": {
"contactId": {
"type": "string",
"description": "The UUID of the existing contact"
}
},
"required": ["contactId"]
}
}
},
{
"type": "function",
"function": {
"name": "finish",
"description": "Call when you dont think theres anything to do",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
} }
},
{
"type": "function",
"function": {
"name": "createContact",
"description": "Saves a new contact to the user's contact list. Only use this function **after** confirming the contact does not already exist by checking the output of listContacts. Provide all available extracted information for the new contact. Process one new contact per call.",
"parameters": {
"type": "object",
"properties": {
"contactId": {
"type": "string",
"description": "The UUID of the contact. You should only provide this IF you believe the contact already exists, from listContacts."
},
"name": {
"type": "string",
"description": "The full name of the person being added as a contact. This field is mandatory."
},
"phoneNumber": {
"type": "string",
"description": "The contact's primary phone number, including area or country code if available. Provide this if extracted from the image."
},
"address": {
"type": "string",
"description": "The complete physical mailing address of the contact (e.g., street number, street name, city, state/province, postal code, country). Provide this if extracted from the image."
},
"email": {
"type": "string",
"description": "The contact's primary email address. Provide this if extracted from the image."
}
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "stopAgent",
"description": "Use this tool to signal that the contact processing for the current image is complete. Call this *only* when: 1) No contact info was found initially, OR 2) All found contacts were confirmed to already exist after calling listContacts, OR 3) All necessary createContact calls for new individuals have been completed.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
}
] ]
` `
type ContactAgent struct {
client client.AgentClient
contactModel models.ContactModel
}
type listContactsArguments struct{} type listContactsArguments struct{}
type createContactsArguments struct { type createContactsArguments struct {
Name string `json:"name"` Name string `json:"name"`
ContactID *string `json:"contactId"`
PhoneNumber *string `json:"phoneNumber"` PhoneNumber *string `json:"phoneNumber"`
Address *string `json:"address"` Address *string `json:"address"`
Email *string `json:"email"` Email *string `json:"email"`
} }
type linkContactArguments struct {
ContactID string `json:"contactId"`
}
func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) { func NewContactAgent(log *log.Logger, contactModel models.ContactModel) client.AgentClient {
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{ agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
ReportTimestamp: true, SystemPrompt: contactPrompt,
TimeFormat: time.Kitchen, JsonTools: contactTools,
Prefix: "Contacts 👥", Log: log,
})) EndToolCall: "stopAgent",
if err != nil { })
return ContactAgent{}, err
}
agent := ContactAgent{
client: agentClient,
contactModel: contactModel,
}
agentClient.ToolHandler.AddTool("listContacts", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { agentClient.ToolHandler.AddTool("listContacts", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return agent.contactModel.List(context.Background(), info.UserId) return contactModel.List(context.Background(), info.UserId)
}) })
agentClient.ToolHandler.AddTool("createContact", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) { agentClient.ToolHandler.AddTool("createContact", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
@ -144,7 +147,18 @@ func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
ctx := context.Background() ctx := context.Background()
contact, err := agent.contactModel.Save(ctx, info.UserId, model.Contacts{ contactId := uuid.Nil
if args.ContactID != nil {
contactUuid, err := uuid.Parse(*args.ContactID)
if err != nil {
return model.Contacts{}, err
}
contactId = contactUuid
}
contact, err := contactModel.Save(ctx, info.UserId, model.Contacts{
ID: contactId,
Name: args.Name, Name: args.Name,
PhoneNumber: args.PhoneNumber, PhoneNumber: args.PhoneNumber,
Email: args.Email, Email: args.Email,
@ -154,7 +168,7 @@ func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
return model.Contacts{}, err return model.Contacts{}, err
} }
_, err = agent.contactModel.SaveToImage(ctx, info.ImageId, contact.ID) _, err = contactModel.SaveToImage(ctx, info.ImageId, contact.ID)
if err != nil { if err != nil {
return model.Contacts{}, err return model.Contacts{}, err
} }
@ -162,27 +176,5 @@ func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
return contact, nil return contact, nil
}) })
agentClient.ToolHandler.AddTool("linkContact", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) { return agentClient
args := linkContactArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return "", err
}
ctx := context.Background()
contactUuid, err := uuid.Parse(args.ContactID)
if err != nil {
return "", err
}
_, err = agent.contactModel.SaveToImage(ctx, info.ImageId, contactUuid)
if err != nil {
return "", err
}
return "Saved", nil
})
return agent, nil
} }

View File

@ -3,7 +3,6 @@ package agents
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"os"
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/models" "screenmark/screenmark/models"
@ -14,24 +13,43 @@ import (
) )
const eventPrompt = ` const eventPrompt = `
You are an agent. **Role:** You are an Event Processing AI Assistant specialized in extracting event information from images, managing event data using provided tools, and ensuring accuracy and avoiding duplicates.
The user will send you images and you have to identify if they have any events or a place. **Primary Goal:** To analyze images, identify potential events (like meetings, appointments, conferences, invitations), extract key details (name, date/time, location description), check against existing events, retrieve location identifiers if applicable, create new event entries when necessary, and signal completion using the 'finish' tool.
This could be a friend suggesting to meet, a conference, or anything that looks like an event.
There are various tools you can use to perform this task. **Core Workflow:**
listEvents **Duplicate Check (Mandatory if Event Found):**
Lists the users already existing events, you should do this before using createEvents to avoid creating duplicates. * If potential event details were found, you **must** call the listEvents tool first to check for duplicates. **Generate only the listEvents tool call structure.**
* Once you receive the list, compare the extracted event details (Name, Start Date/Time primarily) against the existing events.
* **If a matching event already exists, proceed directly to Step 6 (call finish).**
createEvent **Location ID Retrieval (Conditional):**
Use this to create a new events. * If the event is identified as *new* AND a *location description* was extracted.
* Call the getEventLocationId tool, providing the extracted location description. **Generate only the getEventLocationId tool call structure.**
linkEvent **Create Event:**
Links an image to a events. * If the event was identified as *new*:
* Prepare the parameters for the createEvent tool using the extracted details (Name, Start Date/Time, End Date/Time).
* If you identify the event as *duplicate*, meaning you think an event in listEvents is the same as the event on this image.
* Call the updateEvent tool so this image is also linked to that event. If you find any new information you can update it using this tool too.
finish **Handling Multiple Events:**
Call when there is nothing else to do. * If the image contains multiple distinct events, ideally process them one by one.
* Do this until there are no more events on this image
**Task Completion / No Action Needed:**
* Call the finish tool **only** when one of the following conditions is met:
* No identifiable event information was found in the initial image analysis.
* The listEvents check confirmed the identified event already exists.
* You have successfully called createEvent for a new event.
**Available Tools:**
* **listEvents**: Retrieves the user's existing events. **Must** be called first if potential event details are found in the image, to enable duplicate checking.
* **getEventLocationId**: Takes a location description (text) and retrieves a unique ID (locationId) for it. Use this *before* createEvent *only* if a new event has a specific location mentioned.
* **createEvent**: Adds a *new*, non-duplicate event to the user's calendar/list. Only call *after* listEvents confirms the event is new. Requires name. Include startDateTime, endDateTime, and locationId (if available and retrieved).
* **stopAgent**: Signals that processing for the current image is complete (either action was taken, no action was needed because the event already existed, or no event was found). Call this as the final step.
` `
const eventTools = ` const eventTools = `
@ -40,7 +58,7 @@ const eventTools = `
"type": "function", "type": "function",
"function": { "function": {
"name": "listEvents", "name": "listEvents",
"description": "List the events the user already has.", "description": "Retrieves the list of the user's currently scheduled events. Essential for checking if an event identified in the image already exists to prevent duplicates. Must be called before potentially creating an event.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@ -48,51 +66,75 @@ const eventTools = `
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "createEvent", "name": "createEvent",
"description": "Use to create a new events", "description": "Creates a new event in the user's calendar or list. Use only after listEvents confirms the event is new. Provide all extracted details.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"name": { "name": {
"type": "string" "type": "string",
"description": "The name or title of the event. This field is mandatory."
}, },
"startDateTime": { "startDateTime": {
"type": "string", "type": "string",
"description": "The start time as an ISO string" "description": "The event's start date and time in ISO 8601 format (e.g., '2025-04-18T10:00:00Z'). Include if available."
}, },
"endDateTime": { "endDateTime": {
"type": "string", "type": "string",
"description": "The end time as an ISO string" "description": "The event's end date and time in ISO 8601 format. Optional, include if available and different from startDateTime."
},
"locationId": {
"type": "string",
"description": "The unique identifier (UUID or similar) for the event's location. Use this if available, do not invent it."
} }
}, },
"required": ["name"] "required": ["name"]
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "linkEvent", "name": "updateEvent",
"description": "Use to link an already existing events to the image you were sent", "description": "Updates an existing event record identified by its eventId. Use this tool when listEvents indicates a match for the event details found in the current input.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"eventId": { "eventId": {
"type": "string" "type": "string",
} "description": "The UUID of the existing event"
}, }
"required": ["eventsId"] },
"required": ["eventId"]
}
}
},
{
"type": "function",
"function": {
"name": "getEventLocationId",
"description": "Retrieves a unique identifier for a location description associated with an event. Use this before createEvent if a new event specifies a location.",
"parameters": {
"type": "object",
"properties": {
"locationDescription": {
"type": "string",
"description": "The text describing the location extracted from the image (e.g., 'Conference Room B', '123 Main St, Anytown', 'Zoom Link details')."
}
},
"required": ["locationDescription"]
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "finish", "name": "stopAgent",
"description": "Call this when there is nothing left to do.", "description": "Call this tool only when event processing for the current image is fully complete. This occurs if: 1) No event info was found, OR 2) The found event already exists, OR 3) A new event has been successfully created.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@ -102,41 +144,32 @@ const eventTools = `
} }
]` ]`
type EventAgent struct {
client client.AgentClient
eventsModel models.EventModel
}
type listEventArguments struct{} type listEventArguments struct{}
type createEventArguments struct { type createEventArguments struct {
Name string `json:"name"` Name string `json:"name"`
StartDateTime *string `json:"startDateTime"` StartDateTime *string `json:"startDateTime"`
EndDateTime *string `json:"endDateTime"` EndDateTime *string `json:"endDateTime"`
OrganizerName *string `json:"organizerName"` OrganizerName *string `json:"organizerName"`
LocationID *string `json:"locationId"`
} }
type linkEventArguments struct { type updateEventArguments struct {
EventID string `json:"eventId"` EventID string `json:"eventId"`
} }
func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) { func NewEventAgent(log *log.Logger, eventsModel models.EventModel, locationModel models.LocationModel) client.AgentClient {
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{ agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
ReportTimestamp: true, SystemPrompt: eventPrompt,
TimeFormat: time.Kitchen, JsonTools: eventTools,
Prefix: "Events 📍", Log: log,
})) EndToolCall: "stopAgent",
})
if err != nil { locationAgent := NewLocationAgentWithComm(log.WithPrefix("Events 📅 > Locations 📍"), locationModel)
return EventAgent{}, err locationQuery := "Can you get me the ID of the location present in this image?"
} locationAgent.Options.Query = &locationQuery
agent := EventAgent{
client: agentClient,
eventsModel: eventsModel,
}
agentClient.ToolHandler.AddTool("listEvents", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { agentClient.ToolHandler.AddTool("listEvents", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return agent.eventsModel.List(context.Background(), info.UserId) return eventsModel.List(context.Background(), info.UserId)
}) })
agentClient.ToolHandler.AddTool("createEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) { agentClient.ToolHandler.AddTool("createEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
@ -160,17 +193,23 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
return model.Events{}, err return model.Events{}, err
} }
events, err := agent.eventsModel.Save(ctx, info.UserId, model.Events{ locationId, err := uuid.Parse(*args.LocationID)
if err != nil {
return model.Events{}, err
}
events, err := eventsModel.Save(ctx, info.UserId, model.Events{
Name: args.Name, Name: args.Name,
StartDateTime: &startTime, StartDateTime: &startTime,
EndDateTime: &endTime, EndDateTime: &endTime,
LocationID: &locationId,
}) })
if err != nil { if err != nil {
return model.Events{}, err return model.Events{}, err
} }
_, err = agent.eventsModel.SaveToImage(ctx, info.ImageId, events.ID) _, err = eventsModel.SaveToImage(ctx, info.ImageId, events.ID)
if err != nil { if err != nil {
return model.Events{}, err return model.Events{}, err
} }
@ -178,8 +217,8 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
return events, nil return events, nil
}) })
agentClient.ToolHandler.AddTool("linkEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) { agentClient.ToolHandler.AddTool("updateEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := linkEventArguments{} args := updateEventArguments{}
err := json.Unmarshal([]byte(_args), &args) err := json.Unmarshal([]byte(_args), &args)
if err != nil { if err != nil {
return "", err return "", err
@ -192,9 +231,17 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
return "", err return "", err
} }
agent.eventsModel.SaveToImage(ctx, info.ImageId, contactUuid) eventsModel.SaveToImage(ctx, info.ImageId, contactUuid)
return "Saved", nil return "Saved", nil
}) })
return agent, nil agentClient.ToolHandler.AddTool("getEventLocationId", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
// TODO: reenable this when I'm creating the agent locally instead of getting it from above.
locationAgent.RunAgent(info.UserId, info.ImageId, info.ImageName, *info.Image)
log.Debugf("Reply from location %s\n", locationAgent.Reply)
return locationAgent.Reply, nil
})
return agentClient
} }

View File

@ -3,43 +3,72 @@ package agents
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"os" "fmt"
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/google/uuid" "github.com/google/uuid"
) )
const locationPrompt = ` const locationPrompt = `
You are an agent. Role: Location AI Assistant
The user will send you images and you have to identify if they have any location or a place. This could a picture of a real place, an address, or it's name. Objective: Identify locations from images/text, manage a saved list (create, update), and answer user queries about saved locations using the provided tools.
There are various tools you can use to perform this task. Core Logic:
listLocations **Extract Location Details:** Attempt to extract location details (like InputName, InputAddress) from the user's input (image or text).
Lists the users already existing locations, you should do this before using createLocation to avoid creating duplicates. * If no details can be extracted, inform the user and use stopAgent.
createLocation **Check for Existing Location:** If details *were* extracted:
Use this to create a new location. Avoid making duplicates and only create a new location if listLocations doesnt contain the location on the image. * Use listLocations with the extracted InputName and/or InputAddress to search for potentially matching locations already saved in the list.
linkLocation **Decide Action based on Search Results:**
Links an image to a location. * **If listLocations returns one or more likely matches:**
* Identify the *best* match (based on name, address similarity).
* **Crucially:** Call upsertLocation, providing the locationId of that best match. Include the newly extracted InputName (required) and any other extracted details (InputAddress, etc.) to potentially *update* the existing record or simply link the current input to it.
* **If listLocations returns no matches OR no returned location is a confident match:**
* Call upsertLocation providing *only* the newly extracted InputName (required) and any other extracted details (InputAddress, etc.). **Do NOT provide a locationId in this case.** This will create a *new* location entry.
finish 4. **Finalize:** After successfully calling upsertLocation (or determining no action could be taken), use stopAgent.
Call when there is nothing else to do.
Tool Usage:
* **listLocations**: Searches the saved locations list based on provided criteria (like name or address). Used specifically to check if a location potentially already exists before using upsertLocation. Returns a list of matching locations, *each including its locationId*.
* **upsertLocation**: Creates or updates a location in the saved list. Requires name. Can include address, etc.
* **To UPDATE:** If you identified an existing location using listLocations, provide its locationId along with any new/updated details (name, address, etc.).
* **To CREATE:** If no existing location was found (or you are creating intentionally), provide the location details (name, address, etc.) but **omit the locationId**.
* **stopAgent**: Signals the end of the agent's processing for the current turn. Call this *after* completing the location task (create/update/failed extraction).
` `
const replyTool = `
{
"type": "function",
"function": {
"name": "reply",
"description": "Signals intent to provide information about a specific known location in response to a user's query. Use only if the user asked a question and the location's ID was found via listLocations.",
"parameters": {
"type": "object",
"properties": {
"locationId": {
"type": "string",
"description": "The unique identifier of the saved location that the user is asking about."
}
},
"required": ["locationId"]
}
}
},`
const locationTools = ` const locationTools = `
[ [
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "listLocations", "name": "listLocations",
"description": "List the locations the user already has.", "description": "Retrieves the list of the user's currently saved locations (names, addresses, IDs). Use this first to check if a location from an image already exists, or to find the ID of a location the user is asking about.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@ -47,92 +76,83 @@ const locationTools = `
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "createLocation", "name": "upsertLocation",
"description": "Use to create a new location", "description": "Upserts a location. This is used for both creating new locations, and updating existing ones. Providing locationId from an existing ID from listLocations, will make this an update function. Not providing one will create a new location. You must provide a locationId if you think the input is a location that already exists.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"name": { "name": {
"type": "string" "type": "string",
"description": "The primary name of the location (e.g., 'Eiffel Tower', 'Mom's House', 'Acme Corp HQ'). This field is mandatory."
}, },
"locationId": {
"type": "string",
"description": "The UUID of the location. You should only provide this IF you believe the location already exists, from listLocation."
},
"address": { "address": {
"type": "string" "type": "string",
"description": "The full street address of the location, if available (e.g., 'Champ de Mars, 5 Av. Anatole France, 75007 Paris, France'). Include if extracted."
} }
}, },
"required": ["name"] "required": ["name"]
} }
} }
}, },
%s
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "linkLocation", "name": "stopAgent",
"description": "Use to link an already existing location to the image you were sent", "description": "Use this tool to signal that the contact processing for the current image is complete.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {},
"locationId": { "required": []
"type": "string" }
} }
}, }
"required": ["locationId"]
}
}
},
{
"type": "function",
"function": {
"name": "finish",
"description": "Call this when there is nothing left to do.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
}
]` ]`
type LocationAgent struct { func getLocationAgentTools(allowReply bool) string {
client client.AgentClient if allowReply {
return fmt.Sprintf(locationTools, replyTool)
locationModel models.LocationModel } else {
return fmt.Sprintf(locationTools, "")
}
} }
type listLocationArguments struct{} type listLocationArguments struct{}
type createLocationArguments struct { type upsertLocationArguments struct {
Name string `json:"name"` Name string `json:"name"`
Address *string `json:"address"` LocationID *string `json:"locationId"`
} Address *string `json:"address"`
type linkLocationArguments struct {
LocationID string `json:"locationId"`
} }
func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error) { func NewLocationAgentWithComm(log *log.Logger, locationModel models.LocationModel) client.AgentClient {
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{ client := NewLocationAgent(log, locationModel)
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: "Locations 📍",
}))
if err != nil { client.Options.JsonTools = getLocationAgentTools(true)
return LocationAgent{}, err
}
agent := LocationAgent{ return client
client: agentClient, }
locationModel: locationModel,
}
agentClient.ToolHandler.AddTool("listLocations", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { func NewLocationAgent(log *log.Logger, locationModel models.LocationModel) client.AgentClient {
return agent.locationModel.List(context.Background(), info.UserId) agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: locationPrompt,
JsonTools: getLocationAgentTools(false),
Log: log,
EndToolCall: "stopAgent",
}) })
agentClient.ToolHandler.AddTool("createLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) { agentClient.ToolHandler.AddTool("listLocations", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
args := createLocationArguments{} return locationModel.List(context.Background(), info.UserId)
})
agentClient.ToolHandler.AddTool("upsertLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := upsertLocationArguments{}
err := json.Unmarshal([]byte(_args), &args) err := json.Unmarshal([]byte(_args), &args)
if err != nil { if err != nil {
return model.Locations{}, err return model.Locations{}, err
@ -140,7 +160,18 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
ctx := context.Background() ctx := context.Background()
location, err := agent.locationModel.Save(ctx, info.UserId, model.Locations{ locationId := uuid.Nil
if args.LocationID != nil {
locationUuid, err := uuid.Parse(*args.LocationID)
if err != nil {
return model.Locations{}, err
}
locationId = locationUuid
}
location, err := locationModel.Save(ctx, info.UserId, model.Locations{
ID: locationId,
Name: args.Name, Name: args.Name,
Address: args.Address, Address: args.Address,
}) })
@ -149,7 +180,7 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
return model.Locations{}, err return model.Locations{}, err
} }
_, err = agent.locationModel.SaveToImage(ctx, info.ImageId, location.ID) _, err = locationModel.SaveToImage(ctx, info.ImageId, location.ID)
if err != nil { if err != nil {
return model.Locations{}, err return model.Locations{}, err
} }
@ -157,23 +188,9 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
return location, nil return location, nil
}) })
agentClient.ToolHandler.AddTool("linkLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) { agentClient.ToolHandler.AddTool("reply", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
args := linkLocationArguments{} return "ok", nil
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return "", err
}
ctx := context.Background()
contactUuid, err := uuid.Parse(args.LocationID)
if err != nil {
return "", err
}
agent.locationModel.SaveToImage(ctx, info.ImageId, contactUuid)
return "Saved", nil
}) })
return agent, nil return agentClient
} }

View File

@ -2,11 +2,9 @@ package agents
import ( import (
"context" "context"
"os"
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/google/uuid" "github.com/google/uuid"
@ -43,7 +41,7 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
} }
request.Chat.AddSystem(noteAgentPrompt) request.Chat.AddSystem(noteAgentPrompt)
request.Chat.AddImage(imageName, imageData) request.Chat.AddImage(imageName, imageData, nil)
resp, err := agent.client.Request(&request) resp, err := agent.client.Request(&request)
if err != nil { if err != nil {
@ -70,20 +68,16 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
return nil return nil
} }
func NewNoteAgent(noteModel models.NoteModel) (NoteAgent, error) { func NewNoteAgent(log *log.Logger, noteModel models.NoteModel) NoteAgent {
client, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{ client := client.CreateAgentClient(client.CreateAgentClientOptions{
ReportTimestamp: true, SystemPrompt: noteAgentPrompt,
TimeFormat: time.Kitchen, Log: log,
Prefix: "Notes 📝", })
}))
if err != nil {
return NoteAgent{}, err
}
agent := NoteAgent{ agent := NoteAgent{
client: client, client: client,
noteModel: noteModel, noteModel: noteModel,
} }
return agent, nil return agent
} }

View File

@ -1,52 +1,42 @@
package agents package agents
import ( import (
"errors"
"os"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"time"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
) )
const OrchestratorPrompt = ` const orchestratorPrompt = `
You are an Orchestrator for various AI agents. **Role:** You are an Orchestrator AI responsible for analyzing images provided by the user.
The user will send you images and you have to determine which agents you have to call, in order to best help the user. **Primary Task:** Examine the input image and determine which specialized AI agent(s), available as tool calls, should be invoked to process the relevant information within the image, or if no specialized processing is needed. Your goal is to either extract and structure useful information for the user by selecting the most appropriate tool(s) or explicitly indicate that no specific action is required.
You might decide no agent needs to be called. **Analysis Process & Decision Logic:**
The agents are available as tool calls. 1. **Analyze Image Content:** Scrutinize the image for distinct types of information:
* General text/writing (including code, formulas)
* Information about a person or contact details
* Information about a place, location, or address
* Information about an event
* Content that doesn't fit any specific category or lacks actionable information.
Agents available: 2. **Agent Selection - Determine ALL that apply:**
* **contactAgent:** Is there information specifically related to a person or their contact details (e.g., business card, name/email/phone)? If YES, select contactAgent.
* **locationAgent:** Is there information specifically identifying a place, location, or address (e.g., map, street sign, address text)? If YES, select locationAgent.
* **eventAgent:** Is there information specifically related to an event (e.g., invitation, poster with date/time, schedule)? If YES, select eventAgent.
* **noteAgent** Does the image contain *any* text/writing (including code, formulas)?
* **noAgent**: Call this when you are done working on this image.
noteAgent * Call all applicable specialized agents (noteAgent, contactAgent, locationAgent, eventAgent) simultaneously (in parallel).
Use when there is ANY text on the image.
contactAgent
Use it when the image contains information relating a person.
locationAgent
Use it when the image contains some address or a place.
eventAgent
Use it when the image contains an event, this can be a date, a message suggesting an event.
noAction
When you think there is no more information to extract from the image.
Always call agents in parallel if you need to call more than 1.
Do not call the agent if you do not think it is relevant for the image.
` `
const OrchestratorTools = ` const orchestratorTools = `
[ [
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "noteAgent", "name": "noteAgent",
"description": "Use when there is any text on the image, this can be code/text/formulas any writing", "description": "Extracts general textual content like handwritten notes, paragraphs in documents, presentation slides, code snippets, or mathematical formulas. Use this for significant text that isn't primarily contact details, an address, or specific event information.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@ -54,11 +44,11 @@ const OrchestratorTools = `
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "contactAgent", "name": "contactAgent",
"description": "Use when then image contains some person or contact", "description": "Extracts personal contact information. Use when the image clearly shows details like names, phone numbers, email addresses, job titles, or company names, especially from sources like business cards, email signatures, or contact lists.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@ -66,11 +56,11 @@ const OrchestratorTools = `
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "locationAgent", "name": "locationAgent",
"description": "Use when then image contains some place, location or address", "description": "Identifies and extracts specific geographic locations or addresses. Use for content like street addresses on mail or signs, place names (e.g., restaurant, shop), map snippets, or recognizable landmarks.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@ -78,11 +68,11 @@ const OrchestratorTools = `
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "eventAgent", "name": "eventAgent",
"description": "Use when then image contains some event", "description": "Extracts details related to scheduled events, appointments, or specific occasions. Use when the image contains information like event titles, dates, times, venues, agendas, or descriptions, typically found on invitations, posters, calendar entries, or schedules.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@ -93,8 +83,8 @@ const OrchestratorTools = `
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "noAction", "name": "noAgent",
"description": "Use when you are sure nothing can be done about this image anymore", "description": "Extracts details related to scheduled events, appointments, or specific occasions. Use when the image contains information like event titles, dates, times, venues, agendas, or descriptions, typically found on invitations, posters, calendar entries, or schedules.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@ -102,7 +92,8 @@ const OrchestratorTools = `
} }
} }
} }
]` ]
`
type OrchestratorAgent struct { type OrchestratorAgent struct {
Client client.AgentClient Client client.AgentClient
@ -114,58 +105,41 @@ type Status struct {
Ok bool `json:"ok"` Ok bool `json:"ok"`
} }
func NewOrchestratorAgent(noteAgent NoteAgent, contactAgent ContactAgent, locationAgent LocationAgent, eventAgent EventAgent, imageName string, imageData []byte) (OrchestratorAgent, error) { func NewOrchestratorAgent(log *log.Logger, noteAgent NoteAgent, contactAgent client.AgentClient, locationAgent client.AgentClient, eventAgent client.AgentClient, imageName string, imageData []byte) client.AgentClient {
agent, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{ agent := client.CreateAgentClient(client.CreateAgentClientOptions{
ReportTimestamp: true, SystemPrompt: orchestratorPrompt,
TimeFormat: time.Kitchen, JsonTools: orchestratorTools,
Prefix: "Orchestrator 🎼", Log: log,
})) EndToolCall: "noAgent",
})
if err != nil {
return OrchestratorAgent{}, err
}
agent.ToolHandler.AddTool("noteAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { agent.ToolHandler.AddTool("noteAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go noteAgent.GetNotes(info.UserId, info.ImageId, imageName, imageData) // go noteAgent.GetNotes(info.UserId, info.ImageId, imageName, imageData)
return Status{ return "noteAgent called successfully", nil
Ok: true,
}, nil
}) })
agent.ToolHandler.AddTool("contactAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { agent.ToolHandler.AddTool("contactAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go contactAgent.client.RunAgent(contactPrompt, contactTools, "finish", info.UserId, info.ImageId, imageName, imageData) go contactAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
return Status{ return "contactAgent called successfully", nil
Ok: true,
}, nil
}) })
agent.ToolHandler.AddTool("locationAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { agent.ToolHandler.AddTool("locationAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go locationAgent.client.RunAgent(locationPrompt, locationTools, "finish", info.UserId, info.ImageId, imageName, imageData) // go locationAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
return Status{ return "locationAgent called successfully", nil
Ok: true,
}, nil
}) })
agent.ToolHandler.AddTool("eventAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { agent.ToolHandler.AddTool("eventAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go eventAgent.client.RunAgent(eventPrompt, eventTools, "finish", info.UserId, info.ImageId, imageName, imageData) // go eventAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
return Status{ return "eventAgent called successfully", nil
Ok: true,
}, nil
}) })
agent.ToolHandler.AddTool("noAction", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) { agent.ToolHandler.AddTool("noAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
// To nothing return "ok", nil
return Status{
Ok: true,
}, errors.New("Finished! Kinda bad return type but...")
}) })
return OrchestratorAgent{ return agent
Client: agent,
}, nil
} }

View File

@ -2,7 +2,6 @@ package main
import ( import (
"errors" "errors"
"fmt"
"math/rand" "math/rand"
"time" "time"
) )
@ -18,7 +17,7 @@ type Auth struct {
mailer Mailer mailer Mailer
} }
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz") var letterRunes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
func randString(n int) string { func randString(n int) string {
b := make([]rune, n) b := make([]rune, n)
@ -44,7 +43,6 @@ func (a *Auth) CreateCode(email string) error {
} }
func (a *Auth) IsCodeValid(email string, code string) bool { func (a *Auth) IsCodeValid(email string, code string) bool {
fmt.Println(a.codes)
existingCode, exists := a.codes[email] existingCode, exists := a.codes[email]
if !exists { if !exists {
return false return false
@ -55,7 +53,6 @@ func (a *Auth) IsCodeValid(email string, code string) bool {
func (a *Auth) UseCode(email string, code string) error { func (a *Auth) UseCode(email string, code string) error {
if valid := a.IsCodeValid(email, code); !valid { if valid := a.IsCodeValid(email, code); !valid {
fmt.Println("returning error?")
return errors.New("This code is invalid.") return errors.New("This code is invalid.")
} }

23
backend/builder/agents.go Normal file
View File

@ -0,0 +1,23 @@
package builder
import (
"context"
"database/sql"
"screenmark/screenmark/.gen/haystack/agents/model"
. "screenmark/screenmark/.gen/haystack/agents/table"
. "github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
)
type AgentModel struct {
dbPool *sql.DB
}
func (m AgentModel) SaveTool(ctx context.Context, agentId uuid.UUID, tool string) (model.Tools, error) {
insertToolStmt := Tools.
INSERT(Tools.Tool).
VALUES(Json(tool))
}

View File

@ -3,17 +3,28 @@ package main
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"log"
"os" "os"
"screenmark/screenmark/agents" "screenmark/screenmark/agents"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"time" "time"
"github.com/charmbracelet/log"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq" "github.com/lib/pq"
) )
func createLogger(prefix string) *log.Logger {
logger := log.NewWithOptions(os.Stdout, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
Prefix: prefix,
})
logger.SetLevel(log.DebugLevel)
return logger
}
func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) { func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) {
listener := pq.NewListener(os.Getenv("DB_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 { if err != nil {
@ -28,6 +39,9 @@ func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) {
imageModel := models.NewImageModel(db) imageModel := models.NewImageModel(db)
contactModel := models.NewContactModel(db) contactModel := models.NewContactModel(db)
databaseEventLog := createLogger("Database Events 🤖")
databaseEventLog.SetLevel(log.DebugLevel)
err := listener.Listen("new_image") err := listener.Listen("new_image")
if err != nil { if err != nil {
panic(err) panic(err)
@ -39,55 +53,41 @@ func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) {
imageId := uuid.MustParse(parameters.Extra) imageId := uuid.MustParse(parameters.Extra)
eventManager.listeners[parameters.Extra] = make(chan string) eventManager.listeners[parameters.Extra] = make(chan string)
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
ctx := context.Background() ctx := context.Background()
go func() { go func() {
noteAgent, err := agents.NewNoteAgent(noteModel) noteAgent := agents.NewNoteAgent(createLogger("Notes 📝"), noteModel)
if err != nil { contactAgent := agents.NewContactAgent(createLogger("Contacts 👥"), contactModel)
panic(err) locationAgent := agents.NewLocationAgent(createLogger("Locations 📍"), locationModel)
} eventAgent := agents.NewEventAgent(createLogger("Events 📅"), eventModel, locationModel)
contactAgent, err := agents.NewContactAgent(contactModel)
if err != nil {
panic(err)
}
locationAgent, err := agents.NewLocationAgent(locationModel)
if err != nil {
panic(err)
}
eventAgent, err := agents.NewEventAgent(eventModel)
if err != nil {
panic(err)
}
image, err := imageModel.GetToProcessWithData(ctx, imageId) image, err := imageModel.GetToProcessWithData(ctx, imageId)
if err != nil { if err != nil {
log.Println("Failed to GetToProcessWithData") databaseEventLog.Error("Failed to GetToProcessWithData", "error", err)
log.Println(err)
return return
} }
if err := imageModel.StartProcessing(ctx, image.ID); err != nil { if err := imageModel.StartProcessing(ctx, image.ID); err != nil {
log.Println("Failed to FinishProcessing") databaseEventLog.Error("Failed to FinishProcessing", "error", err)
log.Println(err)
return return
} }
orchestrator, err := agents.NewOrchestratorAgent(noteAgent, contactAgent, locationAgent, eventAgent, image.Image.ImageName, image.Image.Image) orchestrator := agents.NewOrchestratorAgent(createLogger("Orchestrator 🎼"), noteAgent, contactAgent, locationAgent, eventAgent, image.Image.ImageName, image.Image.Image)
err = orchestrator.RunAgent(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
if err != nil { if err != nil {
panic(err) databaseEventLog.Error("Orchestrator failed", "error", err)
return
} }
// Still need to find some way to hide this complexity away. _, err = imageModel.FinishProcessing(ctx, image.ID)
// I don't think wrapping agents in structs actually works too well.
err = orchestrator.Client.RunAgent(agents.OrchestratorPrompt, agents.OrchestratorTools, "noAction", image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
if err != nil { if err != nil {
log.Println(err) databaseEventLog.Error("Failed to finish processing", "ImageID", imageId)
return
} }
imageModel.FinishProcessing(ctx, image.ID) databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
}() }()
} }
} }
@ -122,9 +122,6 @@ func ListenProcessingImageStatus(db *sql.DB, eventManager *EventManager) {
stringUuid := data.Extra[0:36] stringUuid := data.Extra[0:36]
status := data.Extra[36:] status := data.Extra[36:]
fmt.Printf("UUID: %s\n", stringUuid)
fmt.Printf("Receiving :s\n", data.Extra)
imageListener, exists := eventManager.listeners[stringUuid] imageListener, exists := eventManager.listeners[stringUuid]
if !exists { if !exists {
continue continue

View File

@ -91,19 +91,36 @@ func main() {
} }
dataTypes := make([]DataType, 0) dataTypes := make([]DataType, 0)
// lord
// forgive me
idMap := make(map[uuid.UUID]bool)
for _, image := range images { for _, image := range images {
for _, location := range image.Locations { for _, location := range image.Locations {
_, exists := idMap[location.ID]
if exists {
continue
}
dataTypes = append(dataTypes, DataType{ dataTypes = append(dataTypes, DataType{
Type: "location", Type: "location",
Data: location, Data: location,
}) })
idMap[location.ID] = true
} }
for _, event := range image.Events { for _, event := range image.Events {
_, exists := idMap[event.ID]
if exists {
continue
}
dataTypes = append(dataTypes, DataType{ dataTypes = append(dataTypes, DataType{
Type: "event", Type: "event",
Data: event, Data: event,
}) })
idMap[event.ID] = true
} }
for _, note := range image.Notes { for _, note := range image.Notes {
@ -111,13 +128,20 @@ func main() {
Type: "note", Type: "note",
Data: note, Data: note,
}) })
idMap[note.ID] = true
} }
for _, contact := range image.Contacts { for _, contact := range image.Contacts {
_, exists := idMap[contact.ID]
if exists {
continue
}
dataTypes = append(dataTypes, DataType{ dataTypes = append(dataTypes, DataType{
Type: "contact", Type: "contact",
Data: contact, Data: contact,
}) })
idMap[contact.ID] = true
} }
} }
@ -333,6 +357,12 @@ func main() {
return return
} }
if exists := userModel.DoesUserExist(r.Context(), codeBody.Email); !exists {
userModel.Save(r.Context(), model.Users{
Email: codeBody.Email,
})
}
uuid, err := userModel.GetUserIdFromEmail(r.Context(), codeBody.Email) uuid, err := userModel.GetUserIdFromEmail(r.Context(), codeBody.Email)
if err != nil { if err != nil {
log.Println(err) log.Println(err)

View File

@ -29,9 +29,56 @@ func (m ContactModel) List(ctx context.Context, userId uuid.UUID) ([]model.Conta
return locations, err return locations, err
} }
func (m ContactModel) Get(ctx context.Context, contactId uuid.UUID) (model.Contacts, error) {
getContactStmt := Contacts.
SELECT(Contacts.AllColumns).
WHERE(Contacts.ID.EQ(UUID(contactId)))
contact := model.Contacts{}
err := getContactStmt.QueryContext(ctx, m.dbPool, &contact)
return contact, err
}
func (m ContactModel) Update(ctx context.Context, contact model.Contacts) (model.Contacts, error) {
existingContact, err := m.Get(ctx, contact.ID)
if err != nil {
return model.Contacts{}, err
}
existingContact.Name = contact.Name
if contact.Description != nil {
existingContact.Description = contact.Description
}
if contact.PhoneNumber != nil {
existingContact.PhoneNumber = contact.PhoneNumber
}
if contact.Email != nil {
existingContact.Email = contact.Email
}
updateContactStmt := Contacts.
UPDATE(Contacts.MutableColumns).
MODEL(existingContact).
WHERE(Contacts.ID.EQ(UUID(contact.ID))).
RETURNING(Contacts.AllColumns)
updatedContact := model.Contacts{}
err = updateContactStmt.QueryContext(ctx, m.dbPool, &updatedContact)
return updatedContact, err
}
func (m ContactModel) Save(ctx context.Context, userId uuid.UUID, contact model.Contacts) (model.Contacts, error) { func (m ContactModel) Save(ctx context.Context, userId uuid.UUID, contact model.Contacts) (model.Contacts, error) {
// TODO: make this a transaction // TODO: make this a transaction
if contact.ID != uuid.Nil {
return m.Update(ctx, contact)
}
insertContactStmt := Contacts. insertContactStmt := Contacts.
INSERT(Contacts.Name, Contacts.Description, Contacts.PhoneNumber, Contacts.Email). INSERT(Contacts.Name, Contacts.Description, Contacts.PhoneNumber, Contacts.Email).
VALUES(contact.Name, contact.Description, contact.PhoneNumber, contact.Email). VALUES(contact.Name, contact.Description, contact.PhoneNumber, contact.Email).

View File

@ -31,8 +31,8 @@ func (m EventModel) List(ctx context.Context, userId uuid.UUID) ([]model.Events,
func (m EventModel) Save(ctx context.Context, userId uuid.UUID, event model.Events) (model.Events, error) { func (m EventModel) Save(ctx context.Context, userId uuid.UUID, event model.Events) (model.Events, error) {
// TODO tx here // TODO tx here
insertEventStmt := Events. insertEventStmt := Events.
INSERT(Events.Name, Events.Description, Events.StartDateTime, Events.EndDateTime). INSERT(Events.MutableColumns).
VALUES(event.Name, event.Description, event.StartDateTime, event.EndDateTime). MODEL(event).
RETURNING(Events.AllColumns) RETURNING(Events.AllColumns)
insertedEvent := model.Events{} insertedEvent := model.Events{}

View File

@ -30,7 +30,50 @@ func (m LocationModel) List(ctx context.Context, userId uuid.UUID) ([]model.Loca
return locations, err return locations, err
} }
func (m LocationModel) Get(ctx context.Context, locationId uuid.UUID) (model.Locations, error) {
getLocationStmt := Locations.
SELECT(Locations.AllColumns).
WHERE(Locations.ID.EQ(UUID(locationId)))
location := model.Locations{}
err := getLocationStmt.QueryContext(ctx, m.dbPool, &location)
return location, err
}
func (m LocationModel) Update(ctx context.Context, location model.Locations) (model.Locations, error) {
existingLocation, err := m.Get(ctx, location.ID)
if err != nil {
return model.Locations{}, err
}
existingLocation.Name = location.Name
if location.Description != nil {
existingLocation.Description = location.Description
}
if location.Address != nil {
existingLocation.Address = location.Address
}
updateLocationStmt := Locations.
UPDATE(Locations.MutableColumns).
MODEL(existingLocation).
WHERE(Locations.ID.EQ(UUID(location.ID))).
RETURNING(Locations.AllColumns)
updatedLocation := model.Locations{}
err = updateLocationStmt.QueryContext(ctx, m.dbPool, &updatedLocation)
return updatedLocation, err
}
func (m LocationModel) Save(ctx context.Context, userId uuid.UUID, location model.Locations) (model.Locations, error) { func (m LocationModel) Save(ctx context.Context, userId uuid.UUID, location model.Locations) (model.Locations, error) {
if location.ID != uuid.Nil {
return m.Update(ctx, location)
}
insertLocationStmt := Locations. insertLocationStmt := Locations.
INSERT(Locations.Name, Locations.Address, Locations.Description). INSERT(Locations.Name, Locations.Address, Locations.Description).
VALUES(location.Name, location.Address, location.Description). VALUES(location.Name, location.Address, location.Description).

View File

@ -4,12 +4,12 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"log" "log"
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
. "screenmark/screenmark/.gen/haystack/haystack/table" . "screenmark/screenmark/.gen/haystack/haystack/table"
. "github.com/go-jet/jet/v2/postgres" . "github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -30,17 +30,9 @@ type ImageWithProperties struct {
Text []model.ImageText Text []model.ImageText
Locations []model.Locations Locations []model.Locations
Events []model.Events
Events []struct { Notes []model.Notes
model.Events Contacts []model.Contacts
Location *model.Locations
Organizer *model.Contacts
}
Notes []model.Notes
Contacts []model.Contacts
} }
func getUserIdFromImage(ctx context.Context, dbPool *sql.DB, imageId uuid.UUID) (uuid.UUID, error) { func getUserIdFromImage(ctx context.Context, dbPool *sql.DB, imageId uuid.UUID) (uuid.UUID, error) {
@ -95,11 +87,9 @@ func (m UserModel) ListWithProperties(ctx context.Context, userId uuid.UUID) ([]
LEFT_JOIN(Notes, Notes.ID.EQ(ImageNotes.NoteID))). LEFT_JOIN(Notes, Notes.ID.EQ(ImageNotes.NoteID))).
WHERE(UserImages.UserID.EQ(UUID(userId))) WHERE(UserImages.UserID.EQ(UUID(userId)))
fmt.Println(listWithPropertiesStmt.DebugSql())
images := []ImageWithProperties{} images := []ImageWithProperties{}
err := listWithPropertiesStmt.QueryContext(ctx, m.dbPool, &images) err := listWithPropertiesStmt.QueryContext(ctx, m.dbPool, &images)
if err != nil { if err != nil {
return images, err return images, err
} }
@ -115,6 +105,24 @@ func (m UserModel) GetUserIdFromEmail(ctx context.Context, email string) (uuid.U
return user.ID, err return user.ID, err
} }
func (m UserModel) DoesUserExist(ctx context.Context, email string) bool {
getUserIdStmt := Users.SELECT(Users.ID).WHERE(Users.Email.EQ(String(email)))
user := model.Users{}
err := getUserIdStmt.QueryContext(ctx, m.dbPool, &user)
return err != qrm.ErrNoRows
}
func (m UserModel) Save(ctx context.Context, user model.Users) (model.Users, error) {
insertUserStmt := Users.INSERT(Users.Email).VALUES(user.Email).RETURNING(Users.AllColumns)
insertedUser := model.Users{}
err := insertUserStmt.QueryContext(ctx, m.dbPool, &insertedUser)
return insertedUser, err
}
func NewUserModel(db *sql.DB) UserModel { func NewUserModel(db *sql.DB) UserModel {
return UserModel{dbPool: db} return UserModel{dbPool: db}
} }

View File

@ -1,6 +1,10 @@
DROP SCHEMA IF EXISTS haystack CASCADE; DROP SCHEMA IF EXISTS haystack CASCADE;
DROP SCHEMA IF EXISTS agents CASCADE;
CREATE SCHEMA haystack; CREATE SCHEMA haystack;
CREATE SCHEMA agents;
/** -----| Haystack |----- **/
/* -----| Enums |----- */ /* -----| Enums |----- */
@ -181,6 +185,29 @@ ON haystack.user_images_to_process
FOR EACH ROW FOR EACH ROW
EXECUTE PROCEDURE notify_new_processing_image_status(); EXECUTE PROCEDURE notify_new_processing_image_status();
/** -----| Agents |----- **/
/* -----| Schema tables |----- */
CREATE TABLE agents.agents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL
);
CREATE TABLE agents.system_prompts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
prompt TEXT NOT NULL,
agent_id UUID NOT NULL REFERENCES agents.agents (id)
);
CREATE TABLE agents.tools (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tool JSONB NOT NULL,
agent_id UUID NOT NULL REFERENCES agents.agents (id)
);
/* -----| Test Data |----- */ /* -----| Test Data |----- */
-- Insert a user -- Insert a user

Binary file not shown.

View File

@ -1,43 +1,46 @@
{ {
"name": "haystack", "name": "haystack",
"version": "0.1.0", "version": "0.1.0",
"description": "", "description": "Screenshots that organize themselves",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"lint": "bunx @biomejs/biome lint .", "lint": "bunx @biomejs/biome lint .",
"format": "bunx @biomejs/biome format . --write" "format": "bunx @biomejs/biome format . --write"
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@kobalte/core": "^0.13.9", "@kobalte/core": "^0.13.9",
"@kobalte/tailwindcss": "^0.9.0", "@kobalte/tailwindcss": "^0.9.0",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@tabler/icons-solidjs": "^3.30.0", "@tabler/icons-solidjs": "^3.30.0",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-http": "~2",
"clsx": "^2.1.1", "@tauri-apps/plugin-opener": "^2",
"fuse.js": "^7.1.0", "clsx": "^2.1.1",
"jwt-decode": "^4.0.0", "fuse.js": "^7.1.0",
"solid-js": "^1.9.3", "jwt-decode": "^4.0.0",
"solid-motionone": "^1.0.3", "solid-js": "^1.9.3",
"tailwind-scrollbar-hide": "^2.0.0", "solid-markdown": "^2.0.14",
"valibot": "^1.0.0-rc.2" "solid-motionone": "^1.0.3",
}, "solidjs-markdown": "^0.2.0",
"devDependencies": { "tailwind-scrollbar-hide": "^2.0.0",
"@biomejs/biome": "^1.9.4", "valibot": "^1.0.0-rc.2"
"@tauri-apps/cli": "^2", },
"autoprefixer": "^10.4.20", "devDependencies": {
"postcss": "^8.5.3", "@biomejs/biome": "^1.9.4",
"postcss-cli": "^11.0.0", "@tauri-apps/cli": "^2",
"tailwindcss": "3.4.0", "autoprefixer": "^10.4.20",
"typescript": "~5.6.2", "postcss": "^8.5.3",
"vite": "^6.0.3", "postcss-cli": "^11.0.0",
"vite-plugin-solid": "^2.11.0" "tailwindcss": "3.4.0",
} "typescript": "~5.6.2",
"vite": "^6.0.3",
"vite-plugin-solid": "^2.11.0"
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
[package] [package]
name = "haystack" name = "Haystack"
version = "0.1.0" version = "0.1.0"
description = "A Tauri App" description = "Screenshots that organize themselves"
authors = ["you"] authors = ["Dmytro Kondakov", "John Costa"]
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -15,17 +15,22 @@ name = "haystack_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2.0.0-beta.12", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["macos-private-api"] } tauri = { version = "2.0.0-beta.12", features = ["macos-private-api"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2.0.0-beta.12"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2.0.0-beta.12"
notify = "6.1.1" notify = "6.1.1"
base64 = "0.21.7" base64 = "0.21.7"
tokio = { version = "1.36.0", features = ["full"] } tokio = { version = "1.36.0", features = ["full"] }
tauri-plugin-store = "2.0.0-beta.12"
tauri-plugin-http = "2.0.0-beta.12"
[target."cfg(target_os = \"macos\")".dependencies] [target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.26" cocoa = "0.26"
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-global-shortcut = "2.0.0-beta.12"

View File

@ -7,6 +7,22 @@
"core:default", "core:default",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"core:window:allow-start-dragging" "core:window:allow-start-dragging",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"http:default",
{
"identifier": "http:default",
"allow": [
{
"url": "https://haystack.johncosta.tech"
},
{
"url": "http://localhost:3040"
}
]
}
] ]
} }

View File

@ -0,0 +1,71 @@
use crate::state::SharedWatcherState;
use crate::utils::process_png_file;
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::PathBuf;
use std::sync::mpsc::channel;
use tauri::AppHandle;
#[tauri::command]
pub async fn handle_selected_folder(
path: String,
state: tauri::State<'_, SharedWatcherState>,
app: AppHandle,
) -> Result<String, String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() || !path_buf.is_dir() {
return Err("Invalid directory path".to_string());
}
// Stop existing watcher if any
let mut state = state
.lock()
.map_err(|_| "Failed to lock state".to_string())?;
state.clear_watcher();
// Create a channel to receive file system events
let (tx, rx) = channel();
// Create a new watcher
let mut watcher = RecommendedWatcher::new(tx, Config::default())
.map_err(|e| format!("Failed to create watcher: {}", e))?;
// Start watching the directory
watcher
.watch(path_buf.as_ref(), RecursiveMode::Recursive)
.map_err(|e| format!("Failed to watch directory: {}", e))?;
// Store the watcher in state
state.set_watcher(watcher);
let path_clone = path.clone();
let app_clone = app.clone();
tokio::spawn(async move {
println!("Starting to watch directory: {}", path_clone);
for res in rx {
match res {
Ok(event) => {
println!("Received event: {:?}", event);
match event.kind {
notify::EventKind::Create(_) | notify::EventKind::Modify(_) => {
for path in event.paths {
println!("Processing path: {}", path.display());
if let Some(extension) = path.extension() {
if extension.to_string_lossy().to_lowercase() == "png" {
if let Err(e) = process_png_file(&path, app_clone.clone()) {
eprintln!("Error processing PNG file: {}", e);
}
}
}
}
}
_ => {}
}
}
Err(e) => eprintln!("Watch error: {:?}", e),
}
}
});
Ok(format!("Now watching directory: {}", path))
}

View File

@ -1,146 +1,31 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; mod commands;
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; mod shortcut;
use std::fs; mod state;
use std::path::PathBuf; mod utils;
use std::sync::mpsc::channel; mod window;
use std::sync::Arc;
use std::sync::Mutex;
use tauri::AppHandle;
use tauri::Emitter;
use tauri::{WebviewUrl, WebviewWindowBuilder};
struct WatcherState { use state::new_shared_watcher_state;
watcher: Option<RecommendedWatcher>, use window::setup_window;
}
impl WatcherState {
fn new() -> Self {
Self { watcher: None }
}
}
// Handle PNG file processing
fn process_png_file(path: &PathBuf, app: AppHandle) -> Result<(), String> {
println!("Processing PNG file: {}", path.display());
// Read the file
let contents = fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
// Convert to base64
let base64_string = BASE64.encode(&contents);
println!("Generated base64 string of length: {}", base64_string.len());
// Emit the base64 to frontend
app.emit("png-processed", base64_string)
.map_err(|e| format!("Failed to emit event: {}", e))?;
println!("Successfully processed file: {}", path.display());
Ok(())
}
#[tauri::command]
async fn handle_selected_folder(
path: String,
state: tauri::State<'_, Arc<Mutex<WatcherState>>>,
app: AppHandle,
) -> Result<String, String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() || !path_buf.is_dir() {
return Err("Invalid directory path".to_string());
}
// Stop existing watcher if any
let mut state = state
.lock()
.map_err(|_| "Failed to lock state".to_string())?;
state.watcher = None;
// Create a channel to receive file system events
let (tx, rx) = channel();
// Create a new watcher
let mut watcher = RecommendedWatcher::new(tx, Config::default())
.map_err(|e| format!("Failed to create watcher: {}", e))?;
// Start watching the directory
watcher
.watch(path_buf.as_ref(), RecursiveMode::Recursive)
.map_err(|e| format!("Failed to watch directory: {}", e))?;
// Store the watcher in state
state.watcher = Some(watcher);
let path_clone = path.clone();
let app_clone = app.clone();
tokio::spawn(async move {
println!("Starting to watch directory: {}", path_clone);
for res in rx {
match res {
Ok(event) => {
println!("Received event: {:?}", event);
match event.kind {
notify::EventKind::Create(_) | notify::EventKind::Modify(_) => {
for path in event.paths {
println!("Processing path: {}", path.display());
if let Some(extension) = path.extension() {
if extension.to_string_lossy().to_lowercase() == "png" {
if let Err(e) = process_png_file(&path, app_clone.clone()) {
eprintln!("Error processing PNG file: {}", e);
}
}
}
}
}
_ => {}
}
}
Err(e) => eprintln!("Watch error: {:?}", e),
}
}
});
Ok(format!("Now watching directory: {}", path))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let watcher_state = Arc::new(Mutex::new(WatcherState::new())); let watcher_state = new_shared_watcher_state();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.manage(watcher_state) .manage(watcher_state)
.invoke_handler(tauri::generate_handler![handle_selected_folder]) .invoke_handler(tauri::generate_handler![
commands::handle_selected_folder,
shortcut::change_shortcut,
shortcut::unregister_shortcut,
shortcut::get_current_shortcut,
])
.setup(|app| { .setup(|app| {
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) setup_window(app)?;
.inner_size(480.0, 360.0) shortcut::enable_shortcut(app);
// .hidden_title(true)
.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);
let window = win_builder.build().unwrap();
// set background color only when building for macOS
#[cfg(target_os = "macos")]
{
use cocoa::appkit::{NSColor, NSWindow};
use cocoa::base::{id, nil};
let ns_window = window.ns_window().unwrap() as id;
unsafe {
let bg_color = NSColor::colorWithRed_green_blue_alpha_(
nil,
245.0 / 255.0,
245.0 / 255.0,
245.0 / 255.0,
1.0,
);
ns_window.setBackgroundColor_(bg_color);
}
}
Ok(()) Ok(())
}) })

View File

@ -0,0 +1,172 @@
use tauri::App;
use tauri::AppHandle;
use tauri::Emitter;
use tauri::Manager;
use tauri::Runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutState;
use tauri_plugin_store::JsonValue;
use tauri_plugin_store::StoreExt;
/// Name of the Tauri storage
const HAYSTACK_TAURI_STORE: &str = "haystack_tauri_store";
/// Key for storing global shortcuts
const HAYSTACK_GLOBAL_SHORTCUT: &str = "haystack_global_shortcut";
/// Default shortcut for macOS
#[cfg(target_os = "macos")]
const DEFAULT_SHORTCUT: &str = "command+shift+k";
/// Default shortcut for Windows and Linux
#[cfg(any(target_os = "windows", target_os = "linux"))]
const DEFAULT_SHORTCUT: &str = "ctrl+shift+k";
/// Set shortcut during application startup
pub fn enable_shortcut(app: &App) {
let store = app
.store(HAYSTACK_TAURI_STORE)
.expect("Creating the store should not fail");
// Use stored shortcut if it exists
if let Some(stored_shortcut) = store.get(HAYSTACK_GLOBAL_SHORTCUT) {
let stored_shortcut_str = match stored_shortcut {
JsonValue::String(str) => str,
unexpected_type => panic!(
"Haystack shortcuts should be stored as strings, found type: {} ",
unexpected_type
),
};
let stored_shortcut = stored_shortcut_str
.parse::<Shortcut>()
.expect("Stored shortcut string should be valid");
_register_shortcut_upon_start(app, stored_shortcut); // Register stored shortcut
} else {
// Use default shortcut if none is stored
store.set(
HAYSTACK_GLOBAL_SHORTCUT,
JsonValue::String(DEFAULT_SHORTCUT.to_string()),
);
let default_shortcut = DEFAULT_SHORTCUT
.parse::<Shortcut>()
.expect("Default shortcut should be valid");
_register_shortcut_upon_start(app, default_shortcut); // Register default shortcut
}
}
/// Get the current stored shortcut as a string
#[tauri::command]
pub fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
let shortcut = _get_shortcut(&app);
Ok(shortcut)
}
/// Unregister the current shortcut in Tauri
#[tauri::command]
pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
let shortcut_str = _get_shortcut(&app);
let shortcut = shortcut_str
.parse::<Shortcut>()
.expect("Stored shortcut string should be valid");
// Unregister the shortcut
app.global_shortcut()
.unregister(shortcut)
.expect("Failed to unregister shortcut")
}
/// Change the global shortcut
#[tauri::command]
pub fn change_shortcut<R: Runtime>(
app: AppHandle<R>,
_window: tauri::Window<R>,
key: String,
) -> Result<(), String> {
println!("Key: {}", key);
let shortcut = match key.parse::<Shortcut>() {
Ok(shortcut) => shortcut,
Err(_) => return Err(format!("Invalid shortcut {}", key)),
};
// Store the new shortcut
let store = app
.get_store(HAYSTACK_TAURI_STORE)
.expect("Store should already be loaded or created");
store.set(HAYSTACK_GLOBAL_SHORTCUT, JsonValue::String(key));
// Register the new shortcut
_register_shortcut(&app, shortcut);
Ok(())
}
/// Helper function to register a shortcut, primarily for updating shortcuts
fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
let main_window = app.get_webview_window("main").unwrap();
// Register global shortcut and define its behavior
app.global_shortcut()
.on_shortcut(shortcut, move |app, scut, event| {
if scut == &shortcut {
if let ShortcutState::Pressed = event.state() {
// Toggle window visibility
if main_window.is_visible().unwrap() {
main_window.hide().unwrap(); // Hide window
} else {
main_window.show().unwrap(); // Show window
main_window.set_focus().unwrap(); // Focus window
// Emit focus-search event
app.emit("focus-search", ()).unwrap();
}
}
}
})
.map_err(|err| format!("Failed to register new shortcut '{}'", err))
.unwrap();
}
/// Helper function to register shortcuts during application startup
fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
let window = app.get_webview_window("main").unwrap();
// Initialize global shortcut and set its handler
app.handle()
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, scut, event| {
if scut == &shortcut {
if let ShortcutState::Pressed = event.state() {
// Toggle window visibility
if window.is_visible().unwrap() {
window.hide().unwrap(); // Hide window
} else {
window.show().unwrap(); // Show window
window.set_focus().unwrap(); // Focus window
// Emit focus-search event
app.emit("focus-search", ()).unwrap();
}
}
}
})
.build(),
)
.unwrap();
app.global_shortcut().register(shortcut).unwrap(); // Register global shortcut
}
/// Retrieve the stored global shortcut as a string
pub fn _get_shortcut<R: Runtime>(app: &AppHandle<R>) -> String {
let store = app
.get_store(HAYSTACK_TAURI_STORE)
.expect("Store should already be loaded or created");
match store
.get(HAYSTACK_GLOBAL_SHORTCUT)
.expect("Shortcut should already be stored")
{
JsonValue::String(str) => str,
unexpected_type => panic!(
"Haystack shortcuts should be stored as strings, found type: {} ",
unexpected_type
),
}
}

View File

@ -0,0 +1,27 @@
use notify::RecommendedWatcher;
use std::sync::Arc;
use std::sync::Mutex;
pub struct WatcherState {
watcher: Option<RecommendedWatcher>,
}
impl WatcherState {
pub fn new() -> Self {
Self { watcher: None }
}
pub fn set_watcher(&mut self, watcher: RecommendedWatcher) {
self.watcher = Some(watcher);
}
pub fn clear_watcher(&mut self) {
self.watcher = None;
}
}
pub type SharedWatcherState = Arc<Mutex<WatcherState>>;
pub fn new_shared_watcher_state() -> SharedWatcherState {
Arc::new(Mutex::new(WatcherState::new()))
}

View File

@ -0,0 +1,22 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter};
pub fn process_png_file(path: &PathBuf, app: AppHandle) -> Result<(), String> {
println!("Processing PNG file: {}", path.display());
// Read the file
let contents = fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
// Convert to base64
let base64_string = BASE64.encode(&contents);
println!("Generated base64 string of length: {}", base64_string.len());
// Emit the base64 to frontend
app.emit("png-processed", base64_string)
.map_err(|e| format!("Failed to emit event: {}", e))?;
println!("Successfully processed file: {}", path.display());
Ok(())
}

View File

@ -0,0 +1,37 @@
use tauri::App;
use tauri::TitleBarStyle;
use tauri::{WebviewUrl, WebviewWindowBuilder};
pub fn setup_window(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.inner_size(480.0, 360.0)
.title("Haystack")
.hidden_title(true)
.resizable(false);
//
#[cfg(target_os = "macos")]
let win_builder = win_builder.title_bar_style(TitleBarStyle::Transparent);
let window = win_builder.build().unwrap();
#[cfg(target_os = "macos")]
{
use cocoa::appkit::{NSColor, NSWindow};
use cocoa::base::{id, nil};
let ns_window = window.ns_window().unwrap() as id;
unsafe {
let bg_color = NSColor::colorWithRed_green_blue_alpha_(
nil,
245.0 / 255.0,
245.0 / 255.0,
245.0 / 255.0,
1.0,
);
ns_window.setBackgroundColor_(bg_color);
}
}
Ok(())
}

View File

@ -1,6 +1,6 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "haystack", "productName": "Haystack",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.haystack.app", "identifier": "com.haystack.app",
"build": { "build": {

View File

@ -1,176 +1,35 @@
import { A } from "@solidjs/router"; import { Route, Router } from "@solidjs/router";
import { IconSearch } from "@tabler/icons-solidjs"; import { listen } from "@tauri-apps/api/event";
import clsx from "clsx"; import { createEffect, onCleanup } from "solid-js";
import Fuse from "fuse.js"; import { Login } from "./Login";
import { For, createEffect, createResource, createSignal } from "solid-js"; import { ProtectedRoute } from "./ProtectedRoute";
import { SearchCardEvent } from "./components/search-card/SearchCardEvent"; import { Search } from "./Search";
import { SearchCardLocation } from "./components/search-card/SearchCardLocation"; import { Settings } from "./Settings";
import { SearchCardNote } from "./components/search-card/SearchCardNote"; import { ImageViewer } from "./components/ImageViewer";
import { type UserImage, getUserImages } from "./network";
import { getCardSize } from "./utils/getCardSize";
import { SearchCardContact } from "./components/search-card/SearchCardContact";
const getCardComponent = (item: UserImage) => {
switch (item.type) {
case "location":
return <SearchCardLocation item={item} />;
case "event":
return <SearchCardEvent item={item} />;
case "note":
return <SearchCardNote item={item} />;
case "contact":
return <SearchCardContact item={item} />;
// case "Website":
// return <SearchCardWebsite item={item} />;
// case "Note":
// return <SearchCardNote item={item} />;
// case "Receipt":
// return <SearchCardReceipt item={item} />;
default:
return null;
}
};
// How wonderfully functional
const getAllValues = (object: object): Array<string> => {
const loop = (acc: Array<string>, next: object): Array<string> => {
for (const _value of Object.values(next)) {
const value: unknown = _value;
switch (typeof value) {
case "object":
if (value != null) {
acc.push(...loop(acc, value));
}
break;
case "string":
case "number":
case "boolean":
acc.push(value.toString());
break;
default:
break;
}
}
return acc;
};
return loop([], object);
};
function App() {
const [searchResults, setSearchResults] = createSignal<UserImage[]>([]);
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedItem, setSelectedItem] = createSignal<UserImage | null>(
null,
);
const [data] = createResource(() =>
getUserImages().then((data) =>
data.map((d) => ({
...d,
rawData: getAllValues(d),
})),
),
);
let fuze = new Fuse<UserImage>(data() ?? [], {
keys: [
{ name: "rawData", weight: 1 },
{ name: "title", weight: 1 },
],
threshold: 0.4,
});
export const App = () => {
createEffect(() => { createEffect(() => {
fuze = new Fuse<UserImage>(data() ?? [], { // TODO: Don't use window.location.href
keys: [ const unlisten = listen("focus-search", () => {
{ name: "data.Name", weight: 2 }, window.location.href = "/";
{ name: "rawData", weight: 1 }, });
],
threshold: 0.4, onCleanup(() => {
unlisten.then((fn) => fn());
}); });
}); });
const onInputChange = (event: InputEvent) => {
const query = (event.target as HTMLInputElement).value;
setSearchQuery(query);
setSearchResults(fuze.search(query).map((s) => s.item));
};
return ( return (
<> <>
<main class="container pt-2"> <ImageViewer />
<A href="login">login</A> <Router>
<div class="px-4"> <Route path="/login" component={Login} />
<div class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-neutral-200 text-neutral-900">
<div class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-l-md px-2.5 text-gray-900">
<IconSearch
size={20}
class="m-auto size-5 text-neutral-600"
/>
</div>
<input
type="text"
value={searchQuery()}
onInput={onInputChange}
placeholder="Search for stuff..."
class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600"
/>
</div>
</div>
<div class="px-4 mt-4 bg-white rounded-t-2xl"> <Route path="/" component={ProtectedRoute}>
<div class="h-[254px] mt-4 overflow-scroll scrollbar-hide"> <Route path="/" component={Search} />
{searchResults().length > 0 ? ( <Route path="/settings" component={Settings} />
<div class="w-full grid grid-cols-9 gap-2 grid-flow-row-dense py-4"> </Route>
<For each={searchResults()}> </Router>
{(item) => (
<div
onClick={() =>
setSelectedItem(item)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setSelectedItem(item);
}
}}
class={clsx(
"h-[144px] border relative border-neutral-200 cursor-pointer overflow-hidden rounded-xl",
{
"col-span-3":
getCardSize(
item.type,
) === "1/1",
"col-span-6":
getCardSize(
item.type,
) === "2/1",
},
)}
>
<span class="sr-only">
{item.data.Name}
</span>
{getCardComponent(item)}
</div>
)}
</For>
</div>
) : searchQuery() !== "" ? (
<div class="text-center text-lg m-auto text-neutral-700">
No results found
</div>
) : null}
</div>
</div>
<div class="w-full border-t h-10 bg-white px-4 flex items-center border-neutral-100">
footer
</div>
</main>
</> </>
); );
} };
export default App;

View File

@ -1,67 +0,0 @@
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}`}
alt="link"
/>
<div class="flex flex-col">
<h2 class="text-xl font-bold">Tags</h2>
<For each={image()?.Tags ?? []}>
{(tag) => <div>{tag.Tag.Tag}</div>}
</For>
<h2 class="text-xl font-bold">Locations</h2>
<For each={image()?.Locations ?? []}>
{(location) => (
<ul>
<li>{location.Name}</li>
{location.Address && <li>{location.Address}</li>}
{location.Coordinates && (
<li>{location.Coordinates}</li>
)}
{location.Description && (
<li>{location.Description}</li>
)}
</ul>
)}
</For>
<h2 class="text-xl font-bold">Events</h2>
<For each={image()?.Events ?? []}>
{(event) => (
<ul>
<li>{event.Name}</li>
{event.Location && <li>{event.Location.Name}</li>}
{event.Description && <li>{event.Description}</li>}
</ul>
)}
</For>
</div>
</Suspense>
);
}

View File

@ -1,9 +1,9 @@
import { Button } from "@kobalte/core/button"; import { Button } from "@kobalte/core/button";
import { TextField } from "@kobalte/core/text-field"; import { TextField } from "@kobalte/core/text-field";
import { createSignal, Show, type Component } from "solid-js";
import { postCode, postLogin } from "./network";
import { isTokenValid } from "./ProtectedRoute";
import { Navigate } from "@solidjs/router"; import { Navigate } from "@solidjs/router";
import { type Component, Show, createSignal } from "solid-js";
import { isTokenValid } from "./ProtectedRoute";
import { base, postCode, postLogin } from "./network";
export const Login: Component = () => { export const Login: Component = () => {
let form: HTMLFormElement | undefined; let form: HTMLFormElement | undefined;
@ -34,26 +34,31 @@ export const Login: Component = () => {
localStorage.setItem("access", access); localStorage.setItem("access", access);
localStorage.setItem("refresh", refresh); localStorage.setItem("refresh", refresh);
window.location.href = "/";
} }
}; };
const isAuthorized = isTokenValid(); const isAuthorized = isTokenValid();
return ( return (
<Show when={!isAuthorized} fallback={<Navigate href="/" />}> <>
<form ref={form} onSubmit={onSubmit}> {base}
<TextField name="email"> <Show when={!isAuthorized} fallback={<Navigate href="/" />}>
<TextField.Label>Email</TextField.Label> <form ref={form} onSubmit={onSubmit}>
<TextField.Input /> <TextField name="email">
</TextField> <TextField.Label>Email</TextField.Label>
<Show when={submitted()}>
<TextField name="code">
<TextField.Label>Code</TextField.Label>
<TextField.Input /> <TextField.Input />
</TextField> </TextField>
</Show> <Show when={submitted()}>
<Button type="submit">Submit</Button> <TextField name="code">
</form> <TextField.Label>Code</TextField.Label>
</Show> <TextField.Input />
</TextField>
</Show>
<Button type="submit">Submit</Button>
</form>
</Show>
</>
); );
}; };

216
frontend/src/Search.tsx Normal file
View File

@ -0,0 +1,216 @@
import { Button } from "@kobalte/core/button";
import { IconSearch, IconSettings } from "@tabler/icons-solidjs";
import { listen } from "@tauri-apps/api/event";
import Fuse from "fuse.js";
import {
For,
Show,
createEffect,
createResource,
createSignal,
onCleanup,
onMount,
} from "solid-js";
import { SearchCard } from "./components/search-card/SearchCard";
import { invoke } from "@tauri-apps/api/core";
import { ItemModal } from "./components/item-modal/ItemModal";
import type { Shortcut } from "./components/shortcuts/hooks/useShortcutEditor";
import { type UserImage, getUserImages } from "./network";
// How wonderfully functional
const getAllValues = (object: object): Array<string> => {
const loop = (acc: Array<string>, next: object): Array<string> => {
for (const _value of Object.values(next)) {
const value: unknown = _value;
switch (typeof value) {
case "object":
if (value != null) {
acc.push(...loop(acc, value));
}
break;
case "string":
case "number":
case "boolean":
acc.push(value.toString());
break;
default:
break;
}
}
return acc;
};
return loop([], object);
};
export const Search = () => {
const [searchResults, setSearchResults] = createSignal<UserImage[]>([]);
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedItem, setSelectedItem] = createSignal<UserImage | null>(
null,
);
const [data] = createResource(() =>
getUserImages().then((data) => {
console.log("DBG: ", data);
return data.map((d) => ({
...d,
rawData: getAllValues(d),
}));
}),
);
let fuze = new Fuse<UserImage>(data() ?? [], {
keys: [
{ name: "rawData", weight: 1 },
{ name: "title", weight: 1 },
],
threshold: 0.4,
});
createEffect(() => {
console.log("DBG: ", data());
setSearchResults(data() ?? []);
fuze = new Fuse<UserImage>(data() ?? [], {
keys: [
{ name: "data.Name", weight: 2 },
{ name: "rawData", weight: 1 },
],
threshold: 0.4,
});
});
const onInputChange = (event: InputEvent) => {
const query = (event.target as HTMLInputElement).value;
setSearchQuery(query);
setSearchResults(fuze.search(query).map((s) => s.item));
};
let searchInputRef: HTMLInputElement | undefined;
onMount(() => {
if (searchInputRef) {
searchInputRef.focus();
}
});
createEffect(() => {
// Listen for the focus-search event from Tauri
const unlisten = listen("focus-search", () => {
if (searchInputRef) {
searchInputRef.focus();
}
});
onCleanup(() => {
unlisten.then((fn) => fn());
});
});
const [shortcut, setShortcut] = createSignal<Shortcut>([]);
async function getCurrentShortcut() {
try {
const res: string = await invoke("get_current_shortcut");
console.log("DBG: ", res);
setShortcut(res?.split("+"));
} catch (err) {
console.error("Failed to fetch shortcut:", err);
}
}
onMount(() => {
getCurrentShortcut();
});
return (
<>
<main class="container pt-2">
<div class="px-4 flex items-center">
<div class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-neutral-200 text-neutral-900">
<div class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-md px-2.5 text-gray-900">
<IconSearch
size={20}
class="m-auto size-5 text-neutral-600"
/>
</div>
<input
ref={searchInputRef}
type="text"
value={searchQuery()}
onInput={onInputChange}
placeholder="Search for stuff..."
autofocus
class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600"
/>
</div>
<Button
as="a"
href="/settings"
class="ml-2 p-2.5 bg-neutral-200 rounded-lg"
>
<IconSettings size={20} />
</Button>
</div>
<div class="px-4 mt-4 bg-white rounded-t-2xl">
<div class="h-[254px] mt-4 overflow-scroll scrollbar-hide">
<Show
when={searchResults().length > 0}
fallback={
<div class="text-center text-lg m-auto mt-6 text-neutral-700">
No results found
</div>
}
>
<div class="w-full grid grid-cols-9 gap-2 grid-flow-row-dense py-4">
<For each={searchResults()}>
{(item) => (
<div
onClick={() =>
setSelectedItem(item)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setSelectedItem(item);
}
}}
class="h-[144px] border relative col-span-3 border-neutral-200 cursor-pointer overflow-hidden rounded-xl"
>
<span class="sr-only">
{item.data.Name}
</span>
<SearchCard item={item} />
</div>
)}
</For>
</div>
</Show>
</div>
</div>
<div class="w-full border-t h-10 bg-white px-4 flex items-center border-neutral-100">
<p class="text-sm text-neutral-700">
Use{" "}
{shortcut().length > 0
? shortcut().join("+")
: "shortcut"}{" "}
globally to toggle and reload this window
</p>
</div>
</main>
{selectedItem() && (
<ItemModal
item={selectedItem() as UserImage}
onClose={() => setSelectedItem(null)}
/>
)}
</>
);
};

33
frontend/src/Settings.tsx Normal file
View File

@ -0,0 +1,33 @@
import { Button } from "@kobalte/core/button";
import { FolderPicker } from "./components/folder-picker/FolderPicker";
import { Shortcuts } from "./components/shortcuts/Shortcuts";
export const Settings = () => {
const logout = () => {
localStorage.removeItem("access");
localStorage.removeItem("refresh");
window.location.href = "/login";
};
return (
<>
<main class="container pt-2">
<div class="flex flex-col px-4 gap-2">
<Button as="a" href="/">
Back to home
</Button>
<h1 class="text-3xl font-bold">Settings</h1>
<FolderPicker />
<Shortcuts />
<Button
class="p-2 bg-neutral-100 border mt-4 border-neutral-300"
onClick={logout}
>
Logout
</Button>
</div>
</main>
</>
);
};

View File

@ -1,49 +0,0 @@
import { createSignal } from "solid-js";
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 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}`);
}
};
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>
)}
{status() && <p class="text-sm text-gray-600">{status()}</p>}
</div>
);
}

View File

@ -1,19 +1,21 @@
import { createEffect, createSignal } from "solid-js";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { FolderPicker } from "./FolderPicker"; import { createEffect } from "solid-js";
import { sendImage } from "../network"; import { sendImage } from "../network";
export function ImageViewer() { export function ImageViewer() {
const [latestImage, setLatestImage] = createSignal<string | null>(null); // const [latestImage, setLatestImage] = createSignal<string | null>(null);
createEffect(() => { createEffect(async () => {
// Listen for PNG processing events // Listen for PNG processing events
const unlisten = listen("png-processed", (event) => { const unlisten = listen("png-processed", async (event) => {
console.log("Received processed PNG", event); console.log("Received processed PNG", event);
const base64Data = event.payload as string; const base64Data = event.payload as string;
setLatestImage(`data:image/png;base64,${base64Data}`); // setLatestImage(`data:image/png;base64,${base64Data}`);
sendImage("test-image.png", base64Data); const result = await sendImage("test-image.png", base64Data);
window.location.reload();
console.log("DBG: ", result);
}); });
return () => { return () => {
@ -21,20 +23,22 @@ export function ImageViewer() {
}; };
}); });
return ( return null;
<div>
<FolderPicker />
{latestImage() && ( // return (
<div class="mt-4"> // <div>
<h3>Latest Processed Image:</h3> // <FolderPicker />
<img
src={latestImage() || undefined} // {latestImage() && (
alt="Latest processed" // <div class="mt-4">
class="max-w-md" // <h3>Latest Processed Image:</h3>
/> // <img
</div> // src={latestImage() || undefined}
)} // alt="Latest processed"
</div> // class="max-w-md"
); // />
// </div>
// )}
// </div>
// );
} }

View File

@ -0,0 +1,52 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { createSignal } from "solid-js";
export function FolderPicker() {
const [selectedPath, setSelectedPath] = createSignal<string>("");
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,
});
console.log("DBG: ", response);
}
} catch (error) {
console.error("DBG: ", error);
}
};
return (
<div class="flex flex-col items-start gap-2">
<p class="text-sm text-neutral-700">
Select the folder where your screenshots are stored. We'll watch
this folder for any changes and process any new screenshots.
</p>
<div class="flex items-center gap-2">
<button
type="button"
onClick={handleFolderSelect}
class="bg-neutral-100 border border-neutral-300 rounded-md px-2 py-1"
>
Select folder
</button>
{selectedPath() && (
<div class="text-left max-w-md">
<p class="text-sm break-all">{selectedPath()}</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { IconX } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
type Props = {
item: UserImage;
onClose: () => void;
};
export const ItemModal = (props: Props) => {
return (
<div class="fixed inset-2 rounded-2xl p-4 bg-white border border-neutral-300">
<div class="flex justify-between">
<h1 class="text-2xl font-bold">{props.item.data.Name}</h1>
<button type="button" onClick={props.onClose}>
<IconX size={24} class="text-neutral-500" />
</button>
</div>
<div class="flex flex-col gap-2 mb-2">
<p class="text-sm text-neutral-500">
{JSON.stringify(props.item.data, null, 2)}
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,22 @@
import type { UserImage } from "../../network";
import { SearchCardContact } from "./SearchCardContact";
import { SearchCardEvent } from "./SearchCardEvent";
import { SearchCardLocation } from "./SearchCardLocation";
import { SearchCardNote } from "./SearchCardNote";
export const SearchCard = (props: { item: UserImage }) => {
const { item } = props;
switch (item.type) {
case "location":
return <SearchCardLocation item={item} />;
case "event":
return <SearchCardEvent item={item} />;
case "note":
return <SearchCardNote item={item} />;
case "contact":
return <SearchCardContact item={item} />;
default:
return null;
}
};

View File

@ -12,17 +12,15 @@ export const SearchCardContact = ({ item }: Props) => {
return ( return (
<div class="absolute inset-0 p-3 bg-orange-50"> <div class="absolute inset-0 p-3 bg-orange-50">
<div class="grid grid-cols-[auto_20px] gap-1 mb-1"> <div class="flex mb-1 items-center gap-1">
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p> <IconUser size={14} class="text-neutral-500" />
<IconUser size={20} class="text-neutral-500 mt-1" /> <p class="text-xs text-neutral-500">Contact</p>
</div> </div>
<p class="text-xs text-neutral-500">{data.PhoneNumber}</p> <p class="text-sm text-neutral-900 font-bold mb-1">
<Separator class="my-2" /> {data.Name.length > 0 ? data.Name : "Unknown 🐞"}
<p class="text-xs text-neutral-500">{data.Email}</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Description}
</p> </p>
<p class="text-xs text-neutral-700">Phone: {data.PhoneNumber}</p>
<p class="text-xs text-neutral-700">Mail: {data.Email}</p>
</div> </div>
); );
}; };

View File

@ -1,5 +1,3 @@
import { Separator } from "@kobalte/core/separator";
import { IconCalendar } from "@tabler/icons-solidjs"; import { IconCalendar } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network"; import type { UserImage } from "../../network";
@ -12,21 +10,22 @@ export const SearchCardEvent = ({ item }: Props) => {
return ( return (
<div class="absolute inset-0 p-3 bg-purple-50"> <div class="absolute inset-0 p-3 bg-purple-50">
<div class="grid grid-cols-[auto_20px] gap-1 mb-1"> <div class="flex mb-1 items-center gap-1">
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p> <IconCalendar size={14} class="text-neutral-500" />
<IconCalendar size={20} class="text-neutral-500 mt-1" /> <p class="text-xs text-neutral-500">Event</p>
</div> </div>
<p class="text-xs text-neutral-500"> <p class="text-sm text-neutral-900 font-bold mb-1">
Organized by {data.Organizer?.Name ?? "unknown"} on{" "} {data.Name.length > 0 ? data.Name : "Unknown 🐞"}
{new Date(data.StartDateTime).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</p> </p>
<Separator class="my-2" /> <p class="text-xs text-neutral-700">
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden"> Organized by {data.Organizer?.Name ?? "unknown"} on{" "}
{data.Description} {data.StartDateTime
? new Date(data.StartDateTime).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
: "unknown date"}
</p> </p>
</div> </div>
); );

View File

@ -1,5 +1,3 @@
import { Separator } from "@kobalte/core/separator";
import { IconMapPin } from "@tabler/icons-solidjs"; import { IconMapPin } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network"; import type { UserImage } from "../../network";
@ -12,15 +10,14 @@ export const SearchCardLocation = ({ item }: Props) => {
return ( return (
<div class="absolute inset-0 p-3 bg-red-50"> <div class="absolute inset-0 p-3 bg-red-50">
<div class="grid grid-cols-[auto_20px] gap-1 mb-1"> <div class="flex mb-1 items-center gap-1">
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p> <IconMapPin size={14} class="text-neutral-500" />
<IconMapPin size={20} class="text-neutral-500 mt-1" /> <p class="text-xs text-neutral-500">Location</p>
</div> </div>
<p class="text-xs text-neutral-500">{data.Address}</p> <p class="text-sm text-neutral-900 font-bold mb-1">
<Separator class="my-2" /> {data.Name.length > 0 ? data.Name : "Unknown 🐞"}
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Description}
</p> </p>
<p class="text-xs text-neutral-700">Address: {data.Address}</p>
</div> </div>
); );
}; };

View File

@ -1,4 +1,5 @@
import { Separator } from "@kobalte/core/separator"; import { Separator } from "@kobalte/core/separator";
import SolidjsMarkdown from "solidjs-markdown";
import { IconNote } from "@tabler/icons-solidjs"; import { IconNote } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network"; import type { UserImage } from "../../network";
@ -12,14 +13,15 @@ export const SearchCardNote = ({ item }: Props) => {
return ( return (
<div class="absolute inset-0 p-3 bg-green-50"> <div class="absolute inset-0 p-3 bg-green-50">
<div class="grid grid-cols-[auto_20px] gap-1 mb-1"> <div class="flex mb-1 items-center gap-1">
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p> <IconNote size={14} class="text-neutral-500" />
<IconNote size={20} class="text-neutral-500 mt-1" /> <p class="text-xs text-neutral-500">Note</p>
</div> </div>
<p class="text-xs text-neutral-500">Keywords TODO</p> <p class="text-sm text-neutral-900 font-bold mb-1">
<Separator class="my-2" /> {data.Name.length > 0 ? data.Name : "Unknown 🐞"}
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden"> </p>
{data.Content} <p class="text-xs text-neutral-700">
<SolidjsMarkdown>{data.Content}</SolidjsMarkdown>
</p> </p>
</div> </div>
); );

View File

@ -0,0 +1,78 @@
import { IconX } from "@tabler/icons-solidjs";
import { type Component, For } from "solid-js";
import { formatKey } from "./utils/formatKey";
import { sortKeys } from "./utils/sortKeys";
interface ShortcutItemProps {
shortcut: string[];
isEditing: boolean;
currentKeys: string[];
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
}
export const ShortcutItem: Component<ShortcutItemProps> = (props) => {
const renderKeys = (keys: string[]) => {
const sortedKeys = sortKeys(keys);
return (
<For each={sortedKeys}>
{(key) => (
<kbd class="px-2 py-1 text-sm font-semibold rounded bg-neutral-100 border border-neutral-300 text-neutral-900 ">
{formatKey(key)}
</kbd>
)}
</For>
);
};
return (
<div class="flex">
<div class="flex items-center gap-4">
{props.isEditing ? (
<>
<div class="flex gap-1 min-w-[144px]">
{props.currentKeys.length > 0 ? (
renderKeys(props.currentKeys)
) : (
<span class="text-neutral-500">
Press keys...
</span>
)}
</div>
<div class="flex gap-2">
<button
type="button"
onClick={props.onSave}
disabled={props.currentKeys.length < 2}
class="px-3 py-1 text-sm rounded bg-neutral-900 text-white"
>
Save
</button>
<button
type="button"
onClick={props.onCancel}
class="p-1 rounded text-neutral-500"
>
<IconX class="w-4 h-4" />
</button>
</div>
</>
) : (
<>
<div class="flex gap-1">
{renderKeys(props.shortcut)}
</div>
<button
type="button"
onClick={props.onEdit}
class="px-3 py-1 text-sm rounded bg-neutral-200 text-neutral-700 "
>
Edit
</button>
</>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,77 @@
import { invoke } from "@tauri-apps/api/core";
import { createSignal, onMount } from "solid-js";
import { ShortcutItem } from "./ShortcutItem";
import { type Shortcut, useShortcutEditor } from "./hooks/useShortcutEditor";
export const Shortcuts = () => {
const [shortcut, setShortcut] = createSignal<Shortcut>([]);
async function getCurrentShortcut() {
try {
const res: string = await invoke("get_current_shortcut");
console.log("DBG: ", res);
setShortcut(res?.split("+"));
} catch (err) {
console.error("Failed to fetch shortcut:", err);
}
}
onMount(() => {
getCurrentShortcut();
});
const changeShortcut = (key: Shortcut) => {
setShortcut(key);
if (key.length === 0) return;
invoke("change_shortcut", { key: key?.join("+") }).catch((err) => {
console.error("Failed to save hotkey:", err);
});
};
const {
isEditing,
currentKeys,
startEditing,
saveShortcut,
cancelEditing,
} = useShortcutEditor(shortcut(), changeShortcut);
const onEditShortcut = async () => {
startEditing();
invoke("unregister_shortcut").catch((err) => {
console.error("Failed to save hotkey:", err);
});
};
const onCancelShortcut = async () => {
cancelEditing();
invoke("change_shortcut", { key: shortcut()?.join("+") }).catch(
(err) => {
console.error("Failed to save hotkey:", err);
},
);
};
const onSaveShortcut = async () => {
saveShortcut();
};
return (
<div class="flex flex-col gap-2 mt-4">
<p class="text-sm text-neutral-700">
Set up a to quickly open Haystack search. This shortcut also
reloads items when updates happen (we should definetely fix
that)
</p>
<ShortcutItem
shortcut={shortcut()}
isEditing={isEditing()}
currentKeys={currentKeys()}
onEdit={onEditShortcut}
onSave={onSaveShortcut}
onCancel={onCancelShortcut}
/>
</div>
);
};

View File

@ -0,0 +1,134 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
import { isModifierKey } from "../utils/isModifierKey";
import { normalizeKey } from "../utils/normalizeKey";
import { sortKeys } from "../utils/sortKeys";
export type Shortcut = string[];
const RESERVED_SHORTCUTS = [
["Command", "C"],
["Command", "V"],
["Command", "X"],
["Command", "A"],
["Command", "Z"],
["Command", "Q"],
// Windows/Linux
["Control", "C"],
["Control", "V"],
["Control", "X"],
["Control", "A"],
["Control", "Z"],
];
export function useShortcutEditor(
shortcut: Shortcut,
onChange: (shortcut: Shortcut) => void,
) {
console.log("DBG: ", shortcut);
const [isEditing, setIsEditing] = createSignal(false);
const [currentKeys, setCurrentKeys] = createSignal<string[]>([]);
const pressedKeys = new Set<string>();
const startEditing = () => {
setIsEditing(true);
setCurrentKeys([]);
};
const saveShortcut = async () => {
if (!isEditing() || currentKeys().length < 2) return;
const hasModifier = currentKeys().some(isModifierKey);
const hasNonModifier = currentKeys().some((key) => !isModifierKey(key));
if (!hasModifier || !hasNonModifier) return;
const isReserved = RESERVED_SHORTCUTS.some(
(reserved) =>
reserved.length === currentKeys().length &&
reserved.every(
(key, index) =>
key.toLowerCase() ===
currentKeys()[index].toLowerCase(),
),
);
if (isReserved) {
console.error("This is a system reserved shortcut");
return;
}
// Sort keys to ensure consistent order (modifiers first)
const sortedKeys = sortKeys(currentKeys());
onChange(sortedKeys);
setIsEditing(false);
setCurrentKeys([]);
};
const cancelEditing = () => {
setIsEditing(false);
setCurrentKeys([]);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isEditing()) return;
e.preventDefault();
e.stopPropagation();
const key = normalizeKey(e.code);
// Update pressed keys
pressedKeys.add(key);
setCurrentKeys(() => {
const keys = Array.from(pressedKeys);
let modifiers = keys.filter(isModifierKey);
let nonModifiers = keys.filter((k) => !isModifierKey(k));
if (modifiers.length > 2) {
modifiers = modifiers.slice(0, 2);
}
if (nonModifiers.length > 2) {
nonModifiers = nonModifiers.slice(0, 2);
}
// Combine modifiers and non-modifiers
return [...modifiers, ...nonModifiers];
});
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isEditing()) return;
const key = normalizeKey(e.code);
pressedKeys.delete(key);
};
// Use createEffect to handle event listeners based on editing state
createEffect(() => {
if (isEditing()) {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
} else {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
pressedKeys.clear();
}
});
// Clean up event listeners on unmount
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
});
return {
isEditing,
currentKeys,
startEditing,
saveShortcut,
cancelEditing,
};
}

View File

@ -0,0 +1,38 @@
export const formatKey = (key: string): string => {
// Convert to uppercase for consistency
const upperKey = key.toUpperCase();
// Handle special keys
switch (upperKey) {
case "CONTROL":
return "Ctrl";
case "META":
return "⌘";
case "ALT":
return "⌥";
case "SHIFT":
return "⇧";
case "ARROWUP":
return "↑";
case "ARROWDOWN":
return "↓";
case "ARROWLEFT":
return "←";
case "ARROWRIGHT":
return "→";
case "ESCAPE":
return "Esc";
case "ENTER":
return "Enter";
case "BACKSPACE":
return "⌫";
case "DELETE":
return "⌦";
case "TAB":
return "⇥";
case "CAPSLOCK":
return "⇪";
default:
return upperKey;
}
};

View File

@ -0,0 +1,4 @@
export const isModifierKey = (key: string): boolean => {
const modifiers = ["Control", "Shift", "Alt", "Meta", "Command"];
return modifiers.includes(key);
};

View File

@ -0,0 +1,33 @@
export const normalizeKey = (code: string): string => {
// Remove 'Key' prefix from letter keys
if (code.startsWith("Key")) {
return code.slice(3);
}
// Remove 'Digit' prefix from number keys
if (code.startsWith("Digit")) {
return code.slice(5);
}
// Handle special keys
const specialKeys: Record<string, string> = {
ControlLeft: "Control",
ControlRight: "Control",
ShiftLeft: "Shift",
ShiftRight: "Shift",
AltLeft: "Alt",
AltRight: "Alt",
MetaLeft: "Command",
MetaRight: "Command",
ArrowLeft: "ArrowLeft",
ArrowRight: "ArrowRight",
ArrowUp: "ArrowUp",
ArrowDown: "ArrowDown",
Enter: "Enter",
Space: "Space",
Escape: "Escape",
Backspace: "Backspace",
Delete: "Delete",
Tab: "Tab",
CapsLock: "CapsLock",
};
return specialKeys[code] || code;
};

View File

@ -0,0 +1,14 @@
export const sortKeys = (keys: string[]): string[] => {
const priority: { [key: string]: number } = {
Control: 1,
Meta: 2,
Alt: 3,
Shift: 4,
};
return [...keys].sort((a, b) => {
const aPriority = priority[a] || 999;
const bPriority = priority[b] || 999;
return aPriority - bPriority;
});
};

View File

@ -1,22 +1,8 @@
/* @refresh reload */ /* @refresh reload */
import { render } from "solid-js/web"; import { render } from "solid-js/web";
import App from "./App";
import "./index.css"; import "./index.css";
import { Route, Router } from "@solidjs/router";
import { ImagePage } from "./ImagePage";
import { Login } from "./Login";
import { ProtectedRoute } from "./ProtectedRoute";
render( import { App } from "./App";
() => (
<Router>
<Route path="/login" component={Login} />
<Route path="/" component={ProtectedRoute}> render(() => <App />, document.getElementById("root") as HTMLElement);
<Route path="/" component={App} />
<Route path="/image/:imageId" component={ImagePage} />
</Route>
</Router>
),
document.getElementById("root") as HTMLElement,
);

View File

@ -1,13 +1,15 @@
import { fetch } from "@tauri-apps/plugin-http";
import { import {
type InferOutput, type InferOutput,
array, array,
literal,
nullable, nullable,
strictObject,
parse, parse,
pipe, pipe,
strictObject,
string, string,
uuid, uuid,
literal,
variant, variant,
} from "valibot"; } from "valibot";
@ -17,8 +19,12 @@ type BaseRequestParams = Partial<{
method: "GET" | "POST"; method: "GET" | "POST";
}>; }>;
export const base = import.meta.env.DEV
? "http://localhost:3040"
: "https://haystack.johncosta.tech";
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => { const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
return new Request(`http://localhost:3040/${path}`, { return new Request(`${base}/${path}`, {
body, body,
method, method,
}); });
@ -29,7 +35,7 @@ const getBaseAuthorizedRequest = ({
body, body,
method, method,
}: BaseRequestParams): Request => { }: BaseRequestParams): Request => {
return new Request(`http://localhost:3040/${path}`, { return new Request(`${base}/${path}`, {
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem("access")?.toString()}`, Authorization: `Bearer ${localStorage.getItem("access")?.toString()}`,
}, },
@ -83,9 +89,9 @@ const eventValidator = strictObject({
EndDateTime: nullable(pipe(string())), EndDateTime: nullable(pipe(string())),
Description: nullable(string()), Description: nullable(string()),
LocationID: nullable(pipe(string(), uuid())), LocationID: nullable(pipe(string(), uuid())),
Location: nullable(locationValidator), // Location: nullable(locationValidator),
OrganizerID: nullable(pipe(string(), uuid())), OrganizerID: nullable(pipe(string(), uuid())),
Organizer: nullable(contactValidator), // Organizer: nullable(contactValidator),
}); });
const noteValidator = strictObject({ const noteValidator = strictObject({
@ -130,6 +136,8 @@ export const getUserImages = async (): Promise<UserImage[]> => {
const res = await fetch(request).then((res) => res.json()); const res = await fetch(request).then((res) => res.json());
console.log("BACKEND RESPONSE: ", res);
return parse(getUserImagesResponseValidator, res); return parse(getUserImagesResponseValidator, res);
}; };