83 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
5ae6a3403f chore: removing old agent that was messy and too coupled
chore
2025-04-13 16:30:20 +01:00
3156cea904 feat(event): seperate event agent 2025-04-13 16:30:20 +01:00
d432d16752 feat(location): agent to create locations 2025-04-13 16:30:20 +01:00
98328be39d fix(email) 2025-04-13 16:28:40 +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
47c871523d feat(sse): very rough events. Not used in the client yet
feat(sse): very rough events. Not used in the client yet
2025-04-13 14:27:59 +01: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
cf7d5e0305 chore: removing unused files 2025-04-12 14:44:16 +01:00
9bb07c1b9b fix: tests 2025-04-12 14:43:01 +01:00
959b741fcb refactor(agent): main agent loop extracted away
Still not super sure how to represent these agents in code.
It doesn't make the most amount of sense to keep them in structs. A
curried function is more like it, with system prompt and tooling.

Maybe that's what I'll end up doing.
2025-04-12 14:39:16 +01:00
91cc54aaec fix(event) 2025-04-12 14:15:07 +01:00
d786ab15c9 fix(orchestrator): better describing the note taking agent 2025-04-12 07:53:43 +01:00
47e65e1609 fix(notes): improving note taking capabilities 2025-04-12 07:48:42 +01:00
91dd2f54ef fix(log): removing access token logging 2025-04-12 07:46:07 +01:00
42771ea958 feat(contact-agent): linking to existing instead of creating new ones 2025-04-12 07:29:29 +01:00
77a0901352 fix: removing extra log line 2025-04-12 07:22:35 +01:00
a43efa014f feat(log): pretty logging agent responses and tool calls 2025-04-12 07:16:30 +01:00
4990cf9c43 feat(contact-agent): working contact agent
Built this in under 20 minutes. Getting some really good agents
2025-04-11 21:12:06 +01:00
9660c99a14 feat: contacts working 2025-04-11 20:31:51 +01:00
72 changed files with 3837 additions and 1933 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

@ -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 enum
import "github.com/go-jet/jet/v2/postgres"
var Progress = &struct {
NotStarted postgres.StringExpression
InProgress postgres.StringExpression
}{
NotStarted: postgres.NewEnumValue("not-started"),
InProgress: postgres.NewEnumValue("in-progress"),
}

View File

@ -0,0 +1,49 @@
//
// 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 "errors"
type Progress string
const (
Progress_NotStarted Progress = "not-started"
Progress_InProgress Progress = "in-progress"
)
var ProgressAllValues = []Progress{
Progress_NotStarted,
Progress_InProgress,
}
func (e *Progress) Scan(value interface{}) error {
var enumValue string
switch val := value.(type) {
case string:
enumValue = val
case []byte:
enumValue = string(val)
default:
return errors.New("jet: Invalid scan value for AllTypesEnum enum. Enum value has to be of type string or []byte")
}
switch enumValue {
case "not-started":
*e = Progress_NotStarted
case "in-progress":
*e = Progress_InProgress
default:
return errors.New("jet: Invalid scan value '" + enumValue + "' for Progress enum")
}
return nil
}
func (e Progress) String() string {
return string(e)
}

View File

@ -13,6 +13,7 @@ import (
type UserImagesToProcess struct {
ID uuid.UUID `sql:"primary_key"`
Status Progress
ImageID uuid.UUID
UserID uuid.UUID
}

View File

@ -18,6 +18,7 @@ type userImagesToProcessTable struct {
// Columns
ID postgres.ColumnString
Status postgres.ColumnString
ImageID postgres.ColumnString
UserID postgres.ColumnString
@ -61,10 +62,11 @@ func newUserImagesToProcessTable(schemaName, tableName, alias string) *UserImage
func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userImagesToProcessTable {
var (
IDColumn = postgres.StringColumn("id")
StatusColumn = postgres.StringColumn("status")
ImageIDColumn = postgres.StringColumn("image_id")
UserIDColumn = postgres.StringColumn("user_id")
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn}
mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn}
allColumns = postgres.ColumnList{IDColumn, StatusColumn, ImageIDColumn, UserIDColumn}
mutableColumns = postgres.ColumnList{StatusColumn, ImageIDColumn, UserIDColumn}
)
return userImagesToProcessTable{
@ -72,6 +74,7 @@ func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userIm
//Columns
ID: IDColumn,
Status: StatusColumn,
ImageID: ImageIDColumn,
UserID: UserIDColumn,

View File

@ -65,8 +65,8 @@ func (m ChatUserMessage) MarshalJSON() ([]byte, error) {
})
case ArrayMessage:
return json.Marshal(&struct {
Role UserRole `json:"role"`
Content []ImageMessageContent `json:"content"`
Role UserRole `json:"role"`
Content []MessageContentMessage `json:"content"`
}{
Role: User,
Content: t.Content,
@ -121,18 +121,35 @@ func (m SingleMessage) IsSingleMessage() bool {
}
type ArrayMessage struct {
Content []ImageMessageContent `json:"content"`
Content []MessageContentMessage `json:"content"`
}
func (m ArrayMessage) IsSingleMessage() bool {
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 {
ImageType string `json:"type"`
ImageUrl string `json:"image_url"`
}
func (m ImageMessageContent) IsImageMessage() bool {
return true
}
type ImageContentUrl struct {
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)
if len(extension) == 0 {
// 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:]
encodedString := base64.StdEncoding.EncodeToString(image)
messageContent := ArrayMessage{
Content: make([]ImageMessageContent, 1),
contentLength := 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",
ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
}

View File

@ -4,10 +4,12 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
type ResponseFormat struct {
@ -69,16 +71,30 @@ type AgentClient struct {
ToolHandler ToolsHandlers
Log *log.Logger
Reply string
Do func(req *http.Request) (*http.Response, error)
Options CreateAgentClientOptions
}
const OPENAI_API_KEY = "OPENAI_API_KEY"
func CreateAgentClient() (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)
if len(apiKey) == 0 {
return AgentClient{}, errors.New(OPENAI_API_KEY + " was not found.")
panic("No api key")
}
return AgentClient{
@ -89,10 +105,14 @@ func CreateAgentClient() (AgentClient, error) {
return client.Do(req)
},
Log: options.Log,
ToolHandler: ToolsHandlers{
handlers: map[string]ToolHandler{},
},
}, nil
Options: options,
}
}
func (client AgentClient) getRequest(body []byte) (*http.Request, error) {
@ -128,8 +148,6 @@ func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error)
return AgentResponse{}, err
}
fmt.Println(string(response))
agentResponse := AgentResponse{}
err = json.Unmarshal(response, &agentResponse)
@ -138,23 +156,36 @@ func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error)
}
if len(agentResponse.Choices) != 1 {
client.Log.Errorf("Received more than 1 choice from AI \n %s\n", string(response))
return AgentResponse{}, errors.New("Unsupported. We currently only accept 1 choice from AI.")
}
req.Chat.AddAiResponse(agentResponse.Choices[0].Message)
msg := agentResponse.Choices[0].Message
req.Chat.AddAiResponse(msg)
return agentResponse, nil
}
func (client AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error {
func (client *AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error {
for {
err := client.Process(info, req)
response, err := client.Request(req)
if err != nil {
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 == FinishedCall {
client.Log.Debug("Agent is finished")
}
return err
}
}
@ -162,7 +193,7 @@ func (client AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody)
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
message, err := req.Chat.GetLatest()
@ -187,8 +218,50 @@ func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) e
toolResponse := client.ToolHandler.Handle(info, toolCall)
if toolCall.Function.Name == "reply" {
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)
}
return err
}
func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error {
var tools any
err := json.Unmarshal([]byte(client.Options.JsonTools), &tools)
if err != nil {
panic(err)
}
toolChoice := "any"
request := AgentRequestBody{
Tools: &tools,
ToolChoice: &toolChoice,
Model: "pixtral-12b-2409",
Temperature: 0.3,
EndToolCall: client.Options.EndToolCall,
ResponseFormat: ResponseFormat{
Type: "text",
},
Chat: &Chat{
Messages: make([]ChatMessage, 0),
},
}
request.Chat.AddSystem(client.Options.SystemPrompt)
request.Chat.AddImage(imageName, imageData, client.Options.Query)
toolHandlerInfo := ToolHandlerInfo{
ImageId: imageId,
ImageName: imageName,
UserId: userId,
Image: &imageData,
}
return client.ToolLoop(toolHandlerInfo, &request)
}

View File

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

View File

@ -2,8 +2,10 @@ package client
import (
"errors"
"os"
"testing"
"github.com/charmbracelet/log"
"github.com/google/uuid"
"github.com/stretchr/testify/suite"
)
@ -28,6 +30,7 @@ func (suite *ToolTestSuite) SetupTest() {
return false, errors.New("I will always error")
})
suite.client.Log = log.New(os.Stdout)
suite.client.ToolHandler = suite.handler
}

