Compare commits
83 Commits
feat/email
...
feat/agent
Author | SHA1 | Date | |
---|---|---|---|
6b0fcf3005 | |||
1b1f957e01 | |||
49969b0608 | |||
9b95ffb59e | |||
c9560f6881 | |||
c5535a5b3b | |||
5ab0d13b21 | |||
15289e4965 | |||
181da1f09d | |||
90b90a8185 | |||
fb30eb4ad6 | |||
5454a1cfaf | |||
3716d22eca | |||
6d2f0c6108 | |||
61c158d5b6 | |||
82331c0833 | |||
e42aa75639 | |||
fa486153b4 | |||
aacecfffac | |||
e89a342751 | |||
e16b6f4529 | |||
6ddae3426d | |||
67468bddb6 | |||
10bc0a04a2 | |||
8a57236f04 | |||
b138661991 | |||
6db9bb2ab3 | |||
6ae2458186 | |||
51d36bf15b | |||
ecc2da5f86 | |||
d7ab3f56dc | |||
55aa1e67ba | |||
1f83b721a6 | |||
0596ea2b1e | |||
3c1f6ba40f | |||
0eff145f02 | |||
1fa1db7d1b | |||
a1369719d7 | |||
40ddf737c8 | |||
ad14254ecb | |||
e8d996cec5 | |||
0ed6b4c123 | |||
0bc556f47c | |||
5a530b2e39 | |||
868c8e6409 | |||
30143019d6 | |||
cd5dd347d3 | |||
ab09378fcd | |||
18f85a8929 | |||
55614b34c7 | |||
664918f431 | |||
048fc38032 | |||
2f26b5dfd9 | |||
4f6c198307 | |||
c99d6e4e6b | |||
b97cf63484 | |||
7af536bd9c | |||
5406e79fc8 | |||
0e88f77474 | |||
878a47ffd1 | |||
eba4268718 | |||
5ae6a3403f | |||
3156cea904 | |||
d432d16752 | |||
98328be39d | |||
4d903f40bf | |||
24bed2aafb | |||
349dcc2275 | |||
47c871523d | |||
dcfed6a746 | |||
91b9e5402e | |||
cf7d5e0305 | |||
9bb07c1b9b | |||
959b741fcb | |||
91cc54aaec | |||
d786ab15c9 | |||
47e65e1609 | |||
91dd2f54ef | |||
42771ea958 | |||
77a0901352 | |||
a43efa014f | |||
4990cf9c43 | |||
9660c99a14 |
17
backend/.gen/haystack/agents/model/agents.go
Normal file
17
backend/.gen/haystack/agents/model/agents.go
Normal 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
|
||||||
|
}
|
18
backend/.gen/haystack/agents/model/system_prompts.go
Normal file
18
backend/.gen/haystack/agents/model/system_prompts.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SystemPrompts struct {
|
||||||
|
ID uuid.UUID `sql:"primary_key"`
|
||||||
|
Prompt string
|
||||||
|
AgentID uuid.UUID
|
||||||
|
}
|
18
backend/.gen/haystack/agents/model/tools.go
Normal file
18
backend/.gen/haystack/agents/model/tools.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tools struct {
|
||||||
|
ID uuid.UUID `sql:"primary_key"`
|
||||||
|
Tool string
|
||||||
|
AgentID uuid.UUID
|
||||||
|
}
|
78
backend/.gen/haystack/agents/table/agents.go
Normal file
78
backend/.gen/haystack/agents/table/agents.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
81
backend/.gen/haystack/agents/table/system_prompts.go
Normal file
81
backend/.gen/haystack/agents/table/system_prompts.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var 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,
|
||||||
|
}
|
||||||
|
}
|
16
backend/.gen/haystack/agents/table/table_use_schema.go
Normal file
16
backend/.gen/haystack/agents/table/table_use_schema.go
Normal 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)
|
||||||
|
}
|
81
backend/.gen/haystack/agents/table/tools.go
Normal file
81
backend/.gen/haystack/agents/table/tools.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var 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,
|
||||||
|
}
|
||||||
|
}
|
18
backend/.gen/haystack/haystack/enum/progress.go
Normal file
18
backend/.gen/haystack/haystack/enum/progress.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package 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"),
|
||||||
|
}
|
49
backend/.gen/haystack/haystack/model/progress.go
Normal file
49
backend/.gen/haystack/haystack/model/progress.go
Normal 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)
|
||||||
|
}
|
@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
type UserImagesToProcess struct {
|
type UserImagesToProcess struct {
|
||||||
ID uuid.UUID `sql:"primary_key"`
|
ID uuid.UUID `sql:"primary_key"`
|
||||||
|
Status Progress
|
||||||
ImageID uuid.UUID
|
ImageID uuid.UUID
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ type userImagesToProcessTable struct {
|
|||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
ID postgres.ColumnString
|
ID postgres.ColumnString
|
||||||
|
Status postgres.ColumnString
|
||||||
ImageID postgres.ColumnString
|
ImageID postgres.ColumnString
|
||||||
UserID postgres.ColumnString
|
UserID postgres.ColumnString
|
||||||
|
|
||||||
@ -61,10 +62,11 @@ func newUserImagesToProcessTable(schemaName, tableName, alias string) *UserImage
|
|||||||
func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userImagesToProcessTable {
|
func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userImagesToProcessTable {
|
||||||
var (
|
var (
|
||||||
IDColumn = postgres.StringColumn("id")
|
IDColumn = postgres.StringColumn("id")
|
||||||
|
StatusColumn = postgres.StringColumn("status")
|
||||||
ImageIDColumn = postgres.StringColumn("image_id")
|
ImageIDColumn = postgres.StringColumn("image_id")
|
||||||
UserIDColumn = postgres.StringColumn("user_id")
|
UserIDColumn = postgres.StringColumn("user_id")
|
||||||
allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, UserIDColumn}
|
allColumns = postgres.ColumnList{IDColumn, StatusColumn, ImageIDColumn, UserIDColumn}
|
||||||
mutableColumns = postgres.ColumnList{ImageIDColumn, UserIDColumn}
|
mutableColumns = postgres.ColumnList{StatusColumn, ImageIDColumn, UserIDColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return userImagesToProcessTable{
|
return userImagesToProcessTable{
|
||||||
@ -72,6 +74,7 @@ func newUserImagesToProcessTableImpl(schemaName, tableName, alias string) userIm
|
|||||||
|
|
||||||
//Columns
|
//Columns
|
||||||
ID: IDColumn,
|
ID: IDColumn,
|
||||||
|
Status: StatusColumn,
|
||||||
ImageID: ImageIDColumn,
|
ImageID: ImageIDColumn,
|
||||||
UserID: UserIDColumn,
|
UserID: UserIDColumn,
|
||||||
|
|
||||||
|
@ -65,8 +65,8 @@ func (m ChatUserMessage) MarshalJSON() ([]byte, error) {
|
|||||||
})
|
})
|
||||||
case ArrayMessage:
|
case ArrayMessage:
|
||||||
return json.Marshal(&struct {
|
return json.Marshal(&struct {
|
||||||
Role UserRole `json:"role"`
|
Role UserRole `json:"role"`
|
||||||
Content []ImageMessageContent `json:"content"`
|
Content []MessageContentMessage `json:"content"`
|
||||||
}{
|
}{
|
||||||
Role: User,
|
Role: User,
|
||||||
Content: t.Content,
|
Content: t.Content,
|
||||||
@ -121,18 +121,35 @@ func (m SingleMessage) IsSingleMessage() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ArrayMessage struct {
|
type ArrayMessage struct {
|
||||||
Content []ImageMessageContent `json:"content"`
|
Content []MessageContentMessage `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m ArrayMessage) IsSingleMessage() bool {
|
func (m ArrayMessage) IsSingleMessage() bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MessageContentMessage interface {
|
||||||
|
IsImageMessage() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextMessageContent struct {
|
||||||
|
TextType string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m TextMessageContent) IsImageMessage() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type ImageMessageContent struct {
|
type ImageMessageContent struct {
|
||||||
ImageType string `json:"type"`
|
ImageType string `json:"type"`
|
||||||
ImageUrl string `json:"image_url"`
|
ImageUrl string `json:"image_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m ImageMessageContent) IsImageMessage() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
type ImageContentUrl struct {
|
type ImageContentUrl struct {
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
}
|
}
|
||||||
@ -165,7 +182,7 @@ func (chat *Chat) AddSystem(prompt string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chat *Chat) AddImage(imageName string, image []byte) error {
|
func (chat *Chat) AddImage(imageName string, image []byte, query *string) error {
|
||||||
extension := filepath.Ext(imageName)
|
extension := filepath.Ext(imageName)
|
||||||
if len(extension) == 0 {
|
if len(extension) == 0 {
|
||||||
// TODO: could also validate for image types we support.
|
// TODO: could also validate for image types we support.
|
||||||
@ -173,14 +190,28 @@ func (chat *Chat) AddImage(imageName string, image []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension = extension[1:]
|
extension = extension[1:]
|
||||||
|
|
||||||
encodedString := base64.StdEncoding.EncodeToString(image)
|
encodedString := base64.StdEncoding.EncodeToString(image)
|
||||||
|
|
||||||
messageContent := ArrayMessage{
|
contentLength := 1
|
||||||
Content: make([]ImageMessageContent, 1),
|
if query != nil {
|
||||||
|
contentLength += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
messageContent.Content[0] = ImageMessageContent{
|
messageContent := ArrayMessage{
|
||||||
|
Content: make([]MessageContentMessage, contentLength),
|
||||||
|
}
|
||||||
|
|
||||||
|
index := 0
|
||||||
|
|
||||||
|
if query != nil {
|
||||||
|
messageContent.Content[index] = TextMessageContent{
|
||||||
|
TextType: "text",
|
||||||
|
Text: *query,
|
||||||
|
}
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
messageContent.Content[index] = ImageMessageContent{
|
||||||
ImageType: "image_url",
|
ImageType: "image_url",
|
||||||
ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
|
ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ResponseFormat struct {
|
type ResponseFormat struct {
|
||||||
@ -69,16 +71,30 @@ type AgentClient struct {
|
|||||||
|
|
||||||
ToolHandler ToolsHandlers
|
ToolHandler ToolsHandlers
|
||||||
|
|
||||||
|
Log *log.Logger
|
||||||
|
|
||||||
|
Reply string
|
||||||
|
|
||||||
Do func(req *http.Request) (*http.Response, error)
|
Do func(req *http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
Options CreateAgentClientOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPENAI_API_KEY = "OPENAI_API_KEY"
|
const OPENAI_API_KEY = "OPENAI_API_KEY"
|
||||||
|
|
||||||
func CreateAgentClient() (AgentClient, error) {
|
type CreateAgentClientOptions struct {
|
||||||
|
Log *log.Logger
|
||||||
|
SystemPrompt string
|
||||||
|
JsonTools string
|
||||||
|
EndToolCall string
|
||||||
|
Query *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateAgentClient(options CreateAgentClientOptions) AgentClient {
|
||||||
apiKey := os.Getenv(OPENAI_API_KEY)
|
apiKey := os.Getenv(OPENAI_API_KEY)
|
||||||
|
|
||||||
if len(apiKey) == 0 {
|
if len(apiKey) == 0 {
|
||||||
return AgentClient{}, errors.New(OPENAI_API_KEY + " was not found.")
|
panic("No api key")
|
||||||
}
|
}
|
||||||
|
|
||||||
return AgentClient{
|
return AgentClient{
|
||||||
@ -89,10 +105,14 @@ func CreateAgentClient() (AgentClient, error) {
|
|||||||
return client.Do(req)
|
return client.Do(req)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Log: options.Log,
|
||||||
|
|
||||||
ToolHandler: ToolsHandlers{
|
ToolHandler: ToolsHandlers{
|
||||||
handlers: map[string]ToolHandler{},
|
handlers: map[string]ToolHandler{},
|
||||||
},
|
},
|
||||||
}, nil
|
|
||||||
|
Options: options,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client AgentClient) getRequest(body []byte) (*http.Request, error) {
|
func (client AgentClient) getRequest(body []byte) (*http.Request, error) {
|
||||||
@ -128,8 +148,6 @@ func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error)
|
|||||||
return AgentResponse{}, err
|
return AgentResponse{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(string(response))
|
|
||||||
|
|
||||||
agentResponse := AgentResponse{}
|
agentResponse := AgentResponse{}
|
||||||
err = json.Unmarshal(response, &agentResponse)
|
err = json.Unmarshal(response, &agentResponse)
|
||||||
|
|
||||||
@ -138,23 +156,36 @@ func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(agentResponse.Choices) != 1 {
|
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.")
|
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
|
return agentResponse, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error {
|
func (client *AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error {
|
||||||
for {
|
for {
|
||||||
err := client.Process(info, req)
|
response, err := client.Request(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = client.Request(req)
|
if response.Choices[0].FinishReason == "stop" {
|
||||||
|
client.Log.Debug("Agent is finished")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.Process(info, req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
|
if err == FinishedCall {
|
||||||
|
client.Log.Debug("Agent is finished")
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,7 +193,7 @@ func (client AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody)
|
|||||||
|
|
||||||
var FinishedCall = errors.New("Last tool tool was called")
|
var FinishedCall = errors.New("Last tool tool was called")
|
||||||
|
|
||||||
func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) error {
|
func (client *AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
message, err := req.Chat.GetLatest()
|
message, err := req.Chat.GetLatest()
|
||||||
@ -187,8 +218,50 @@ func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) e
|
|||||||
|
|
||||||
toolResponse := client.ToolHandler.Handle(info, toolCall)
|
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)
|
req.Chat.AddToolResponse(toolResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
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)
|
||||||
|
}
|
||||||
|
@ -8,8 +8,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ToolHandlerInfo struct {
|
type ToolHandlerInfo struct {
|
||||||
UserId uuid.UUID
|
UserId uuid.UUID
|
||||||
ImageId uuid.UUID
|
ImageId uuid.UUID
|
||||||
|
ImageName string
|
||||||
|
|
||||||
|
// Pointer because we don't want to copy this around too much.
|
||||||
|
Image *[]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolHandler struct {
|
type ToolHandler struct {
|
||||||
|
@ -2,8 +2,10 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
)
|
)
|
||||||
@ -28,6 +30,7 @@ func (suite *ToolTestSuite) SetupTest() {
|
|||||||
return false, errors.New("I will always error")
|
return false, errors.New("I will always error")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite.client.Log = log.New(os.Stdout)
|
||||||
suite.client.ToolHandler = suite.handler
|
suite.client.ToolHandler = suite.handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
180
backend/agents/contact_agent.go
Normal file
180
backend/agents/contact_agent.go
Normal 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
|
||||||
|
}
|
247
backend/agents/event_agent.go
Normal file
247
backend/agents/event_agent.go
Normal 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
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
196
backend/agents/location_agent.go
Normal file
196
backend/agents/location_agent.go
Normal 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
|
||||||
|
}
|
@ -6,6 +6,7 @@ import (
|
|||||||
"screenmark/screenmark/agents/client"
|
"screenmark/screenmark/agents/client"
|
||||||
"screenmark/screenmark/models"
|
"screenmark/screenmark/models"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
"github.com/google/uuid"
|
"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.
|
You must return markdown, and adapt the text to best fit markdown.
|
||||||
Do not return anything except 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 {
|
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.AddSystem(noteAgentPrompt)
|
||||||
request.Chat.AddImage(imageName, imageData)
|
request.Chat.AddImage(imageName, imageData, nil)
|
||||||
|
|
||||||
resp, err := agent.client.Request(&request)
|
resp, err := agent.client.Request(&request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -65,16 +68,16 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNoteAgent(noteModel models.NoteModel) (NoteAgent, error) {
|
func NewNoteAgent(log *log.Logger, noteModel models.NoteModel) NoteAgent {
|
||||||
client, err := client.CreateAgentClient()
|
client := client.CreateAgentClient(client.CreateAgentClientOptions{
|
||||||
if err != nil {
|
SystemPrompt: noteAgentPrompt,
|
||||||
return NoteAgent{}, err
|
Log: log,
|
||||||
}
|
})
|
||||||
|
|
||||||
agent := NoteAgent{
|
agent := NoteAgent{
|
||||||
client: client,
|
client: client,
|
||||||
noteModel: noteModel,
|
noteModel: noteModel,
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent
|
||||||
}
|
}
|
||||||
|
@ -1,59 +1,78 @@
|
|||||||
package agents
|
package agents
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"screenmark/screenmark/agents/client"
|
"screenmark/screenmark/agents/client"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/charmbracelet/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const orchestratorPrompt = `
|
const orchestratorPrompt = `
|
||||||
You are an Orchestrator for various AI agents.
|
**Role:** You are an Orchestrator AI responsible for analyzing images provided by the user.
|
||||||
|
|
||||||
The user will send you images and you have to determine which agents you have to call, in order to best help the user.
|
**Primary Task:** Examine the input image and determine which specialized AI agent(s), available as tool calls, should be invoked to process the relevant information within the image, or if no specialized processing is needed. Your goal is to either extract and structure useful information for the user by selecting the most appropriate tool(s) or explicitly indicate that no specific action is required.
|
||||||
|
|
||||||
You might decide no agent needs to be called.
|
**Analysis Process & Decision Logic:**
|
||||||
|
|
||||||
The agents are available as tool calls.
|
1. **Analyze Image Content:** Scrutinize the image for distinct types of information:
|
||||||
|
* General text/writing (including code, formulas)
|
||||||
|
* Information about a person or contact details
|
||||||
|
* Information about a place, location, or address
|
||||||
|
* Information about an event
|
||||||
|
* Content that doesn't fit any specific category or lacks actionable information.
|
||||||
|
|
||||||
Agents available:
|
2. **Agent Selection - Determine ALL that apply:**
|
||||||
|
* **contactAgent:** Is there information specifically related to a person or their contact details (e.g., business card, name/email/phone)? If YES, select contactAgent.
|
||||||
|
* **locationAgent:** Is there information specifically identifying a place, location, or address (e.g., map, street sign, address text)? If YES, select locationAgent.
|
||||||
|
* **eventAgent:** Is there information specifically related to an event (e.g., invitation, poster with date/time, schedule)? If YES, select eventAgent.
|
||||||
|
* **noteAgent** Does the image contain *any* text/writing (including code, formulas)?
|
||||||
|
* **noAgent**: Call this when you are done working on this image.
|
||||||
|
|
||||||
eventLocationAgent
|
* Call all applicable specialized agents (noteAgent, contactAgent, locationAgent, eventAgent) simultaneously (in parallel).
|
||||||
|
|
||||||
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.
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const MY_TOOLS = `
|
const orchestratorTools = `
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "eventLocationAgent",
|
|
||||||
"description": "Uses the event location agent",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
"required": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "noteAgent",
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {},
|
"properties": {},
|
||||||
@ -64,8 +83,8 @@ const MY_TOOLS = `
|
|||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "defaultAgent",
|
"name": "noAgent",
|
||||||
"description": "Used when you dont think its a good idea to call other agents",
|
"description": "Extracts details related to scheduled events, appointments, or specific occasions. Use when the image contains information like event titles, dates, times, venues, agendas, or descriptions, typically found on invitations, posters, calendar entries, or schedules.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {},
|
"properties": {},
|
||||||
@ -73,95 +92,54 @@ const MY_TOOLS = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]`
|
]
|
||||||
|
`
|
||||||
|
|
||||||
type OrchestratorAgent struct {
|
type OrchestratorAgent struct {
|
||||||
client client.AgentClient
|
Client client.AgentClient
|
||||||
|
|
||||||
|
log log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Ok bool `json:"ok"`
|
Ok bool `json:"ok"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: the primary function of the agent could be extracted outwards.
|
func NewOrchestratorAgent(log *log.Logger, noteAgent NoteAgent, contactAgent client.AgentClient, locationAgent client.AgentClient, eventAgent client.AgentClient, imageName string, imageData []byte) client.AgentClient {
|
||||||
// This is basically the same function as we have in the `event_location_agent.go`
|
agent := client.CreateAgentClient(client.CreateAgentClientOptions{
|
||||||
func (agent OrchestratorAgent) Orchestrate(userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error {
|
SystemPrompt: orchestratorPrompt,
|
||||||
toolChoice := "any"
|
JsonTools: orchestratorTools,
|
||||||
|
Log: log,
|
||||||
var tools any
|
EndToolCall: "noAgent",
|
||||||
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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
agent.ToolHandler.AddTool("noteAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
agent.ToolHandler.AddTool("noteAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||||
go noteAgent.GetNotes(info.UserId, info.ImageId, imageName, imageData)
|
// go noteAgent.GetNotes(info.UserId, info.ImageId, imageName, imageData)
|
||||||
|
|
||||||
return Status{
|
return "noteAgent called successfully", nil
|
||||||
Ok: true,
|
|
||||||
}, nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
agent.ToolHandler.AddTool("defaultAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
agent.ToolHandler.AddTool("contactAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||||
// To nothing
|
go contactAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
|
||||||
|
|
||||||
return Status{
|
return "contactAgent called successfully", nil
|
||||||
Ok: true,
|
|
||||||
}, errors.New("Finished! Kinda bad return type but...")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return OrchestratorAgent{
|
agent.ToolHandler.AddTool("locationAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||||
client: agent,
|
// go locationAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
|
||||||
}, nil
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -18,7 +17,7 @@ type Auth struct {
|
|||||||
mailer Mailer
|
mailer Mailer
|
||||||
}
|
}
|
||||||
|
|
||||||
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz")
|
var letterRunes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
|
|
||||||
func randString(n int) string {
|
func randString(n int) string {
|
||||||
b := make([]rune, n)
|
b := make([]rune, n)
|
||||||
@ -44,7 +43,6 @@ func (a *Auth) CreateCode(email string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) IsCodeValid(email string, code string) bool {
|
func (a *Auth) IsCodeValid(email string, code string) bool {
|
||||||
fmt.Println(a.codes)
|
|
||||||
existingCode, exists := a.codes[email]
|
existingCode, exists := a.codes[email]
|
||||||
if !exists {
|
if !exists {
|
||||||
return false
|
return false
|
||||||
@ -55,7 +53,6 @@ func (a *Auth) IsCodeValid(email string, code string) bool {
|
|||||||
|
|
||||||
func (a *Auth) UseCode(email string, code string) error {
|
func (a *Auth) UseCode(email string, code string) error {
|
||||||
if valid := a.IsCodeValid(email, code); !valid {
|
if valid := a.IsCodeValid(email, code); !valid {
|
||||||
fmt.Println("returning error?")
|
|
||||||
return errors.New("This code is invalid.")
|
return errors.New("This code is invalid.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
23
backend/builder/agents.go
Normal file
23
backend/builder/agents.go
Normal 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))
|
||||||
|
}
|
@ -56,6 +56,7 @@ func CreateMailClient() (Mailer, error) {
|
|||||||
|
|
||||||
client, err := mail.NewClient(
|
client, err := mail.NewClient(
|
||||||
"smtp.mailbox.org",
|
"smtp.mailbox.org",
|
||||||
|
mail.WithTLSPortPolicy(mail.TLSMandatory),
|
||||||
mail.WithSMTPAuth(mail.SMTPAuthPlain),
|
mail.WithSMTPAuth(mail.SMTPAuthPlain),
|
||||||
mail.WithUsername(os.Getenv("EMAIL_USERNAME")),
|
mail.WithUsername(os.Getenv("EMAIL_USERNAME")),
|
||||||
mail.WithPassword(os.Getenv("EMAIL_PASSWORD")),
|
mail.WithPassword(os.Getenv("EMAIL_PASSWORD")),
|
||||||
|
@ -3,17 +3,29 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"screenmark/screenmark/agents"
|
"screenmark/screenmark/agents"
|
||||||
"screenmark/screenmark/models"
|
"screenmark/screenmark/models"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
func 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) {
|
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
@ -27,6 +39,9 @@ func ListenNewImageEvents(db *sql.DB) {
|
|||||||
imageModel := models.NewImageModel(db)
|
imageModel := models.NewImageModel(db)
|
||||||
contactModel := models.NewContactModel(db)
|
contactModel := models.NewContactModel(db)
|
||||||
|
|
||||||
|
databaseEventLog := createLogger("Database Events 🤖")
|
||||||
|
databaseEventLog.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
err := listener.Listen("new_image")
|
err := listener.Listen("new_image")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
@ -36,44 +51,86 @@ func ListenNewImageEvents(db *sql.DB) {
|
|||||||
select {
|
select {
|
||||||
case parameters := <-listener.Notify:
|
case parameters := <-listener.Notify:
|
||||||
imageId := uuid.MustParse(parameters.Extra)
|
imageId := uuid.MustParse(parameters.Extra)
|
||||||
|
eventManager.listeners[parameters.Extra] = make(chan string)
|
||||||
|
|
||||||
|
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
locationAgent, err := agents.NewLocationEventAgent(locationModel, eventModel, contactModel)
|
noteAgent := agents.NewNoteAgent(createLogger("Notes 📝"), noteModel)
|
||||||
if err != nil {
|
contactAgent := agents.NewContactAgent(createLogger("Contacts 👥"), contactModel)
|
||||||
panic(err)
|
locationAgent := agents.NewLocationAgent(createLogger("Locations 📍"), locationModel)
|
||||||
}
|
eventAgent := agents.NewEventAgent(createLogger("Events 📅"), eventModel, locationModel)
|
||||||
|
|
||||||
noteAgent, err := agents.NewNoteAgent(noteModel)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
image, err := imageModel.GetToProcessWithData(ctx, imageId)
|
image, err := imageModel.GetToProcessWithData(ctx, imageId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Failed to GetToProcessWithData")
|
databaseEventLog.Error("Failed to GetToProcessWithData", "error", err)
|
||||||
log.Println(err)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = imageModel.FinishProcessing(ctx, image.ID)
|
_, err = imageModel.FinishProcessing(ctx, image.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Failed to FinishProcessing")
|
databaseEventLog.Error("Failed to finish processing", "ImageID", imageId)
|
||||||
log.Println(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
orchestrator, err := agents.NewOrchestratorAgent(locationAgent, noteAgent, image.Image.ImageName, image.Image.Image)
|
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = orchestrator.Orchestrate(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -3,17 +3,28 @@ module screenmark/screenmark
|
|||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
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/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/go-chi/chi/v5 v5.2.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-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/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/lib/pq v1.10.9 // 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/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/stretchr/testify v1.10.0 // indirect
|
||||||
github.com/wneessen/go-mail v0.6.2 // indirect
|
github.com/wneessen/go-mail v0.6.2 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // 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
|
golang.org/x/text v0.22.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
@ -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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
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 h1:z2JfvBAZgsfxlQz6NXBYdZTXc7ep3jhbszTLtETv1JE=
|
||||||
github.com/go-jet/jet/v2 v2.12.0/go.mod h1:ufQVRQeI1mbcO5R8uCEVcVf3Foej9kReBdwDx7YMWUM=
|
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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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=
|
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/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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
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.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 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.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-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.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.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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.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.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.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/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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -12,6 +13,7 @@ import (
|
|||||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||||
"screenmark/screenmark/agents/client"
|
"screenmark/screenmark/agents/client"
|
||||||
"screenmark/screenmark/models"
|
"screenmark/screenmark/models"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@ -48,7 +50,10 @@ func main() {
|
|||||||
|
|
||||||
auth := CreateAuth(mail)
|
auth := CreateAuth(mail)
|
||||||
|
|
||||||
go ListenNewImageEvents(db)
|
eventManager := NewEventManager()
|
||||||
|
|
||||||
|
go ListenNewImageEvents(db, &eventManager)
|
||||||
|
go ListenProcessingImageStatus(db, &eventManager)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
@ -86,19 +91,36 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dataTypes := make([]DataType, 0)
|
dataTypes := make([]DataType, 0)
|
||||||
|
|
||||||
|
// lord
|
||||||
|
// forgive me
|
||||||
|
idMap := make(map[uuid.UUID]bool)
|
||||||
|
|
||||||
for _, image := range images {
|
for _, image := range images {
|
||||||
for _, location := range image.Locations {
|
for _, location := range image.Locations {
|
||||||
|
_, exists := idMap[location.ID]
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
dataTypes = append(dataTypes, DataType{
|
dataTypes = append(dataTypes, DataType{
|
||||||
Type: "location",
|
Type: "location",
|
||||||
Data: location,
|
Data: location,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
idMap[location.ID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, event := range image.Events {
|
for _, event := range image.Events {
|
||||||
|
_, exists := idMap[event.ID]
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
dataTypes = append(dataTypes, DataType{
|
dataTypes = append(dataTypes, DataType{
|
||||||
Type: "event",
|
Type: "event",
|
||||||
Data: event,
|
Data: event,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
idMap[event.ID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, note := range image.Notes {
|
for _, note := range image.Notes {
|
||||||
@ -106,6 +128,20 @@ func main() {
|
|||||||
Type: "note",
|
Type: "note",
|
||||||
Data: 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userImage, err := imageModel.Process(r.Context(), uuid.MustParse(userId), model.Image{
|
userImage, err := imageModel.Process(r.Context(), userId, model.Image{
|
||||||
Image: image,
|
Image: image,
|
||||||
ImageName: imageName,
|
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) {
|
r.Post("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
type LoginBody struct {
|
type LoginBody struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@ -286,6 +357,12 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if exists := userModel.DoesUserExist(r.Context(), codeBody.Email); !exists {
|
||||||
|
userModel.Save(r.Context(), model.Users{
|
||||||
|
Email: codeBody.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
uuid, err := userModel.GetUserIdFromEmail(r.Context(), codeBody.Email)
|
uuid, err := userModel.GetUserIdFromEmail(r.Context(), codeBody.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
|
@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -26,7 +25,6 @@ func ProtectedRoute(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(token[len("Bearer "):])
|
|
||||||
userId, err := GetUserIdFromAccess(token[len("Bearer "):])
|
userId, err := GetUserIdFromAccess(token[len("Bearer "):])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
@ -29,9 +29,56 @@ func (m ContactModel) List(ctx context.Context, userId uuid.UUID) ([]model.Conta
|
|||||||
return locations, err
|
return locations, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m ContactModel) Get(ctx context.Context, contactId uuid.UUID) (model.Contacts, error) {
|
||||||
|
getContactStmt := Contacts.
|
||||||
|
SELECT(Contacts.AllColumns).
|
||||||
|
WHERE(Contacts.ID.EQ(UUID(contactId)))
|
||||||
|
|
||||||
|
contact := model.Contacts{}
|
||||||
|
err := getContactStmt.QueryContext(ctx, m.dbPool, &contact)
|
||||||
|
|
||||||
|
return contact, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ContactModel) Update(ctx context.Context, contact model.Contacts) (model.Contacts, error) {
|
||||||
|
existingContact, err := m.Get(ctx, contact.ID)
|
||||||
|
if err != nil {
|
||||||
|
return model.Contacts{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
existingContact.Name = contact.Name
|
||||||
|
|
||||||
|
if contact.Description != nil {
|
||||||
|
existingContact.Description = contact.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
if contact.PhoneNumber != nil {
|
||||||
|
existingContact.PhoneNumber = contact.PhoneNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
if contact.Email != nil {
|
||||||
|
existingContact.Email = contact.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContactStmt := Contacts.
|
||||||
|
UPDATE(Contacts.MutableColumns).
|
||||||
|
MODEL(existingContact).
|
||||||
|
WHERE(Contacts.ID.EQ(UUID(contact.ID))).
|
||||||
|
RETURNING(Contacts.AllColumns)
|
||||||
|
|
||||||
|
updatedContact := model.Contacts{}
|
||||||
|
err = updateContactStmt.QueryContext(ctx, m.dbPool, &updatedContact)
|
||||||
|
|
||||||
|
return updatedContact, err
|
||||||
|
}
|
||||||
|
|
||||||
func (m ContactModel) Save(ctx context.Context, userId uuid.UUID, contact model.Contacts) (model.Contacts, error) {
|
func (m ContactModel) Save(ctx context.Context, userId uuid.UUID, contact model.Contacts) (model.Contacts, error) {
|
||||||
// TODO: make this a transaction
|
// TODO: make this a transaction
|
||||||
|
|
||||||
|
if contact.ID != uuid.Nil {
|
||||||
|
return m.Update(ctx, contact)
|
||||||
|
}
|
||||||
|
|
||||||
insertContactStmt := Contacts.
|
insertContactStmt := Contacts.
|
||||||
INSERT(Contacts.Name, Contacts.Description, Contacts.PhoneNumber, Contacts.Email).
|
INSERT(Contacts.Name, Contacts.Description, Contacts.PhoneNumber, Contacts.Email).
|
||||||
VALUES(contact.Name, contact.Description, contact.PhoneNumber, contact.Email).
|
VALUES(contact.Name, contact.Description, contact.PhoneNumber, contact.Email).
|
||||||
|
@ -14,11 +14,25 @@ type EventModel struct {
|
|||||||
dbPool *sql.DB
|
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) {
|
func (m EventModel) Save(ctx context.Context, userId uuid.UUID, event model.Events) (model.Events, error) {
|
||||||
// TODO tx here
|
// TODO tx here
|
||||||
insertEventStmt := Events.
|
insertEventStmt := Events.
|
||||||
INSERT(Events.Name, Events.Description, Events.StartDateTime, Events.EndDateTime).
|
INSERT(Events.MutableColumns).
|
||||||
VALUES(event.Name, event.Description, event.StartDateTime, event.EndDateTime).
|
MODEL(event).
|
||||||
RETURNING(Events.AllColumns)
|
RETURNING(Events.AllColumns)
|
||||||
|
|
||||||
insertedEvent := model.Events{}
|
insertedEvent := model.Events{}
|
||||||
|
@ -130,6 +130,17 @@ func (m ImageModel) FinishProcessing(ctx context.Context, imageId uuid.UUID) (mo
|
|||||||
return userImage, err
|
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) {
|
func (m ImageModel) Get(ctx context.Context, imageId uuid.UUID) (ImageData, error) {
|
||||||
getImageStmt := SELECT(UserImages.AllColumns, Image.AllColumns).
|
getImageStmt := SELECT(UserImages.AllColumns, Image.AllColumns).
|
||||||
FROM(
|
FROM(
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
. "screenmark/screenmark/.gen/haystack/haystack/table"
|
. "screenmark/screenmark/.gen/haystack/haystack/table"
|
||||||
|
|
||||||
. "github.com/go-jet/jet/v2/postgres"
|
. "github.com/go-jet/jet/v2/postgres"
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@ -29,7 +30,50 @@ func (m LocationModel) List(ctx context.Context, userId uuid.UUID) ([]model.Loca
|
|||||||
return locations, err
|
return locations, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m LocationModel) Get(ctx context.Context, locationId uuid.UUID) (model.Locations, error) {
|
||||||
|
getLocationStmt := Locations.
|
||||||
|
SELECT(Locations.AllColumns).
|
||||||
|
WHERE(Locations.ID.EQ(UUID(locationId)))
|
||||||
|
|
||||||
|
location := model.Locations{}
|
||||||
|
err := getLocationStmt.QueryContext(ctx, m.dbPool, &location)
|
||||||
|
|
||||||
|
return location, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m LocationModel) Update(ctx context.Context, location model.Locations) (model.Locations, error) {
|
||||||
|
existingLocation, err := m.Get(ctx, location.ID)
|
||||||
|
if err != nil {
|
||||||
|
return model.Locations{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
existingLocation.Name = location.Name
|
||||||
|
|
||||||
|
if location.Description != nil {
|
||||||
|
existingLocation.Description = location.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
if location.Address != nil {
|
||||||
|
existingLocation.Address = location.Address
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLocationStmt := Locations.
|
||||||
|
UPDATE(Locations.MutableColumns).
|
||||||
|
MODEL(existingLocation).
|
||||||
|
WHERE(Locations.ID.EQ(UUID(location.ID))).
|
||||||
|
RETURNING(Locations.AllColumns)
|
||||||
|
|
||||||
|
updatedLocation := model.Locations{}
|
||||||
|
err = updateLocationStmt.QueryContext(ctx, m.dbPool, &updatedLocation)
|
||||||
|
|
||||||
|
return updatedLocation, err
|
||||||
|
}
|
||||||
|
|
||||||
func (m LocationModel) Save(ctx context.Context, userId uuid.UUID, location model.Locations) (model.Locations, error) {
|
func (m LocationModel) Save(ctx context.Context, userId uuid.UUID, location model.Locations) (model.Locations, error) {
|
||||||
|
if location.ID != uuid.Nil {
|
||||||
|
return m.Update(ctx, location)
|
||||||
|
}
|
||||||
|
|
||||||
insertLocationStmt := Locations.
|
insertLocationStmt := Locations.
|
||||||
INSERT(Locations.Name, Locations.Address, Locations.Description).
|
INSERT(Locations.Name, Locations.Address, Locations.Description).
|
||||||
VALUES(location.Name, location.Address, location.Description).
|
VALUES(location.Name, location.Address, location.Description).
|
||||||
@ -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) {
|
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.
|
insertImageLocationStmt := ImageLocations.
|
||||||
INSERT(ImageLocations.ImageID, ImageLocations.LocationID).
|
INSERT(ImageLocations.ImageID, ImageLocations.LocationID).
|
||||||
VALUES(imageId, locationId).
|
VALUES(imageId, locationId).
|
||||||
RETURNING(ImageLocations.AllColumns)
|
RETURNING(ImageLocations.AllColumns)
|
||||||
|
|
||||||
imageLocation := model.ImageLocations{}
|
err = insertImageLocationStmt.QueryContext(ctx, m.dbPool, &imageLocation)
|
||||||
err := insertImageLocationStmt.QueryContext(ctx, m.dbPool, &imageLocation)
|
|
||||||
|
|
||||||
return imageLocation, err
|
return imageLocation, err
|
||||||
}
|
}
|
||||||
|
@ -4,12 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||||
. "screenmark/screenmark/.gen/haystack/haystack/table"
|
. "screenmark/screenmark/.gen/haystack/haystack/table"
|
||||||
|
|
||||||
. "github.com/go-jet/jet/v2/postgres"
|
. "github.com/go-jet/jet/v2/postgres"
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -30,15 +30,9 @@ type ImageWithProperties struct {
|
|||||||
Text []model.ImageText
|
Text []model.ImageText
|
||||||
|
|
||||||
Locations []model.Locations
|
Locations []model.Locations
|
||||||
|
Events []model.Events
|
||||||
Events []struct {
|
Notes []model.Notes
|
||||||
model.Events
|
Contacts []model.Contacts
|
||||||
|
|
||||||
Location *model.Locations
|
|
||||||
Organizer *model.Contacts
|
|
||||||
}
|
|
||||||
|
|
||||||
Notes []model.Notes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserIdFromImage(ctx context.Context, dbPool *sql.DB, imageId uuid.UUID) (uuid.UUID, error) {
|
func getUserIdFromImage(ctx context.Context, dbPool *sql.DB, imageId uuid.UUID) (uuid.UUID, error) {
|
||||||
@ -93,11 +87,9 @@ func (m UserModel) ListWithProperties(ctx context.Context, userId uuid.UUID) ([]
|
|||||||
LEFT_JOIN(Notes, Notes.ID.EQ(ImageNotes.NoteID))).
|
LEFT_JOIN(Notes, Notes.ID.EQ(ImageNotes.NoteID))).
|
||||||
WHERE(UserImages.UserID.EQ(UUID(userId)))
|
WHERE(UserImages.UserID.EQ(UUID(userId)))
|
||||||
|
|
||||||
fmt.Println(listWithPropertiesStmt.DebugSql())
|
|
||||||
|
|
||||||
images := []ImageWithProperties{}
|
images := []ImageWithProperties{}
|
||||||
|
|
||||||
err := listWithPropertiesStmt.QueryContext(ctx, m.dbPool, &images)
|
err := listWithPropertiesStmt.QueryContext(ctx, m.dbPool, &images)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return images, err
|
return images, err
|
||||||
}
|
}
|
||||||
@ -113,6 +105,24 @@ func (m UserModel) GetUserIdFromEmail(ctx context.Context, email string) (uuid.U
|
|||||||
return user.ID, err
|
return user.ID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m UserModel) DoesUserExist(ctx context.Context, email string) bool {
|
||||||
|
getUserIdStmt := Users.SELECT(Users.ID).WHERE(Users.Email.EQ(String(email)))
|
||||||
|
|
||||||
|
user := model.Users{}
|
||||||
|
err := getUserIdStmt.QueryContext(ctx, m.dbPool, &user)
|
||||||
|
|
||||||
|
return err != qrm.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m UserModel) Save(ctx context.Context, user model.Users) (model.Users, error) {
|
||||||
|
insertUserStmt := Users.INSERT(Users.Email).VALUES(user.Email).RETURNING(Users.AllColumns)
|
||||||
|
|
||||||
|
insertedUser := model.Users{}
|
||||||
|
err := insertUserStmt.QueryContext(ctx, m.dbPool, &insertedUser)
|
||||||
|
|
||||||
|
return insertedUser, err
|
||||||
|
}
|
||||||
|
|
||||||
func NewUserModel(db *sql.DB) UserModel {
|
func NewUserModel(db *sql.DB) UserModel {
|
||||||
return UserModel{dbPool: db}
|
return UserModel{dbPool: db}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
DROP SCHEMA IF EXISTS haystack CASCADE;
|
DROP SCHEMA IF EXISTS haystack CASCADE;
|
||||||
|
DROP SCHEMA IF EXISTS agents CASCADE;
|
||||||
|
|
||||||
CREATE SCHEMA haystack;
|
CREATE SCHEMA haystack;
|
||||||
|
CREATE SCHEMA agents;
|
||||||
|
|
||||||
|
/** -----| Haystack |----- **/
|
||||||
|
|
||||||
|
/* -----| Enums |----- */
|
||||||
|
|
||||||
|
CREATE TYPE haystack.progress AS ENUM('not-started','in-progress');
|
||||||
|
|
||||||
/* -----| Schema tables |----- */
|
/* -----| Schema tables |----- */
|
||||||
|
|
||||||
@ -17,6 +25,7 @@ CREATE TABLE haystack.image (
|
|||||||
|
|
||||||
CREATE TABLE haystack.user_images_to_process (
|
CREATE TABLE haystack.user_images_to_process (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
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),
|
image_id uuid NOT NULL UNIQUE REFERENCES haystack.image (id),
|
||||||
user_id uuid NOT NULL REFERENCES haystack.users (id)
|
user_id uuid NOT NULL REFERENCES haystack.users (id)
|
||||||
);
|
);
|
||||||
@ -155,6 +164,14 @@ BEGIN
|
|||||||
END
|
END
|
||||||
$$ LANGUAGE plpgsql;
|
$$ 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 |----- */
|
/* -----| Triggers |----- */
|
||||||
|
|
||||||
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
|
CREATE OR REPLACE TRIGGER on_new_image AFTER INSERT
|
||||||
@ -162,6 +179,35 @@ ON haystack.user_images_to_process
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE PROCEDURE notify_new_image();
|
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 |----- */
|
/* -----| Test Data |----- */
|
||||||
|
|
||||||
-- Insert a user
|
-- Insert a user
|
||||||
|
@ -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.
@ -1,43 +1,46 @@
|
|||||||
{
|
{
|
||||||
"name": "haystack",
|
"name": "haystack",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "Screenshots that organize themselves",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"lint": "bunx @biomejs/biome lint .",
|
"lint": "bunx @biomejs/biome lint .",
|
||||||
"format": "bunx @biomejs/biome format . --write"
|
"format": "bunx @biomejs/biome format . --write"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "^0.13.9",
|
"@kobalte/core": "^0.13.9",
|
||||||
"@kobalte/tailwindcss": "^0.9.0",
|
"@kobalte/tailwindcss": "^0.9.0",
|
||||||
"@solidjs/router": "^0.15.3",
|
"@solidjs/router": "^0.15.3",
|
||||||
"@tabler/icons-solidjs": "^3.30.0",
|
"@tabler/icons-solidjs": "^3.30.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"clsx": "^2.1.1",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"fuse.js": "^7.1.0",
|
"clsx": "^2.1.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"fuse.js": "^7.1.0",
|
||||||
"solid-js": "^1.9.3",
|
"jwt-decode": "^4.0.0",
|
||||||
"solid-motionone": "^1.0.3",
|
"solid-js": "^1.9.3",
|
||||||
"tailwind-scrollbar-hide": "^2.0.0",
|
"solid-markdown": "^2.0.14",
|
||||||
"valibot": "^1.0.0-rc.2"
|
"solid-motionone": "^1.0.3",
|
||||||
},
|
"solidjs-markdown": "^0.2.0",
|
||||||
"devDependencies": {
|
"tailwind-scrollbar-hide": "^2.0.0",
|
||||||
"@biomejs/biome": "^1.9.4",
|
"valibot": "^1.0.0-rc.2"
|
||||||
"@tauri-apps/cli": "^2",
|
},
|
||||||
"autoprefixer": "^10.4.20",
|
"devDependencies": {
|
||||||
"postcss": "^8.5.3",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"postcss-cli": "^11.0.0",
|
"@tauri-apps/cli": "^2",
|
||||||
"tailwindcss": "3.4.0",
|
"autoprefixer": "^10.4.20",
|
||||||
"typescript": "~5.6.2",
|
"postcss": "^8.5.3",
|
||||||
"vite": "^6.0.3",
|
"postcss-cli": "^11.0.0",
|
||||||
"vite-plugin-solid": "^2.11.0"
|
"tailwindcss": "3.4.0",
|
||||||
}
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vite-plugin-solid": "^2.11.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
1592
frontend/src-tauri/Cargo.lock
generated
1592
frontend/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "haystack"
|
name = "Haystack"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A Tauri App"
|
description = "Screenshots that organize themselves"
|
||||||
authors = ["you"]
|
authors = ["Dmytro Kondakov", "John Costa"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@ -15,17 +15,22 @@ name = "haystack_lib"
|
|||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2.0.0-beta.12", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["macos-private-api"] }
|
tauri = { version = "2.0.0-beta.12", features = ["macos-private-api"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2.0.0-beta.12"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2.0.0-beta.12"
|
||||||
notify = "6.1.1"
|
notify = "6.1.1"
|
||||||
base64 = "0.21.7"
|
base64 = "0.21.7"
|
||||||
tokio = { version = "1.36.0", features = ["full"] }
|
tokio = { version = "1.36.0", features = ["full"] }
|
||||||
|
tauri-plugin-store = "2.0.0-beta.12"
|
||||||
|
tauri-plugin-http = "2.0.0-beta.12"
|
||||||
|
|
||||||
[target."cfg(target_os = \"macos\")".dependencies]
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
cocoa = "0.26"
|
cocoa = "0.26"
|
||||||
|
|
||||||
|
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||||
|
tauri-plugin-global-shortcut = "2.0.0-beta.12"
|
||||||
|
@ -7,6 +7,22 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"core:window:allow-start-dragging"
|
"core:window:allow-start-dragging",
|
||||||
|
"global-shortcut:allow-is-registered",
|
||||||
|
"global-shortcut:allow-register",
|
||||||
|
"global-shortcut:allow-unregister",
|
||||||
|
"global-shortcut:allow-unregister-all",
|
||||||
|
"http:default",
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "https://haystack.johncosta.tech"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:3040"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
71
frontend/src-tauri/src/commands.rs
Normal file
71
frontend/src-tauri/src/commands.rs
Normal 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))
|
||||||
|
}
|
@ -1,146 +1,31 @@
|
|||||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
mod commands;
|
||||||
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
|
mod shortcut;
|
||||||
use std::fs;
|
mod state;
|
||||||
use std::path::PathBuf;
|
mod utils;
|
||||||
use std::sync::mpsc::channel;
|
mod window;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use tauri::AppHandle;
|
|
||||||
use tauri::Emitter;
|
|
||||||
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
|
||||||
|
|
||||||
struct WatcherState {
|
use state::new_shared_watcher_state;
|
||||||
watcher: Option<RecommendedWatcher>,
|
use window::setup_window;
|
||||||
}
|
|
||||||
|
|
||||||
impl WatcherState {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self { watcher: None }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle PNG file processing
|
|
||||||
fn process_png_file(path: &PathBuf, app: AppHandle) -> Result<(), String> {
|
|
||||||
println!("Processing PNG file: {}", path.display());
|
|
||||||
|
|
||||||
// Read the file
|
|
||||||
let contents = fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
|
|
||||||
|
|
||||||
// Convert to base64
|
|
||||||
let base64_string = BASE64.encode(&contents);
|
|
||||||
println!("Generated base64 string of length: {}", base64_string.len());
|
|
||||||
|
|
||||||
// Emit the base64 to frontend
|
|
||||||
app.emit("png-processed", base64_string)
|
|
||||||
.map_err(|e| format!("Failed to emit event: {}", e))?;
|
|
||||||
|
|
||||||
println!("Successfully processed file: {}", path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn handle_selected_folder(
|
|
||||||
path: String,
|
|
||||||
state: tauri::State<'_, Arc<Mutex<WatcherState>>>,
|
|
||||||
app: AppHandle,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let path_buf = PathBuf::from(&path);
|
|
||||||
|
|
||||||
if !path_buf.exists() || !path_buf.is_dir() {
|
|
||||||
return Err("Invalid directory path".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop existing watcher if any
|
|
||||||
let mut state = state
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| "Failed to lock state".to_string())?;
|
|
||||||
state.watcher = None;
|
|
||||||
|
|
||||||
// Create a channel to receive file system events
|
|
||||||
let (tx, rx) = channel();
|
|
||||||
|
|
||||||
// Create a new watcher
|
|
||||||
let mut watcher = RecommendedWatcher::new(tx, Config::default())
|
|
||||||
.map_err(|e| format!("Failed to create watcher: {}", e))?;
|
|
||||||
|
|
||||||
// Start watching the directory
|
|
||||||
watcher
|
|
||||||
.watch(path_buf.as_ref(), RecursiveMode::Recursive)
|
|
||||||
.map_err(|e| format!("Failed to watch directory: {}", e))?;
|
|
||||||
|
|
||||||
// Store the watcher in state
|
|
||||||
state.watcher = Some(watcher);
|
|
||||||
|
|
||||||
let path_clone = path.clone();
|
|
||||||
let app_clone = app.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
println!("Starting to watch directory: {}", path_clone);
|
|
||||||
for res in rx {
|
|
||||||
match res {
|
|
||||||
Ok(event) => {
|
|
||||||
println!("Received event: {:?}", event);
|
|
||||||
match event.kind {
|
|
||||||
notify::EventKind::Create(_) | notify::EventKind::Modify(_) => {
|
|
||||||
for path in event.paths {
|
|
||||||
println!("Processing path: {}", path.display());
|
|
||||||
if let Some(extension) = path.extension() {
|
|
||||||
if extension.to_string_lossy().to_lowercase() == "png" {
|
|
||||||
if let Err(e) = process_png_file(&path, app_clone.clone()) {
|
|
||||||
eprintln!("Error processing PNG file: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("Watch error: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(format!("Now watching directory: {}", path))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let watcher_state = Arc::new(Mutex::new(WatcherState::new()));
|
let watcher_state = new_shared_watcher_state();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(watcher_state)
|
.manage(watcher_state)
|
||||||
.invoke_handler(tauri::generate_handler![handle_selected_folder])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::handle_selected_folder,
|
||||||
|
shortcut::change_shortcut,
|
||||||
|
shortcut::unregister_shortcut,
|
||||||
|
shortcut::get_current_shortcut,
|
||||||
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
setup_window(app)?;
|
||||||
.inner_size(480.0, 360.0)
|
shortcut::enable_shortcut(app);
|
||||||
// .hidden_title(true)
|
|
||||||
.resizable(true);
|
|
||||||
// set transparent title bar only when building for macOS
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let win_builder = win_builder.title_bar_style(TitleBarStyle::Transparent);
|
|
||||||
|
|
||||||
let window = win_builder.build().unwrap();
|
|
||||||
|
|
||||||
// set background color only when building for macOS
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
use cocoa::appkit::{NSColor, NSWindow};
|
|
||||||
use cocoa::base::{id, nil};
|
|
||||||
|
|
||||||
let ns_window = window.ns_window().unwrap() as id;
|
|
||||||
unsafe {
|
|
||||||
let bg_color = NSColor::colorWithRed_green_blue_alpha_(
|
|
||||||
nil,
|
|
||||||
245.0 / 255.0,
|
|
||||||
245.0 / 255.0,
|
|
||||||
245.0 / 255.0,
|
|
||||||
1.0,
|
|
||||||
);
|
|
||||||
ns_window.setBackgroundColor_(bg_color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
172
frontend/src-tauri/src/shortcut.rs
Normal file
172
frontend/src-tauri/src/shortcut.rs
Normal 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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
27
frontend/src-tauri/src/state.rs
Normal file
27
frontend/src-tauri/src/state.rs
Normal 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()))
|
||||||
|
}
|
22
frontend/src-tauri/src/utils.rs
Normal file
22
frontend/src-tauri/src/utils.rs
Normal 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(())
|
||||||
|
}
|
37
frontend/src-tauri/src/window.rs
Normal file
37
frontend/src-tauri/src/window.rs
Normal 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(())
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "haystack",
|
"productName": "Haystack",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.haystack.app",
|
"identifier": "com.haystack.app",
|
||||||
"build": {
|
"build": {
|
||||||
|
@ -1,175 +1,35 @@
|
|||||||
import { IconSearch } from "@tabler/icons-solidjs";
|
import { Route, Router } from "@solidjs/router";
|
||||||
import clsx from "clsx";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import Fuse from "fuse.js";
|
import { createEffect, onCleanup } from "solid-js";
|
||||||
import { For, createEffect, createResource, createSignal } from "solid-js";
|
import { Login } from "./Login";
|
||||||
import { SearchCardEvent } from "./components/search-card/SearchCardEvent";
|
import { ProtectedRoute } from "./ProtectedRoute";
|
||||||
import { SearchCardLocation } from "./components/search-card/SearchCardLocation";
|
import { Search } from "./Search";
|
||||||
import { UserImage, getUserImages } from "./network";
|
import { Settings } from "./Settings";
|
||||||
import { getCardSize } from "./utils/getCardSize";
|
import { ImageViewer } from "./components/ImageViewer";
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
fuze = new Fuse<UserImage>(data() ?? [], {
|
// TODO: Don't use window.location.href
|
||||||
keys: [
|
const unlisten = listen("focus-search", () => {
|
||||||
{ name: "data.Name", weight: 2 },
|
window.location.href = "/";
|
||||||
{ name: "rawData", weight: 1 },
|
});
|
||||||
],
|
|
||||||
threshold: 0.4,
|
onCleanup(() => {
|
||||||
|
unlisten.then((fn) => fn());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const onInputChange = (event: InputEvent) => {
|
|
||||||
const query = (event.target as HTMLInputElement).value;
|
|
||||||
setSearchQuery(query);
|
|
||||||
setSearchResults(fuze.search(query).map((s) => s.item));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main class="container pt-2">
|
<ImageViewer />
|
||||||
<A href="login">login</A>
|
<Router>
|
||||||
<div class="px-4">
|
<Route path="/login" component={Login} />
|
||||||
<div class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-neutral-200 text-neutral-900">
|
|
||||||
<div class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-l-md px-2.5 text-gray-900">
|
|
||||||
<IconSearch
|
|
||||||
size={20}
|
|
||||||
class="m-auto size-5 text-neutral-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery()}
|
|
||||||
onInput={onInputChange}
|
|
||||||
placeholder="Search for stuff..."
|
|
||||||
class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 mt-4 bg-white rounded-t-2xl">
|
<Route path="/" component={ProtectedRoute}>
|
||||||
<div class="h-[254px] mt-4 overflow-scroll scrollbar-hide">
|
<Route path="/" component={Search} />
|
||||||
{searchResults().length > 0 ? (
|
<Route path="/settings" component={Settings} />
|
||||||
<div class="w-full grid grid-cols-9 gap-2 grid-flow-row-dense py-4">
|
</Route>
|
||||||
<For each={searchResults()}>
|
</Router>
|
||||||
{(item) => (
|
|
||||||
<div
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedItem(item)
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
setSelectedItem(item);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class={clsx(
|
|
||||||
"h-[144px] border relative border-neutral-200 cursor-pointer overflow-hidden rounded-xl",
|
|
||||||
{
|
|
||||||
"col-span-3":
|
|
||||||
getCardSize(
|
|
||||||
item.type,
|
|
||||||
) === "1/1",
|
|
||||||
"col-span-6":
|
|
||||||
getCardSize(
|
|
||||||
item.type,
|
|
||||||
) === "2/1",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span class="sr-only">
|
|
||||||
{item.data.Name}
|
|
||||||
</span>
|
|
||||||
{getCardComponent(item)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
) : searchQuery() !== "" ? (
|
|
||||||
<div class="text-center text-lg m-auto text-neutral-700">
|
|
||||||
No results found
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full border-t h-10 bg-white px-4 flex items-center border-neutral-100">
|
|
||||||
footer
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,9 +1,9 @@
|
|||||||
import { Button } from "@kobalte/core/button";
|
import { Button } from "@kobalte/core/button";
|
||||||
import { TextField } from "@kobalte/core/text-field";
|
import { TextField } from "@kobalte/core/text-field";
|
||||||
import { createSignal, Show, type Component } from "solid-js";
|
|
||||||
import { postCode, postLogin } from "./network";
|
|
||||||
import { isTokenValid } from "./ProtectedRoute";
|
|
||||||
import { Navigate } from "@solidjs/router";
|
import { Navigate } from "@solidjs/router";
|
||||||
|
import { type Component, Show, createSignal } from "solid-js";
|
||||||
|
import { isTokenValid } from "./ProtectedRoute";
|
||||||
|
import { base, postCode, postLogin } from "./network";
|
||||||
|
|
||||||
export const Login: Component = () => {
|
export const Login: Component = () => {
|
||||||
let form: HTMLFormElement | undefined;
|
let form: HTMLFormElement | undefined;
|
||||||
@ -34,26 +34,31 @@ export const Login: Component = () => {
|
|||||||
|
|
||||||
localStorage.setItem("access", access);
|
localStorage.setItem("access", access);
|
||||||
localStorage.setItem("refresh", refresh);
|
localStorage.setItem("refresh", refresh);
|
||||||
|
|
||||||
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAuthorized = isTokenValid();
|
const isAuthorized = isTokenValid();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={!isAuthorized} fallback={<Navigate href="/" />}>
|
<>
|
||||||
<form ref={form} onSubmit={onSubmit}>
|
{base}
|
||||||
<TextField name="email">
|
<Show when={!isAuthorized} fallback={<Navigate href="/" />}>
|
||||||
<TextField.Label>Email</TextField.Label>
|
<form ref={form} onSubmit={onSubmit}>
|
||||||
<TextField.Input />
|
<TextField name="email">
|
||||||
</TextField>
|
<TextField.Label>Email</TextField.Label>
|
||||||
<Show when={submitted()}>
|
|
||||||
<TextField name="code">
|
|
||||||
<TextField.Label>Code</TextField.Label>
|
|
||||||
<TextField.Input />
|
<TextField.Input />
|
||||||
</TextField>
|
</TextField>
|
||||||
</Show>
|
<Show when={submitted()}>
|
||||||
<Button type="submit">Submit</Button>
|
<TextField name="code">
|
||||||
</form>
|
<TextField.Label>Code</TextField.Label>
|
||||||
</Show>
|
<TextField.Input />
|
||||||
|
</TextField>
|
||||||
|
</Show>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</form>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
216
frontend/src/Search.tsx
Normal file
216
frontend/src/Search.tsx
Normal 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
33
frontend/src/Settings.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,19 +1,21 @@
|
|||||||
import { createEffect, createSignal } from "solid-js";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { FolderPicker } from "./FolderPicker";
|
import { createEffect } from "solid-js";
|
||||||
import { sendImage } from "../network";
|
import { sendImage } from "../network";
|
||||||
|
|
||||||
export function ImageViewer() {
|
export function ImageViewer() {
|
||||||
const [latestImage, setLatestImage] = createSignal<string | null>(null);
|
// const [latestImage, setLatestImage] = createSignal<string | null>(null);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(async () => {
|
||||||
// Listen for PNG processing events
|
// Listen for PNG processing events
|
||||||
const unlisten = listen("png-processed", (event) => {
|
const unlisten = listen("png-processed", async (event) => {
|
||||||
console.log("Received processed PNG", event);
|
console.log("Received processed PNG", event);
|
||||||
const base64Data = event.payload as string;
|
const base64Data = event.payload as string;
|
||||||
|
|
||||||
setLatestImage(`data:image/png;base64,${base64Data}`);
|
// setLatestImage(`data:image/png;base64,${base64Data}`);
|
||||||
sendImage("test-image.png", base64Data);
|
const result = await sendImage("test-image.png", base64Data);
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
console.log("DBG: ", result);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -21,20 +23,22 @@ export function ImageViewer() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return null;
|
||||||
<div>
|
|
||||||
<FolderPicker />
|
|
||||||
|
|
||||||
{latestImage() && (
|
// return (
|
||||||
<div class="mt-4">
|
// <div>
|
||||||
<h3>Latest Processed Image:</h3>
|
// <FolderPicker />
|
||||||
<img
|
|
||||||
src={latestImage() || undefined}
|
// {latestImage() && (
|
||||||
alt="Latest processed"
|
// <div class="mt-4">
|
||||||
class="max-w-md"
|
// <h3>Latest Processed Image:</h3>
|
||||||
/>
|
// <img
|
||||||
</div>
|
// src={latestImage() || undefined}
|
||||||
)}
|
// alt="Latest processed"
|
||||||
</div>
|
// class="max-w-md"
|
||||||
);
|
// />
|
||||||
|
// </div>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
|
52
frontend/src/components/folder-picker/FolderPicker.tsx
Normal file
52
frontend/src/components/folder-picker/FolderPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
25
frontend/src/components/item-modal/ItemModal.tsx
Normal file
25
frontend/src/components/item-modal/ItemModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
22
frontend/src/components/search-card/SearchCard.tsx
Normal file
22
frontend/src/components/search-card/SearchCard.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
@ -1,10 +1,10 @@
|
|||||||
import { Separator } from "@kobalte/core/separator";
|
import { Separator } from "@kobalte/core/separator";
|
||||||
|
|
||||||
import { IconUser } from "@tabler/icons-solidjs";
|
import { IconUser } from "@tabler/icons-solidjs";
|
||||||
import type { Contact } from "../../network/types";
|
import type { UserImage } from "../../network";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: Contact;
|
item: Extract<UserImage, { type: "contact" }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SearchCardContact = ({ item }: Props) => {
|
export const SearchCardContact = ({ item }: Props) => {
|
||||||
@ -12,15 +12,15 @@ export const SearchCardContact = ({ item }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="absolute inset-0 p-3 bg-orange-50">
|
<div class="absolute inset-0 p-3 bg-orange-50">
|
||||||
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
|
<div class="flex mb-1 items-center gap-1">
|
||||||
<p class="text-sm text-neutral-900 font-bold">{data.name}</p>
|
<IconUser size={14} class="text-neutral-500" />
|
||||||
<IconUser size={20} class="text-neutral-500 mt-1" />
|
<p class="text-xs text-neutral-500">Contact</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-neutral-500">{data.phoneNumber}</p>
|
<p class="text-sm text-neutral-900 font-bold mb-1">
|
||||||
<Separator class="my-2" />
|
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
|
||||||
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
|
|
||||||
{data.notes}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-xs text-neutral-700">Phone: {data.PhoneNumber}</p>
|
||||||
|
<p class="text-xs text-neutral-700">Mail: {data.Email}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Separator } from "@kobalte/core/separator";
|
|
||||||
|
|
||||||
import { IconCalendar } from "@tabler/icons-solidjs";
|
import { IconCalendar } from "@tabler/icons-solidjs";
|
||||||
import type { UserImage } from "../../network";
|
import type { UserImage } from "../../network";
|
||||||
|
|
||||||
@ -12,21 +10,22 @@ export const SearchCardEvent = ({ item }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="absolute inset-0 p-3 bg-purple-50">
|
<div class="absolute inset-0 p-3 bg-purple-50">
|
||||||
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
|
<div class="flex mb-1 items-center gap-1">
|
||||||
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
|
<IconCalendar size={14} class="text-neutral-500" />
|
||||||
<IconCalendar size={20} class="text-neutral-500 mt-1" />
|
<p class="text-xs text-neutral-500">Event</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-neutral-500">
|
<p class="text-sm text-neutral-900 font-bold mb-1">
|
||||||
Organized by {data.Organizer?.Name ?? "unknown"} on{" "}
|
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
|
||||||
{new Date(data.StartDateTime).toLocaleDateString("en-US", {
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
<Separator class="my-2" />
|
<p class="text-xs text-neutral-700">
|
||||||
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
|
Organized by {data.Organizer?.Name ?? "unknown"} on{" "}
|
||||||
{data.Description}
|
{data.StartDateTime
|
||||||
|
? new Date(data.StartDateTime).toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
: "unknown date"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Separator } from "@kobalte/core/separator";
|
|
||||||
|
|
||||||
import { IconMapPin } from "@tabler/icons-solidjs";
|
import { IconMapPin } from "@tabler/icons-solidjs";
|
||||||
import type { UserImage } from "../../network";
|
import type { UserImage } from "../../network";
|
||||||
|
|
||||||
@ -12,15 +10,14 @@ export const SearchCardLocation = ({ item }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="absolute inset-0 p-3 bg-red-50">
|
<div class="absolute inset-0 p-3 bg-red-50">
|
||||||
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
|
<div class="flex mb-1 items-center gap-1">
|
||||||
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
|
<IconMapPin size={14} class="text-neutral-500" />
|
||||||
<IconMapPin size={20} class="text-neutral-500 mt-1" />
|
<p class="text-xs text-neutral-500">Location</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-neutral-500">{data.Address}</p>
|
<p class="text-sm text-neutral-900 font-bold mb-1">
|
||||||
<Separator class="my-2" />
|
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
|
||||||
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
|
|
||||||
{data.Description}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-xs text-neutral-700">Address: {data.Address}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Separator } from "@kobalte/core/separator";
|
import { Separator } from "@kobalte/core/separator";
|
||||||
|
import SolidjsMarkdown from "solidjs-markdown";
|
||||||
|
|
||||||
import { IconNote } from "@tabler/icons-solidjs";
|
import { IconNote } from "@tabler/icons-solidjs";
|
||||||
import type { UserImage } from "../../network";
|
import type { UserImage } from "../../network";
|
||||||
@ -12,14 +13,15 @@ export const SearchCardNote = ({ item }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="absolute inset-0 p-3 bg-green-50">
|
<div class="absolute inset-0 p-3 bg-green-50">
|
||||||
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
|
<div class="flex mb-1 items-center gap-1">
|
||||||
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
|
<IconNote size={14} class="text-neutral-500" />
|
||||||
<IconNote size={20} class="text-neutral-500 mt-1" />
|
<p class="text-xs text-neutral-500">Note</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-neutral-500">Keywords TODO</p>
|
<p class="text-sm text-neutral-900 font-bold mb-1">
|
||||||
<Separator class="my-2" />
|
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
|
||||||
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
|
</p>
|
||||||
{data.Content}
|
<p class="text-xs text-neutral-700">
|
||||||
|
<SolidjsMarkdown>{data.Content}</SolidjsMarkdown>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
78
frontend/src/components/shortcuts/ShortcutItem.tsx
Normal file
78
frontend/src/components/shortcuts/ShortcutItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
77
frontend/src/components/shortcuts/Shortcuts.tsx
Normal file
77
frontend/src/components/shortcuts/Shortcuts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
134
frontend/src/components/shortcuts/hooks/useShortcutEditor.ts
Normal file
134
frontend/src/components/shortcuts/hooks/useShortcutEditor.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
38
frontend/src/components/shortcuts/utils/formatKey.ts
Normal file
38
frontend/src/components/shortcuts/utils/formatKey.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
4
frontend/src/components/shortcuts/utils/isModifierKey.ts
Normal file
4
frontend/src/components/shortcuts/utils/isModifierKey.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const isModifierKey = (key: string): boolean => {
|
||||||
|
const modifiers = ["Control", "Shift", "Alt", "Meta", "Command"];
|
||||||
|
return modifiers.includes(key);
|
||||||
|
};
|
33
frontend/src/components/shortcuts/utils/normalizeKey.ts
Normal file
33
frontend/src/components/shortcuts/utils/normalizeKey.ts
Normal 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;
|
||||||
|
};
|
14
frontend/src/components/shortcuts/utils/sortKeys.ts
Normal file
14
frontend/src/components/shortcuts/utils/sortKeys.ts
Normal 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;
|
||||||
|
});
|
||||||
|
};
|
@ -1,22 +1,8 @@
|
|||||||
/* @refresh reload */
|
/* @refresh reload */
|
||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
import App from "./App";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { Route, Router } from "@solidjs/router";
|
|
||||||
import { ImagePage } from "./ImagePage";
|
|
||||||
import { Login } from "./Login";
|
|
||||||
import { ProtectedRoute } from "./ProtectedRoute";
|
|
||||||
|
|
||||||
render(
|
import { App } from "./App";
|
||||||
() => (
|
|
||||||
<Router>
|
|
||||||
<Route path="/login" component={Login} />
|
|
||||||
|
|
||||||
<Route path="/" component={ProtectedRoute}>
|
render(() => <App />, document.getElementById("root") as HTMLElement);
|
||||||
<Route path="/" component={App} />
|
|
||||||
<Route path="/image/:imageId" component={ImagePage} />
|
|
||||||
</Route>
|
|
||||||
</Router>
|
|
||||||
),
|
|
||||||
document.getElementById("root") as HTMLElement,
|
|
||||||
);
|
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
|
import { fetch } from "@tauri-apps/plugin-http";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type InferOutput,
|
type InferOutput,
|
||||||
array,
|
array,
|
||||||
|
literal,
|
||||||
nullable,
|
nullable,
|
||||||
strictObject,
|
|
||||||
parse,
|
parse,
|
||||||
pipe,
|
pipe,
|
||||||
|
strictObject,
|
||||||
string,
|
string,
|
||||||
uuid,
|
uuid,
|
||||||
literal,
|
|
||||||
variant,
|
variant,
|
||||||
} from "valibot";
|
} from "valibot";
|
||||||
|
|
||||||
@ -17,8 +19,12 @@ type BaseRequestParams = Partial<{
|
|||||||
method: "GET" | "POST";
|
method: "GET" | "POST";
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export const base = import.meta.env.DEV
|
||||||
|
? "http://localhost:3040"
|
||||||
|
: "https://haystack.johncosta.tech";
|
||||||
|
|
||||||
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
|
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
|
||||||
return new Request(`http://localhost:3040/${path}`, {
|
return new Request(`${base}/${path}`, {
|
||||||
body,
|
body,
|
||||||
method,
|
method,
|
||||||
});
|
});
|
||||||
@ -29,7 +35,7 @@ const getBaseAuthorizedRequest = ({
|
|||||||
body,
|
body,
|
||||||
method,
|
method,
|
||||||
}: BaseRequestParams): Request => {
|
}: BaseRequestParams): Request => {
|
||||||
return new Request(`http://localhost:3040/${path}`, {
|
return new Request(`${base}/${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem("access")?.toString()}`,
|
Authorization: `Bearer ${localStorage.getItem("access")?.toString()}`,
|
||||||
},
|
},
|
||||||
@ -41,6 +47,7 @@ const sendImageResponseValidator = strictObject({
|
|||||||
ID: pipe(string(), uuid()),
|
ID: pipe(string(), uuid()),
|
||||||
ImageID: pipe(string(), uuid()),
|
ImageID: pipe(string(), uuid()),
|
||||||
UserID: pipe(string(), uuid()),
|
UserID: pipe(string(), uuid()),
|
||||||
|
Status: string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sendImage = async (
|
export const sendImage = async (
|
||||||
@ -82,9 +89,9 @@ const eventValidator = strictObject({
|
|||||||
EndDateTime: nullable(pipe(string())),
|
EndDateTime: nullable(pipe(string())),
|
||||||
Description: nullable(string()),
|
Description: nullable(string()),
|
||||||
LocationID: nullable(pipe(string(), uuid())),
|
LocationID: nullable(pipe(string(), uuid())),
|
||||||
Location: nullable(locationValidator),
|
// Location: nullable(locationValidator),
|
||||||
OrganizerID: nullable(pipe(string(), uuid())),
|
OrganizerID: nullable(pipe(string(), uuid())),
|
||||||
Organizer: nullable(contactValidator),
|
// Organizer: nullable(contactValidator),
|
||||||
});
|
});
|
||||||
|
|
||||||
const noteValidator = strictObject({
|
const noteValidator = strictObject({
|
||||||
@ -109,10 +116,16 @@ const noteDataType = strictObject({
|
|||||||
data: noteValidator,
|
data: noteValidator,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const contactDataType = strictObject({
|
||||||
|
type: literal("contact"),
|
||||||
|
data: contactValidator,
|
||||||
|
});
|
||||||
|
|
||||||
const dataTypeValidator = variant("type", [
|
const dataTypeValidator = variant("type", [
|
||||||
locationDataType,
|
locationDataType,
|
||||||
eventDataType,
|
eventDataType,
|
||||||
noteDataType,
|
noteDataType,
|
||||||
|
contactDataType,
|
||||||
]);
|
]);
|
||||||
const getUserImagesResponseValidator = array(dataTypeValidator);
|
const getUserImagesResponseValidator = array(dataTypeValidator);
|
||||||
|
|
||||||
@ -123,6 +136,8 @@ export const getUserImages = async (): Promise<UserImage[]> => {
|
|||||||
|
|
||||||
const res = await fetch(request).then((res) => res.json());
|
const res = await fetch(request).then((res) => res.json());
|
||||||
|
|
||||||
|
console.log("BACKEND RESPONSE: ", res);
|
||||||
|
|
||||||
return parse(getUserImagesResponseValidator, res);
|
return parse(getUserImagesResponseValidator, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user