View File

@ -0,0 +1,180 @@
package agents
import (
"context"
"encoding/json"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
const contactPrompt = `
**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.
**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.
**Input:** You will be given an image that may contain contact information.
**Output Behavior (CRITICAL):**
* **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.
**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 = `
[
{
"type": "function",
"function": {
"name": "listContacts",
"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": {
"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 listContactsArguments struct{}
type createContactsArguments struct {
Name string `json:"name"`
ContactID *string `json:"contactId"`
PhoneNumber *string `json:"phoneNumber"`
Address *string `json:"address"`
Email *string `json:"email"`
}
func NewContactAgent(log *log.Logger, contactModel models.ContactModel) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: contactPrompt,
JsonTools: contactTools,
Log: log,
EndToolCall: "stopAgent",
})
agentClient.ToolHandler.AddTool("listContacts", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return contactModel.List(context.Background(), info.UserId)
})
agentClient.ToolHandler.AddTool("createContact", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := createContactsArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return model.Contacts{}, err
}
ctx := context.Background()
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,
PhoneNumber: args.PhoneNumber,
Email: args.Email,
})
if err != nil {
return model.Contacts{}, err
}
_, err = contactModel.SaveToImage(ctx, info.ImageId, contact.ID)
if err != nil {
return model.Contacts{}, err
}
return contact, nil
})
return agentClient
}

View File

@ -0,0 +1,247 @@
package agents
import (
"context"
"encoding/json"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
const eventPrompt = `
**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.
**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.
**Core Workflow:**
**Duplicate Check (Mandatory if Event Found):**
* 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).**
**Location ID Retrieval (Conditional):**
* 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.**
**Create Event:**
* 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.
**Handling Multiple Events:**
* 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 = `
[
{
"type": "function",
"function": {
"name": "listEvents",
"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": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "createEvent",
"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": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name or title of the event. This field is mandatory."
},
"startDateTime": {
"type": "string",
"description": "The event's start date and time in ISO 8601 format (e.g., '2025-04-18T10:00:00Z'). Include if available."
},
"endDateTime": {
"type": "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"]
}
}
},
{
"type": "function",
"function": {
"name": "updateEvent",
"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": {
"type": "object",
"properties": {
"eventId": {
"type": "string",
"description": "The UUID of the existing event"
}
},
"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",
"function": {
"name": "stopAgent",
"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": {
"type": "object",
"properties": {},
"required": []
}
}
}
]`
type listEventArguments struct{}
type createEventArguments struct {
Name string `json:"name"`
StartDateTime *string `json:"startDateTime"`
EndDateTime *string `json:"endDateTime"`
OrganizerName *string `json:"organizerName"`
LocationID *string `json:"locationId"`
}
type updateEventArguments struct {
EventID string `json:"eventId"`
}
func NewEventAgent(log *log.Logger, eventsModel models.EventModel, locationModel models.LocationModel) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: eventPrompt,
JsonTools: eventTools,
Log: log,
EndToolCall: "stopAgent",
})
locationAgent := NewLocationAgentWithComm(log.WithPrefix("Events 📅 > Locations 📍"), locationModel)
locationQuery := "Can you get me the ID of the location present in this image?"
locationAgent.Options.Query = &locationQuery
agentClient.ToolHandler.AddTool("listEvents", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return eventsModel.List(context.Background(), info.UserId)
})
agentClient.ToolHandler.AddTool("createEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := createEventArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return model.Events{}, err
}
ctx := context.Background()
layout := "2006-01-02T15:04:05Z"
startTime, err := time.Parse(layout, *args.StartDateTime)
if err != nil {
return model.Events{}, err
}
endTime, err := time.Parse(layout, *args.EndDateTime)
if err != nil {
return model.Events{}, err
}
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,
StartDateTime: &startTime,
EndDateTime: &endTime,
LocationID: &locationId,
})
if err != nil {
return model.Events{}, err
}
_, err = eventsModel.SaveToImage(ctx, info.ImageId, events.ID)
if err != nil {
return model.Events{}, err
}
return events, nil
})
agentClient.ToolHandler.AddTool("updateEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := updateEventArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return "", err
}
ctx := context.Background()
contactUuid, err := uuid.Parse(args.EventID)
if err != nil {
return "", err
}
eventsModel.SaveToImage(ctx, info.ImageId, contactUuid)
return "Saved", 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

@ -1,295 +0,0 @@
package agents
import (
"context"
"encoding/json"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"time"
"github.com/google/uuid"
)
// This prompt is probably shit.
const eventLocationPrompt = `
You are an agent that extracts events, locations, and organizers from an image. Your primary tasks are to identify and create locations and organizers before creating events. Follow these steps:
Identify and Create Locations:
Check if the image contains a location.
If a location is found, check if it exists in the listLocations.
If the location does not exist, create it first.
Always reuse existing locations from listLocations to avoid duplicates.
Identify and Create Events:
Check if the image contains an event. An event should have a name and a date.
If an event is found, ensure you have a location (from step 1) and an organizer (from step 2) before creating the event.
Events must have an associated location and organizer. Do not create an event without these.
If possible, return a start time and an end time as ISO datetime strings.
Handling Images Without Events or Locations:
It is possible that the image does not contain an event or a location. In such cases, do not create an event.
Always prioritize the creation of locations and organizers before events. Ensure that all events have an associated location and organizer.
`
// TODO: this should be read directly from a file on load.
const TOOLS = `
[
{
"type": "function",
"function": {
"name": "createLocation",
"description": "Creates a location. No not use if you think an existing location is suitable!",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"address": {
"type": "string"
}
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "listLocations",
"description": "Lists the locations available",
"parameters": {
"type": "object",
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "createEvent",
"description": "Creates a new event",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"startDateTime": {
"type": "string",
"description": "The start time as an ISO string"
},
"endDateTime": {
"type": "string",
"description": "The end time as an ISO string"
},
"locationId": {
"type": "string",
"description": "The ID of the location, available by listLocations"
},
"organizerName": {
"type": "string",
"description": "The name of the organizer"
}
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "finish",
"description": "Nothing else to do. call this function.",
"parameters": {}
}
}
]
`
type EventLocationAgent struct {
client client.AgentClient
eventModel models.EventModel
locationModel models.LocationModel
contactModel models.ContactModel
toolHandler client.ToolsHandlers
}
type ListLocationArguments struct{}
type ListOrganizerArguments struct{}
type CreateLocationArguments struct {
Name string `json:"name"`
Address *string `json:"address,omitempty"`
Coordinates *string `json:"coordinates,omitempty"`
}
type CreateOrganizerArguments struct {
Name string `json:"name"`
PhoneNumber *string `json:"phoneNumber,omitempty"`
Email *string `json:"email,omitempty"`
}
type AttachImageLocationArguments struct {
LocationId string `json:"locationId"`
}
type CreateEventArguments struct {
Name string `json:"name"`
StartDateTime string `json:"startDateTime"`
EndDateTime string `json:"endDateTime"`
LocationId string `json:"locationId"`
OrganizerName string `json:"organizerName"`
}
func (agent EventLocationAgent) GetLocations(userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error {
var tools any
err := json.Unmarshal([]byte(TOOLS), &tools)
toolChoice := "any"
request := client.AgentRequestBody{
Tools: &tools,
ToolChoice: &toolChoice,
Model: "pixtral-12b-2409",
Temperature: 0.3,
EndToolCall: "finish",
ResponseFormat: client.ResponseFormat{
Type: "text",
},
Chat: &client.Chat{
Messages: make([]client.ChatMessage, 0),
},
}
request.Chat.AddSystem(eventLocationPrompt)
request.Chat.AddImage(imageName, imageData)
_, err = agent.client.Request(&request)
if err != nil {
return err
}
toolHandlerInfo := client.ToolHandlerInfo{
ImageId: imageId,
UserId: userId,
}
return agent.client.ToolLoop(toolHandlerInfo, &request)
}
func NewLocationEventAgent(locationModel models.LocationModel, eventModel models.EventModel, contactModel models.ContactModel) (EventLocationAgent, error) {
agentClient, err := client.CreateAgentClient()
if err != nil {
return EventLocationAgent{}, err
}
agent := EventLocationAgent{
client: agentClient,
locationModel: locationModel,
eventModel: eventModel,
contactModel: contactModel,
}
agentClient.ToolHandler.AddTool("listLocations",
func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return agent.locationModel.List(context.Background(), info.UserId)
},
)
agentClient.ToolHandler.AddTool("createLocation",
func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := CreateLocationArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return model.Locations{}, err
}
ctx := context.Background()
location, err := agent.locationModel.Save(ctx, info.UserId, model.Locations{
Name: args.Name,
Address: args.Address,
})
if err != nil {
return location, err
}
_, err = agent.locationModel.SaveToImage(ctx, info.ImageId, location.ID)
return location, err
},
)
agentClient.ToolHandler.AddTool("createEvent",
func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
args := CreateEventArguments{}
err := json.Unmarshal([]byte(_args), &args)
if err != nil {
return model.Locations{}, err
}
ctx := context.Background()
layout := "2006-01-02T15:04:05Z"
startTime, err := time.Parse(layout, args.StartDateTime)
if err != nil {
return model.Events{}, err
}
endTime, err := time.Parse(layout, args.EndDateTime)
if err != nil {
return model.Events{}, err
}
event, err := agent.eventModel.Save(ctx, info.UserId, model.Events{
Name: args.Name,
StartDateTime: &startTime,
EndDateTime: &endTime,
})
if err != nil {
return event, err
}
organizer, err := agent.contactModel.Save(ctx, info.UserId, model.Contacts{
Name: args.Name,
})
if err != nil {
return event, err
}
_, err = agent.eventModel.SaveToImage(ctx, info.ImageId, event.ID)
if err != nil {
return event, err
}
_, err = agent.contactModel.SaveToImage(ctx, info.ImageId, organizer.ID)
if err != nil {
return event, err
}
locationId, err := uuid.Parse(args.LocationId)
if err != nil {
return event, err
}
event, err = agent.eventModel.UpdateLocation(ctx, event.ID, locationId)
if err != nil {
return event, err
}
return agent.eventModel.UpdateOrganizer(ctx, event.ID, organizer.ID)
},
)
return agent, nil
}

View File

@ -0,0 +1,196 @@
package agents
import (
"context"
"encoding/json"
"fmt"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
const locationPrompt = `
Role: Location AI Assistant
Objective: Identify locations from images/text, manage a saved list (create, update), and answer user queries about saved locations using the provided tools.
Core Logic:
**Extract Location Details:** Attempt to extract location details (like InputName, InputAddress) from the user's input (image or text).
* If no details can be extracted, inform the user and use stopAgent.
**Check for Existing Location:** If details *were* extracted:
* Use listLocations with the extracted InputName and/or InputAddress to search for potentially matching locations already saved in the list.
**Decide Action based on Search Results:**
* **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.
4. **Finalize:** After successfully calling upsertLocation (or determining no action could be taken), use stopAgent.
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 = `
[
{
"type": "function",
"function": {
"name": "listLocations",
"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": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "upsertLocation",
"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": {
"type": "object",
"properties": {
"name": {
"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": {
"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"]
}
}
},
%s
{
"type": "function",
"function": {
"name": "stopAgent",
"description": "Use this tool to signal that the contact processing for the current image is complete.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
}
]`
func getLocationAgentTools(allowReply bool) string {
if allowReply {
return fmt.Sprintf(locationTools, replyTool)
} else {
return fmt.Sprintf(locationTools, "")
}
}
type listLocationArguments struct{}
type upsertLocationArguments struct {
Name string `json:"name"`
LocationID *string `json:"locationId"`
Address *string `json:"address"`
}
func NewLocationAgentWithComm(log *log.Logger, locationModel models.LocationModel) client.AgentClient {
client := NewLocationAgent(log, locationModel)
client.Options.JsonTools = getLocationAgentTools(true)
return client
}
func NewLocationAgent(log *log.Logger, locationModel models.LocationModel) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: locationPrompt,
JsonTools: getLocationAgentTools(false),
Log: log,
EndToolCall: "stopAgent",
})
agentClient.ToolHandler.AddTool("listLocations", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
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)
if err != nil {
return model.Locations{}, err
}
ctx := context.Background()
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,
Address: args.Address,
})
if err != nil {
return model.Locations{}, err
}
_, err = locationModel.SaveToImage(ctx, info.ImageId, location.ID)
if err != nil {
return model.Locations{}, err
}
return location, nil
})
agentClient.ToolHandler.AddTool("reply", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "ok", nil
})
return agentClient
}

View File

@ -6,6 +6,7 @@ import (
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"github.com/charmbracelet/log"
"github.com/google/uuid"
)
@ -17,6 +18,8 @@ An image can have more than one note.
You must return markdown, and adapt the text to best fit markdown.
Do not return anything except markdown.
If the image contains code, add this inside code blocks. You must try and correctly guess the language too.
`
type NoteAgent struct {
@ -38,7 +41,7 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
}
request.Chat.AddSystem(noteAgentPrompt)
request.Chat.AddImage(imageName, imageData)
request.Chat.AddImage(imageName, imageData, nil)
resp, err := agent.client.Request(&request)
if err != nil {
@ -65,16 +68,16 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
return nil
}
func NewNoteAgent(noteModel models.NoteModel) (NoteAgent, error) {
client, err := client.CreateAgentClient()
if err != nil {
return NoteAgent{}, err
}
func NewNoteAgent(log *log.Logger, noteModel models.NoteModel) NoteAgent {
client := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: noteAgentPrompt,
Log: log,
})
agent := NoteAgent{
client: client,
noteModel: noteModel,
}
return agent, nil
return agent
}

View File

@ -1,59 +1,78 @@
package agents
import (
"encoding/json"
"errors"
"fmt"
"screenmark/screenmark/agents/client"
"github.com/google/uuid"
"github.com/charmbracelet/log"
)
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.
eventLocationAgent
Use it when you think the image contains an event or a location of any sort. This can be an event page, a map, an address or a date.
noteAgent
Use it when there is text on the screen. Any text, always use this. Use me!
defaultAgent
When none of the above apply.
Always call agents in parallel if you need to call more than 1.
* Call all applicable specialized agents (noteAgent, contactAgent, locationAgent, eventAgent) simultaneously (in parallel).
`
const MY_TOOLS = `
const orchestratorTools = `
[
{
"type": "function",
"function": {
"name": "eventLocationAgent",
"description": "Uses the event location agent",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "noteAgent",
"description": "Uses the note agent",
"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": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "contactAgent",
"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": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "locationAgent",
"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": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "eventAgent",
"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": {
"type": "object",
"properties": {},
@ -64,8 +83,8 @@ const MY_TOOLS = `
{
"type": "function",
"function": {
"name": "defaultAgent",
"description": "Used when you dont think its a good idea to call other agents",
"name": "noAgent",
"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": {
"type": "object",
"properties": {},
@ -73,95 +92,54 @@ const MY_TOOLS = `
}
}
}
]`
]
`
type OrchestratorAgent struct {
client client.AgentClient
Client client.AgentClient
log log.Logger
}
type Status struct {
Ok bool `json:"ok"`
}
// TODO: the primary function of the agent could be extracted outwards.
// This is basically the same function as we have in the `event_location_agent.go`
func (agent OrchestratorAgent) Orchestrate(userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error {
toolChoice := "any"
var tools any
err := json.Unmarshal([]byte(MY_TOOLS), &tools)
if err != nil {
return err
}
request := client.AgentRequestBody{
Model: "pixtral-12b-2409",
Temperature: 0.3,
ResponseFormat: client.ResponseFormat{
Type: "text",
},
ToolChoice: &toolChoice,
Tools: &tools,
EndToolCall: "defaultAgent",
Chat: &client.Chat{
Messages: make([]client.ChatMessage, 0),
},
}
request.Chat.AddSystem(orchestratorPrompt)
request.Chat.AddImage(imageName, imageData)
res, err := agent.client.Request(&request)
if err != nil {
return err
}
fmt.Println(res)
toolHandlerInfo := client.ToolHandlerInfo{
ImageId: imageId,
UserId: userId,
}
return agent.client.ToolLoop(toolHandlerInfo, &request)
}
func NewOrchestratorAgent(eventLocationAgent EventLocationAgent, noteAgent NoteAgent, imageName string, imageData []byte) (OrchestratorAgent, error) {
agent, err := client.CreateAgentClient()
if err != nil {
return OrchestratorAgent{}, err
}
agent.ToolHandler.AddTool("eventLocationAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
// We need a way to keep track of this async?
// Probably just a DB, because we don't want to wait. The orchistrator shouldnt wait for this stuff to finish.
go eventLocationAgent.GetLocations(info.UserId, info.ImageId, imageName, imageData)
return Status{
Ok: true,
}, nil
func NewOrchestratorAgent(log *log.Logger, noteAgent NoteAgent, contactAgent client.AgentClient, locationAgent client.AgentClient, eventAgent client.AgentClient, imageName string, imageData []byte) client.AgentClient {
agent := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: orchestratorPrompt,
JsonTools: orchestratorTools,
Log: log,
EndToolCall: "noAgent",
})
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{
Ok: true,
}, nil
return "noteAgent called successfully", nil
})
agent.ToolHandler.AddTool("defaultAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
// To nothing
agent.ToolHandler.AddTool("contactAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
go contactAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
return Status{
Ok: true,
}, errors.New("Finished! Kinda bad return type but...")
return "contactAgent called successfully", nil
})
return OrchestratorAgent{
client: agent,
}, nil
agent.ToolHandler.AddTool("locationAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
// go locationAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
return "locationAgent called successfully", nil
})
agent.ToolHandler.AddTool("eventAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
// go eventAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
return "eventAgent called successfully", nil
})
agent.ToolHandler.AddTool("noAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
return "ok", nil
})
return agent
}

View File

@ -1,108 +0,0 @@
{
"name": "image_info",
"strict": true,
"schema": {
"type": "object",
"title": "image",
"required": ["tags", "text", "links"],
"additionalProperties": false,
"properties": {
"tags": {
"type": "array",
"title": "tags",
"description": "A list of tags you think the image is relevant to.",
"items": {
"type": "string"
}
},
"text": {
"type": "array",
"title": "text",
"description": "A list of sentences the image contains.",
"items": {
"type": "string"
}
},
"links": {
"type": "array",
"title": "links",
"description": "A list of all the links you can find in the image.",
"items": {
"type": "string"
}
},
"locations": {
"title": "locations",
"type": "array",
"description": "A list of locations you can find on the image, if any",
"items": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": {
"title": "name",
"type": "string"
},
"coordinates": {
"title": "coordinates",
"type": "string"
},
"address": {
"title": "address",
"type": "string"
},
"description": {
"title": "description",
"type": "string"
}
}
}
},
"events": {
"title": "events",
"type": "array",
"description": "A list of events you find on the image, if any",
"items": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"title": "name"
},
"locations": {
"title": "locations",
"type": "array",
"description": "A list of locations on this event, if any",
"items": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": {
"title": "name",
"type": "string"
},
"coordinates": {
"title": "coordinates",
"type": "string"
},
"address": {
"title": "address",
"type": "string"
},
"description": {
"title": "description",
"type": "string"
}
}
}
}
}
}
}
}
}
}

View File

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

@ -56,6 +56,7 @@ func CreateMailClient() (Mailer, error) {
client, err := mail.NewClient(
"smtp.mailbox.org",
mail.WithTLSPortPolicy(mail.TLSMandatory),
mail.WithSMTPAuth(mail.SMTPAuthPlain),
mail.WithUsername(os.Getenv("EMAIL_USERNAME")),
mail.WithPassword(os.Getenv("EMAIL_PASSWORD")),

View File

@ -3,17 +3,29 @@ package main
import (
"context"
"database/sql"
"log"
"os"
"screenmark/screenmark/agents"
"screenmark/screenmark/models"
"time"
"github.com/charmbracelet/log"
"github.com/google/uuid"
"github.com/lib/pq"
)
func ListenNewImageEvents(db *sql.DB) {
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) {
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
if err != nil {
panic(err)
@ -27,6 +39,9 @@ func ListenNewImageEvents(db *sql.DB) {
imageModel := models.NewImageModel(db)
contactModel := models.NewContactModel(db)
databaseEventLog := createLogger("Database Events 🤖")
databaseEventLog.SetLevel(log.DebugLevel)
err := listener.Listen("new_image")
if err != nil {
panic(err)
@ -36,44 +51,86 @@ func ListenNewImageEvents(db *sql.DB) {
select {
case parameters := <-listener.Notify:
imageId := uuid.MustParse(parameters.Extra)
eventManager.listeners[parameters.Extra] = make(chan string)
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
ctx := context.Background()
go func() {
locationAgent, err := agents.NewLocationEventAgent(locationModel, eventModel, contactModel)
if err != nil {
panic(err)
}
noteAgent, err := agents.NewNoteAgent(noteModel)
if err != nil {
panic(err)
}
noteAgent := agents.NewNoteAgent(createLogger("Notes 📝"), noteModel)
contactAgent := agents.NewContactAgent(createLogger("Contacts 👥"), contactModel)
locationAgent := agents.NewLocationAgent(createLogger("Locations 📍"), locationModel)
eventAgent := agents.NewEventAgent(createLogger("Events 📅"), eventModel, locationModel)
image, err := imageModel.GetToProcessWithData(ctx, imageId)
if err != nil {
log.Println("Failed to GetToProcessWithData")
log.Println(err)
databaseEventLog.Error("Failed to GetToProcessWithData", "error", err)
return
}
if err := imageModel.StartProcessing(ctx, image.ID); err != nil {
databaseEventLog.Error("Failed to FinishProcessing", "error", err)
return
}
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 {
databaseEventLog.Error("Orchestrator failed", "error", err)
return
}
_, err = imageModel.FinishProcessing(ctx, image.ID)
if err != nil {
log.Println("Failed to FinishProcessing")
log.Println(err)
databaseEventLog.Error("Failed to finish processing", "ImageID", imageId)
return
}
orchestrator, err := agents.NewOrchestratorAgent(locationAgent, noteAgent, image.Image.ImageName, image.Image.Image)
if err != nil {
panic(err)
}
err = orchestrator.Orchestrate(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
if err != nil {
log.Println(err)
}
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
}()
}
}
}
type EventManager struct {
// Maps processing image UUID to a channel
listeners map[string]chan string
}
func NewEventManager() EventManager {
return EventManager{
listeners: make(map[string]chan string),
}
}
func ListenProcessingImageStatus(db *sql.DB, eventManager *EventManager) {
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
if err != nil {
panic(err)
}
})
defer listener.Close()
if err := listener.Listen("new_processing_image_status"); err != nil {
panic(err)
}
for {
select {
case data := <-listener.Notify:
stringUuid := data.Extra[0:36]
status := data.Extra[36:]
imageListener, exists := eventManager.listeners[stringUuid]
if !exists {
continue
}
imageListener <- status
close(imageListener)
delete(eventManager.listeners, stringUuid)
}
}
}

View File

@ -3,17 +3,28 @@ module screenmark/screenmark
go 1.24.0
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v1.0.0 // indirect
github.com/charmbracelet/log v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.4.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-chi/chi/v5 v5.2.1 // indirect
github.com/go-jet/jet/v2 v2.12.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/wneessen/go-mail v0.6.2 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -1,9 +1,19 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-jet/jet/v2 v2.12.0 h1:z2JfvBAZgsfxlQz6NXBYdZTXc7ep3jhbszTLtETv1JE=
github.com/go-jet/jet/v2 v2.12.0/go.mod h1:ufQVRQeI1mbcO5R8uCEVcVf3Foej9kReBdwDx7YMWUM=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@ -13,8 +23,16 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@ -29,6 +47,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -55,10 +75,12 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View File

@ -2,6 +2,7 @@ package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
@ -12,6 +13,7 @@ import (
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@ -48,7 +50,10 @@ func main() {
auth := CreateAuth(mail)
go ListenNewImageEvents(db)
eventManager := NewEventManager()
go ListenNewImageEvents(db, &eventManager)
go ListenProcessingImageStatus(db, &eventManager)
r := chi.NewRouter()
@ -86,19 +91,36 @@ func main() {
}
dataTypes := make([]DataType, 0)
// lord
// forgive me
idMap := make(map[uuid.UUID]bool)
for _, image := range images {
for _, location := range image.Locations {
_, exists := idMap[location.ID]
if exists {
continue
}
dataTypes = append(dataTypes, DataType{
Type: "location",
Data: location,
})
idMap[location.ID] = true
}
for _, event := range image.Events {
_, exists := idMap[event.ID]
if exists {
continue
}
dataTypes = append(dataTypes, DataType{
Type: "event",
Data: event,
})
idMap[event.ID] = true
}
for _, note := range image.Notes {
@ -106,6 +128,20 @@ func main() {
Type: "note",
Data: note,
})
idMap[note.ID] = true
}
for _, contact := range image.Contacts {
_, exists := idMap[contact.ID]
if exists {
continue
}
dataTypes = append(dataTypes, DataType{
Type: "contact",
Data: contact,
})
idMap[contact.ID] = true
}
}
@ -210,7 +246,7 @@ func main() {
return
}
userImage, err := imageModel.Process(r.Context(), uuid.MustParse(userId), model.Image{
userImage, err := imageModel.Process(r.Context(), userId, model.Image{
Image: image,
ImageName: imageName,
})
@ -239,6 +275,41 @@ func main() {
})
r.Get("/image-events/{id}", func(w http.ResponseWriter, r *http.Request) {
// TODO: authentication :)
id := r.PathValue("id")
imageNotifier, exists := eventManager.listeners[id]
if !exists {
fmt.Println("Not found!")
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.(http.Flusher).Flush()
ctx, cancel := context.WithCancel(r.Context())
for {
select {
case <-ctx.Done():
fmt.Fprint(w, "event: close\ndata: Connection closed\n\n")
w.(http.Flusher).Flush()
cancel()
return
case data := <-imageNotifier:
fmt.Printf("Status received: %s\n", data)
fmt.Fprintf(w, "data: %s-%s\n", data, time.Now().String())
w.(http.Flusher).Flush()
cancel()
}
}
})
r.Post("/login", func(w http.ResponseWriter, r *http.Request) {
type LoginBody struct {
Email string `json:"email"`
@ -286,6 +357,12 @@ func main() {
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)
if err != nil {
log.Println(err)

View File

@ -2,7 +2,6 @@ package main
import (
"context"
"fmt"
"net/http"
)
@ -26,7 +25,6 @@ func ProtectedRoute(next http.Handler) http.Handler {
return
}
fmt.Println(token[len("Bearer "):])
userId, err := GetUserIdFromAccess(token[len("Bearer "):])
if err != nil {
w.WriteHeader(http.StatusUnauthorized)

View File

@ -29,9 +29,56 @@ func (m ContactModel) List(ctx context.Context, userId uuid.UUID) ([]model.Conta
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) {
// TODO: make this a transaction
if contact.ID != uuid.Nil {
return m.Update(ctx, contact)
}
insertContactStmt := Contacts.
INSERT(Contacts.Name, Contacts.Description, Contacts.PhoneNumber, Contacts.Email).
VALUES(contact.Name, contact.Description, contact.PhoneNumber, contact.Email).

View File

@ -14,11 +14,25 @@ type EventModel struct {
dbPool *sql.DB
}
func (m EventModel) List(ctx context.Context, userId uuid.UUID) ([]model.Events, error) {
listEventsStmt := SELECT(Events.AllColumns).
FROM(
Events.
INNER_JOIN(UserEvents, UserEvents.EventID.EQ(Events.ID)),
).
WHERE(UserEvents.UserID.EQ(UUID(userId)))
events := []model.Events{}
err := listEventsStmt.QueryContext(ctx, m.dbPool, &events)
return events, err
}
func (m EventModel) Save(ctx context.Context, userId uuid.UUID, event model.Events) (model.Events, error) {
// TODO tx here
insertEventStmt := Events.
INSERT(Events.Name, Events.Description, Events.StartDateTime, Events.EndDateTime).
VALUES(event.Name, event.Description, event.StartDateTime, event.EndDateTime).
INSERT(Events.MutableColumns).
MODEL(event).
RETURNING(Events.AllColumns)
insertedEvent := model.Events{}

View File

@ -130,6 +130,17 @@ func (m ImageModel) FinishProcessing(ctx context.Context, imageId uuid.UUID) (mo
return userImage, err
}
func (m ImageModel) StartProcessing(ctx context.Context, processingImageId uuid.UUID) error {
startProcessingStmt := UserImagesToProcess.
UPDATE(UserImagesToProcess.Status).
SET(model.Progress_InProgress).
WHERE(UserImagesToProcess.ID.EQ(UUID(processingImageId)))
_, err := startProcessingStmt.ExecContext(ctx, m.dbPool)
return err
}
func (m ImageModel) Get(ctx context.Context, imageId uuid.UUID) (ImageData, error) {
getImageStmt := SELECT(UserImages.AllColumns, Image.AllColumns).
FROM(

View File

@ -7,6 +7,7 @@ import (
. "screenmark/screenmark/.gen/haystack/haystack/table"
. "github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
)
@ -29,7 +30,50 @@ func (m LocationModel) List(ctx context.Context, userId uuid.UUID) ([]model.Loca
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) {
if location.ID != uuid.Nil {
return m.Update(ctx, location)
}
insertLocationStmt := Locations.
INSERT(Locations.Name, Locations.Address, Locations.Description).
VALUES(location.Name, location.Address, location.Description).
@ -51,13 +95,32 @@ func (m LocationModel) Save(ctx context.Context, userId uuid.UUID, location mode
}
func (m LocationModel) SaveToImage(ctx context.Context, imageId uuid.UUID, locationId uuid.UUID) (model.ImageLocations, error) {
imageLocation := model.ImageLocations{}
checkExistingStmt := ImageLocations.
SELECT(ImageLocations.AllColumns).
WHERE(
ImageLocations.ImageID.EQ(UUID(imageId)).
AND(ImageLocations.LocationID.EQ(UUID(locationId))),
)
err := checkExistingStmt.QueryContext(ctx, m.dbPool, &imageLocation)
if err != nil && err != qrm.ErrNoRows {
// A real error
return model.ImageLocations{}, err
}
if err == nil {
// Already exists.
return imageLocation, nil
}
insertImageLocationStmt := ImageLocations.
INSERT(ImageLocations.ImageID, ImageLocations.LocationID).
VALUES(imageId, locationId).
RETURNING(ImageLocations.AllColumns)
imageLocation := model.ImageLocations{}
err := insertImageLocationStmt.QueryContext(ctx, m.dbPool, &imageLocation)
err = insertImageLocationStmt.QueryContext(ctx, m.dbPool, &imageLocation)
return imageLocation, err
}

View File

@ -4,12 +4,12 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"screenmark/screenmark/.gen/haystack/haystack/model"
. "screenmark/screenmark/.gen/haystack/haystack/table"
. "github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
)
@ -30,15 +30,9 @@ type ImageWithProperties struct {
Text []model.ImageText
Locations []model.Locations
Events []struct {
model.Events
Location *model.Locations
Organizer *model.Contacts
}
Notes []model.Notes
Events []model.Events
Notes []model.Notes
Contacts []model.Contacts
}
func getUserIdFromImage(ctx context.Context, dbPool *sql.DB, imageId uuid.UUID) (uuid.UUID, error) {
@ -93,11 +87,9 @@ func (m UserModel) ListWithProperties(ctx context.Context, userId uuid.UUID) ([]
LEFT_JOIN(Notes, Notes.ID.EQ(ImageNotes.NoteID))).
WHERE(UserImages.UserID.EQ(UUID(userId)))
fmt.Println(listWithPropertiesStmt.DebugSql())
images := []ImageWithProperties{}
err := listWithPropertiesStmt.QueryContext(ctx, m.dbPool, &images)
if err != nil {
return images, err
}
@ -113,6 +105,24 @@ func (m UserModel) GetUserIdFromEmail(ctx context.Context, email string) (uuid.U
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 {
return UserModel{dbPool: db}
}

View File

@ -1,6 +1,14 @@
DROP SCHEMA IF EXISTS haystack CASCADE;
DROP SCHEMA IF EXISTS agents CASCADE;
CREATE SCHEMA haystack;
CREATE SCHEMA agents;
/** -----| Haystack |----- **/
/* -----| Enums |----- */
CREATE TYPE haystack.progress AS ENUM('not-started','in-progress');
/* -----| Schema tables |----- */
@ -17,6 +25,7 @@ CREATE TABLE haystack.image (
CREATE TABLE haystack.user_images_to_process (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
status haystack.progress NOT NULL DEFAULT 'not-started',
image_id uuid NOT NULL UNIQUE REFERENCES haystack.image (id),
user_id uuid NOT NULL REFERENCES haystack.users (id)
);
@ -155,6 +164,14 @@ BEGIN
END
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_new_processing_image_status()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('new_processing_image_status', NEW.id::text || NEW.status::text);
RETURN NEW;
END
$$ LANGUAGE plpgsql;
/* -----| Triggers |----- */
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
@ -162,6 +179,35 @@ ON haystack.user_images_to_process
FOR EACH ROW
EXECUTE PROCEDURE notify_new_image();
CREATE OR REPLACE TRIGGER on_update_image_progress
AFTER UPDATE OF status
ON haystack.user_images_to_process
FOR EACH ROW
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 |----- */
-- Insert a user

View File

@ -1,75 +0,0 @@
[
{
"type": "function",
"function": {
"name": "createLocation",
"description": "Creates a location. No not use if you think an existing location is suitable!",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"address": {
"type": "string"
}
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "listLocations",
"description": "Lists the locations available",
"parameters": {
"type": "object",
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "createEvent",
"description": "Creates a new event",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"startDateTime": {
"type": "string",
"description": "The start time as an ISO string"
},
"endDateTime": {
"type": "string",
"description": "The end time as an ISO string"
},
"locationId": {
"type": "string",
"description": "The ID of the location, available by listLocations"
},
"organizerName": {
"type": "string",
"description": "The name of the organizer"
}
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "finish",
"description": "Nothing else to do, call this function.",
"parameters": {
"type": "object",
"properties": {}
}
}
}
]

Binary file not shown.

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
[package]
name = "haystack"
name = "Haystack"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
description = "Screenshots that organize themselves"
authors = ["Dmytro Kondakov", "John Costa"]
edition = "2021"
# 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"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-build = { version = "2.0.0-beta.12", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api"] }
tauri-plugin-opener = "2"
tauri = { version = "2.0.0-beta.12", features = ["macos-private-api"] }
tauri-plugin-opener = "2.0.0-beta.12"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-dialog = "2"
tauri-plugin-dialog = "2.0.0-beta.12"
notify = "6.1.1"
base64 = "0.21.7"
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]
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",
"opener: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 _};
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::fs;
use std::path::PathBuf;
use std::sync::mpsc::channel;
use std::sync::Arc;
use std::sync::Mutex;
use tauri::AppHandle;
use tauri::Emitter;
use tauri::{WebviewUrl, WebviewWindowBuilder};
mod commands;
mod shortcut;
mod state;
mod utils;
mod window;
struct WatcherState {
watcher: Option<RecommendedWatcher>,
}
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))
}
use state::new_shared_watcher_state;
use window::setup_window;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let watcher_state = Arc::new(Mutex::new(WatcherState::new()));
let watcher_state = new_shared_watcher_state();
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.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| {
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.inner_size(480.0, 360.0)
// .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);
}
}
setup_window(app)?;
shortcut::enable_shortcut(app);
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",
"productName": "haystack",
"productName": "Haystack",
"version": "0.1.0",
"identifier": "com.haystack.app",
"build": {

View File

@ -1,175 +1,35 @@
import { IconSearch } from "@tabler/icons-solidjs";
import clsx from "clsx";
import Fuse from "fuse.js";
import { For, createEffect, createResource, createSignal } from "solid-js";
import { SearchCardEvent } from "./components/search-card/SearchCardEvent";
import { SearchCardLocation } from "./components/search-card/SearchCardLocation";
import { UserImage, getUserImages } from "./network";
import { getCardSize } from "./utils/getCardSize";
import { SearchCardNote } from "./components/search-card/SearchCardNote";
import { A } from "@solidjs/router";
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,
});
import { Route, Router } from "@solidjs/router";
import { listen } from "@tauri-apps/api/event";
import { createEffect, onCleanup } from "solid-js";
import { Login } from "./Login";
import { ProtectedRoute } from "./ProtectedRoute";
import { Search } from "./Search";
import { Settings } from "./Settings";
import { ImageViewer } from "./components/ImageViewer";
export const App = () => {
createEffect(() => {
fuze = new Fuse<UserImage>(data() ?? [], {
keys: [
{ name: "data.Name", weight: 2 },
{ name: "rawData", weight: 1 },
],
threshold: 0.4,
// TODO: Don't use window.location.href
const unlisten = listen("focus-search", () => {
window.location.href = "/";
});
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 (
<>
<main class="container pt-2">
<A href="login">login</A>
<div class="px-4">
<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>
<ImageViewer />
<Router>
<Route path="/login" component={Login} />
<div class="px-4 mt-4 bg-white rounded-t-2xl">
<div class="h-[254px] mt-4 overflow-scroll scrollbar-hide">
{searchResults().length > 0 ? (
<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={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>
<Route path="/" component={ProtectedRoute}>
<Route path="/" component={Search} />
<Route path="/settings" component={Settings} />
</Route>
</Router>
</>
);
}
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 { 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 { type Component, Show, createSignal } from "solid-js";
import { isTokenValid } from "./ProtectedRoute";
import { base, postCode, postLogin } from "./network";
export const Login: Component = () => {
let form: HTMLFormElement | undefined;
@ -34,26 +34,31 @@ export const Login: Component = () => {
localStorage.setItem("access", access);
localStorage.setItem("refresh", refresh);
window.location.href = "/";
}
};
const isAuthorized = isTokenValid();
return (
<Show when={!isAuthorized} fallback={<Navigate href="/" />}>
<form ref={form} onSubmit={onSubmit}>
<TextField name="email">
<TextField.Label>Email</TextField.Label>
<TextField.Input />
</TextField>
<Show when={submitted()}>
<TextField name="code">
<TextField.Label>Code</TextField.Label>
<>
{base}
<Show when={!isAuthorized} fallback={<Navigate href="/" />}>
<form ref={form} onSubmit={onSubmit}>
<TextField name="email">
<TextField.Label>Email</TextField.Label>
<TextField.Input />
</TextField>
</Show>
<Button type="submit">Submit</Button>
</form>
</Show>
<Show when={submitted()}>
<TextField name="code">
<TextField.Label>Code</TextField.Label>
<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 { FolderPicker } from "./FolderPicker";
import { createEffect } from "solid-js";
import { sendImage } from "../network";
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
const unlisten = listen("png-processed", (event) => {
const unlisten = listen("png-processed", async (event) => {
console.log("Received processed PNG", event);
const base64Data = event.payload as string;
setLatestImage(`data:image/png;base64,${base64Data}`);
sendImage("test-image.png", base64Data);
// setLatestImage(`data:image/png;base64,${base64Data}`);
const result = await sendImage("test-image.png", base64Data);
window.location.reload();
console.log("DBG: ", result);
});
return () => {
@ -21,20 +23,22 @@ export function ImageViewer() {
};
});
return (
<div>
<FolderPicker />
return null;
{latestImage() && (
<div class="mt-4">
<h3>Latest Processed Image:</h3>
<img
src={latestImage() || undefined}
alt="Latest processed"
class="max-w-md"
/>
</div>
)}
</div>
);
// return (
// <div>
// <FolderPicker />
// {latestImage() && (
// <div class="mt-4">
// <h3>Latest Processed Image:</h3>
// <img
// src={latestImage() || undefined}
// alt="Latest processed"
// 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

@ -1,10 +1,10 @@
import { Separator } from "@kobalte/core/separator";
import { IconUser } from "@tabler/icons-solidjs";
import type { Contact } from "../../network/types";
import type { UserImage } from "../../network";
type Props = {
item: Contact;
item: Extract<UserImage, { type: "contact" }>;
};
export const SearchCardContact = ({ item }: Props) => {
@ -12,15 +12,15 @@ export const SearchCardContact = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-orange-50">
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
<p class="text-sm text-neutral-900 font-bold">{data.name}</p>
<IconUser size={20} class="text-neutral-500 mt-1" />
<div class="flex mb-1 items-center gap-1">
<IconUser size={14} class="text-neutral-500" />
<p class="text-xs text-neutral-500">Contact</p>
</div>
<p class="text-xs text-neutral-500">{data.phoneNumber}</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.notes}
<p class="text-sm text-neutral-900 font-bold mb-1">
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
</p>
<p class="text-xs text-neutral-700">Phone: {data.PhoneNumber}</p>
<p class="text-xs text-neutral-700">Mail: {data.Email}</p>
</div>
);
};

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { Separator } from "@kobalte/core/separator";
import SolidjsMarkdown from "solidjs-markdown";
import { IconNote } from "@tabler/icons-solidjs";
import type { UserImage } from "../../network";
@ -12,14 +13,15 @@ export const SearchCardNote = ({ item }: Props) => {
return (
<div class="absolute inset-0 p-3 bg-green-50">
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
<IconNote size={20} class="text-neutral-500 mt-1" />
<div class="flex mb-1 items-center gap-1">
<IconNote size={14} class="text-neutral-500" />
<p class="text-xs text-neutral-500">Note</p>
</div>
<p class="text-xs text-neutral-500">Keywords TODO</p>
<Separator class="my-2" />
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
{data.Content}
<p class="text-sm text-neutral-900 font-bold mb-1">
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
</p>
<p class="text-xs text-neutral-700">
<SolidjsMarkdown>{data.Content}</SolidjsMarkdown>
</p>
</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 */
import { render } from "solid-js/web";
import App from "./App";
import "./index.css";
import { Route, Router } from "@solidjs/router";
import { ImagePage } from "./ImagePage";
import { Login } from "./Login";
import { ProtectedRoute } from "./ProtectedRoute";
render(
() => (
<Router>
<Route path="/login" component={Login} />
import { App } from "./App";
<Route path="/" component={ProtectedRoute}>
<Route path="/" component={App} />
<Route path="/image/:imageId" component={ImagePage} />
</Route>
</Router>
),
document.getElementById("root") as HTMLElement,
);
render(() => <App />, document.getElementById("root") as HTMLElement);

View File

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