Compare commits
66 Commits
feat/split
...
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 | |||
4d903f40bf | |||
24bed2aafb | |||
349dcc2275 | |||
dcfed6a746 | |||
91b9e5402e |
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,
|
||||
}
|
||||
}
|
@ -65,8 +65,8 @@ func (m ChatUserMessage) MarshalJSON() ([]byte, error) {
|
||||
})
|
||||
case ArrayMessage:
|
||||
return json.Marshal(&struct {
|
||||
Role UserRole `json:"role"`
|
||||
Content []ImageMessageContent `json:"content"`
|
||||
Role UserRole `json:"role"`
|
||||
Content []MessageContentMessage `json:"content"`
|
||||
}{
|
||||
Role: User,
|
||||
Content: t.Content,
|
||||
@ -121,18 +121,35 @@ func (m SingleMessage) IsSingleMessage() bool {
|
||||
}
|
||||
|
||||
type ArrayMessage struct {
|
||||
Content []ImageMessageContent `json:"content"`
|
||||
Content []MessageContentMessage `json:"content"`
|
||||
}
|
||||
|
||||
func (m ArrayMessage) IsSingleMessage() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type MessageContentMessage interface {
|
||||
IsImageMessage() bool
|
||||
}
|
||||
|
||||
type TextMessageContent struct {
|
||||
TextType string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (m TextMessageContent) IsImageMessage() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type ImageMessageContent struct {
|
||||
ImageType string `json:"type"`
|
||||
ImageUrl string `json:"image_url"`
|
||||
}
|
||||
|
||||
func (m ImageMessageContent) IsImageMessage() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type ImageContentUrl struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
@ -165,7 +182,7 @@ func (chat *Chat) AddSystem(prompt string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (chat *Chat) AddImage(imageName string, image []byte) error {
|
||||
func (chat *Chat) AddImage(imageName string, image []byte, query *string) error {
|
||||
extension := filepath.Ext(imageName)
|
||||
if len(extension) == 0 {
|
||||
// TODO: could also validate for image types we support.
|
||||
@ -173,14 +190,28 @@ func (chat *Chat) AddImage(imageName string, image []byte) error {
|
||||
}
|
||||
|
||||
extension = extension[1:]
|
||||
|
||||
encodedString := base64.StdEncoding.EncodeToString(image)
|
||||
|
||||
messageContent := ArrayMessage{
|
||||
Content: make([]ImageMessageContent, 1),
|
||||
contentLength := 1
|
||||
if query != nil {
|
||||
contentLength += 1
|
||||
}
|
||||
|
||||
messageContent.Content[0] = ImageMessageContent{
|
||||
messageContent := ArrayMessage{
|
||||
Content: make([]MessageContentMessage, contentLength),
|
||||
}
|
||||
|
||||
index := 0
|
||||
|
||||
if query != nil {
|
||||
messageContent.Content[index] = TextMessageContent{
|
||||
TextType: "text",
|
||||
Text: *query,
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
messageContent.Content[index] = ImageMessageContent{
|
||||
ImageType: "image_url",
|
||||
ImageUrl: fmt.Sprintf("data:image/%s;base64,%s", extension, encodedString),
|
||||
}
|
||||
|
@ -73,16 +73,28 @@ type AgentClient struct {
|
||||
|
||||
Log *log.Logger
|
||||
|
||||
Reply string
|
||||
|
||||
Do func(req *http.Request) (*http.Response, error)
|
||||
|
||||
Options CreateAgentClientOptions
|
||||
}
|
||||
|
||||
const OPENAI_API_KEY = "OPENAI_API_KEY"
|
||||
|
||||
func CreateAgentClient(log *log.Logger) (AgentClient, error) {
|
||||
type CreateAgentClientOptions struct {
|
||||
Log *log.Logger
|
||||
SystemPrompt string
|
||||
JsonTools string
|
||||
EndToolCall string
|
||||
Query *string
|
||||
}
|
||||
|
||||
func CreateAgentClient(options CreateAgentClientOptions) AgentClient {
|
||||
apiKey := os.Getenv(OPENAI_API_KEY)
|
||||
|
||||
if len(apiKey) == 0 {
|
||||
return AgentClient{}, errors.New(OPENAI_API_KEY + " was not found.")
|
||||
panic("No api key")
|
||||
}
|
||||
|
||||
return AgentClient{
|
||||
@ -93,12 +105,14 @@ func CreateAgentClient(log *log.Logger) (AgentClient, error) {
|
||||
return client.Do(req)
|
||||
},
|
||||
|
||||
Log: log,
|
||||
Log: options.Log,
|
||||
|
||||
ToolHandler: ToolsHandlers{
|
||||
handlers: map[string]ToolHandler{},
|
||||
},
|
||||
}, nil
|
||||
|
||||
Options: options,
|
||||
}
|
||||
}
|
||||
|
||||
func (client AgentClient) getRequest(body []byte) (*http.Request, error) {
|
||||
@ -146,39 +160,32 @@ func (client AgentClient) Request(req *AgentRequestBody) (AgentResponse, error)
|
||||
return AgentResponse{}, errors.New("Unsupported. We currently only accept 1 choice from AI.")
|
||||
}
|
||||
|
||||
client.Log.SetLevel(log.DebugLevel)
|
||||
|
||||
msg := agentResponse.Choices[0].Message
|
||||
|
||||
if len(msg.Content) > 0 {
|
||||
client.Log.Debugf("Content: %s", msg.Content)
|
||||
}
|
||||
|
||||
if msg.ToolCalls != nil && len(*msg.ToolCalls) > 0 {
|
||||
client.Log.Debugf("Tool Call: %s", (*msg.ToolCalls)[0].Function.Name)
|
||||
|
||||
prettyJson, err := json.MarshalIndent((*msg.ToolCalls)[0].Function.Arguments, "", " ")
|
||||
if err != nil {
|
||||
return AgentResponse{}, err
|
||||
}
|
||||
|
||||
client.Log.Debugf("Arguments: %s", string(prettyJson))
|
||||
}
|
||||
|
||||
req.Chat.AddAiResponse(agentResponse.Choices[0].Message)
|
||||
req.Chat.AddAiResponse(msg)
|
||||
|
||||
return agentResponse, nil
|
||||
}
|
||||
|
||||
func (client AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error {
|
||||
func (client *AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody) error {
|
||||
for {
|
||||
err := client.Process(info, req)
|
||||
response, err := client.Request(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.Request(req)
|
||||
if response.Choices[0].FinishReason == "stop" {
|
||||
client.Log.Debug("Agent is finished")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = client.Process(info, req)
|
||||
|
||||
if err != nil {
|
||||
|
||||
if err == FinishedCall {
|
||||
client.Log.Debug("Agent is finished")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -186,7 +193,7 @@ func (client AgentClient) ToolLoop(info ToolHandlerInfo, req *AgentRequestBody)
|
||||
|
||||
var FinishedCall = errors.New("Last tool tool was called")
|
||||
|
||||
func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) error {
|
||||
func (client *AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) error {
|
||||
var err error
|
||||
|
||||
message, err := req.Chat.GetLatest()
|
||||
@ -211,8 +218,11 @@ func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) e
|
||||
|
||||
toolResponse := client.ToolHandler.Handle(info, toolCall)
|
||||
|
||||
client.Log.SetLevel(log.DebugLevel)
|
||||
client.Log.Debugf("Response: %s", toolResponse.Content)
|
||||
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)
|
||||
}
|
||||
@ -220,9 +230,12 @@ func (client AgentClient) Process(info ToolHandlerInfo, req *AgentRequestBody) e
|
||||
return err
|
||||
}
|
||||
|
||||
func (client AgentClient) RunAgent(systemPrompt string, jsonTools string, endToolCall string, userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error {
|
||||
func (client *AgentClient) RunAgent(userId uuid.UUID, imageId uuid.UUID, imageName string, imageData []byte) error {
|
||||
var tools any
|
||||
err := json.Unmarshal([]byte(jsonTools), &tools)
|
||||
err := json.Unmarshal([]byte(client.Options.JsonTools), &tools)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
toolChoice := "any"
|
||||
|
||||
@ -231,7 +244,7 @@ func (client AgentClient) RunAgent(systemPrompt string, jsonTools string, endToo
|
||||
ToolChoice: &toolChoice,
|
||||
Model: "pixtral-12b-2409",
|
||||
Temperature: 0.3,
|
||||
EndToolCall: endToolCall,
|
||||
EndToolCall: client.Options.EndToolCall,
|
||||
ResponseFormat: ResponseFormat{
|
||||
Type: "text",
|
||||
},
|
||||
@ -240,17 +253,14 @@ func (client AgentClient) RunAgent(systemPrompt string, jsonTools string, endToo
|
||||
},
|
||||
}
|
||||
|
||||
request.Chat.AddSystem(systemPrompt)
|
||||
request.Chat.AddImage(imageName, imageData)
|
||||
|
||||
_, err = client.Request(&request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Chat.AddSystem(client.Options.SystemPrompt)
|
||||
request.Chat.AddImage(imageName, imageData, client.Options.Query)
|
||||
|
||||
toolHandlerInfo := ToolHandlerInfo{
|
||||
ImageId: imageId,
|
||||
UserId: userId,
|
||||
ImageId: imageId,
|
||||
ImageName: imageName,
|
||||
UserId: userId,
|
||||
Image: &imageData,
|
||||
}
|
||||
|
||||
return client.ToolLoop(toolHandlerInfo, &request)
|
||||
|
@ -8,8 +8,12 @@ import (
|
||||
)
|
||||
|
||||
type ToolHandlerInfo struct {
|
||||
UserId uuid.UUID
|
||||
ImageId uuid.UUID
|
||||
UserId uuid.UUID
|
||||
ImageId uuid.UUID
|
||||
ImageName string
|
||||
|
||||
// Pointer because we don't want to copy this around too much.
|
||||
Image *[]byte
|
||||
}
|
||||
|
||||
type ToolHandler struct {
|
||||
|
@ -3,136 +3,139 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/agents/client"
|
||||
"screenmark/screenmark/models"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const contactPrompt = `
|
||||
You are an agent that performs actions on contacts and people you find on an image.
|
||||
**Role:** You are an AI assistant specialized in processing contact information from images. Your primary function is to use the provided tools (listContacts, createContact, stopAgent) to manage contacts based on image analysis and signal when processing is complete.
|
||||
|
||||
You can use tools to achieve your task.
|
||||
**Primary Goal:** To accurately identify potential contacts in an image, check against existing contacts using the provided tools, create new contact entries when necessary (meticulously avoiding duplicates), and explicitly stop processing when finished or if no action is needed.
|
||||
|
||||
You should use listContacts to make sure that you don't create duplicate contacts.
|
||||
**Input:** You will be given an image that may contain contact information.
|
||||
|
||||
Call createContact when you see there is a new contact on this image. Do not create duplicate contacts.
|
||||
Or call linkContact when you think this image contains an existing contact.
|
||||
**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.
|
||||
|
||||
Call finish if you dont think theres anything else to do.
|
||||
**Core Workflow:**
|
||||
|
||||
1. **Image Analysis:**
|
||||
* Carefully scan the provided image to identify and extract any visible contact details (Name, Phone Number, Email Address, Physical Address). Extract *all* available information for each potential contact.
|
||||
* **If NO potential contact information is found in the image, proceed directly to Step 5 (call stopAgent).**
|
||||
|
||||
2. **Duplicate Check (Mandatory First Step if contacts found):**
|
||||
* If potential contact(s) were found in Step 1, you **must** call the listContacts tool first. **Generate only the listContacts tool call structure.**
|
||||
* Once you receive the list, compare the extracted information against the existing contacts to determine if each identified person is already present.
|
||||
* **If *all* identified potential contacts already exist in the list, proceed directly to Step 5 (call stopAgent).**
|
||||
|
||||
3. **Create New Contact (Conditional):**
|
||||
* For each potential contact identified in Step 1 that your check in Step 2 confirms is *new*:
|
||||
* Call the createContact tool with *all* corresponding extracted information (name, phoneNumber, address, email). name is mandatory. **Generate only the createContact tool call structure.**
|
||||
* Process *one new contact creation per turn*. If multiple new contacts need creating, you will call createContact sequentially (one call per turn).
|
||||
|
||||
4. **Handling Multiple Contacts:**
|
||||
* The workflow intrinsically handles multiple contacts by requiring a listContacts check first, followed by potential sequential createContact calls for each new individual found.
|
||||
|
||||
5. **Task Completion / No Action Needed:**
|
||||
* Call the stopAgent tool **only** when one of the following conditions is met:
|
||||
* No potential contact information was found in the initial image analysis (Step 1).
|
||||
* The listContacts check confirmed that *all* potential contacts identified in the image already exist (Step 2).
|
||||
* You have successfully processed all identified contacts (i.e., performed the listContacts check and called createContact for *all* new individuals found).
|
||||
* **Generate only the stopAgent tool call structure.**
|
||||
|
||||
**Available Tools:**
|
||||
|
||||
* **listContacts**: Retrieves the existing contact list. **Must** be called first if potential contacts are found in the image, to enable duplicate checking.
|
||||
* **createContact**: Adds a *new*, non-duplicate contact. Only call *after* listContacts confirms the person is new. name is mandatory.
|
||||
* **stopAgent**: Signals that processing for the current image is complete (either action was taken, no action was needed, or all identified contacts already existed). Call this as the final step or when no other action is applicable based on the workflow.
|
||||
`
|
||||
|
||||
const contactTools = `
|
||||
[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "listContacts",
|
||||
"description": "List the users existing contacts",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "createContact",
|
||||
"description": "Creates a new contact",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "the name of the person"
|
||||
},
|
||||
"phoneNumber": {
|
||||
"type": "string"
|
||||
},
|
||||
"address": {
|
||||
"type": "string",
|
||||
"description": "their physical address"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "linkContact",
|
||||
"description": "Links an existing contact with this image",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contactId": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the existing contact"
|
||||
}
|
||||
},
|
||||
"required": ["contactId"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "finish",
|
||||
"description": "Call when you dont think theres anything to do",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "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 ContactAgent struct {
|
||||
client client.AgentClient
|
||||
|
||||
contactModel models.ContactModel
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
type linkContactArguments struct {
|
||||
ContactID string `json:"contactId"`
|
||||
}
|
||||
|
||||
func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
|
||||
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: "Contacts 👥",
|
||||
}))
|
||||
if err != nil {
|
||||
return ContactAgent{}, err
|
||||
}
|
||||
|
||||
agent := ContactAgent{
|
||||
client: agentClient,
|
||||
contactModel: contactModel,
|
||||
}
|
||||
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 agent.contactModel.List(context.Background(), info.UserId)
|
||||
return contactModel.List(context.Background(), info.UserId)
|
||||
})
|
||||
|
||||
agentClient.ToolHandler.AddTool("createContact", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
||||
@ -144,7 +147,18 @@ func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
contact, err := agent.contactModel.Save(ctx, info.UserId, model.Contacts{
|
||||
contactId := uuid.Nil
|
||||
if args.ContactID != nil {
|
||||
contactUuid, err := uuid.Parse(*args.ContactID)
|
||||
if err != nil {
|
||||
return model.Contacts{}, err
|
||||
}
|
||||
|
||||
contactId = contactUuid
|
||||
}
|
||||
|
||||
contact, err := contactModel.Save(ctx, info.UserId, model.Contacts{
|
||||
ID: contactId,
|
||||
Name: args.Name,
|
||||
PhoneNumber: args.PhoneNumber,
|
||||
Email: args.Email,
|
||||
@ -154,7 +168,7 @@ func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
|
||||
return model.Contacts{}, err
|
||||
}
|
||||
|
||||
_, err = agent.contactModel.SaveToImage(ctx, info.ImageId, contact.ID)
|
||||
_, err = contactModel.SaveToImage(ctx, info.ImageId, contact.ID)
|
||||
if err != nil {
|
||||
return model.Contacts{}, err
|
||||
}
|
||||
@ -162,27 +176,5 @@ func NewContactAgent(contactModel models.ContactModel) (ContactAgent, error) {
|
||||
return contact, nil
|
||||
})
|
||||
|
||||
agentClient.ToolHandler.AddTool("linkContact", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
||||
args := linkContactArguments{}
|
||||
err := json.Unmarshal([]byte(_args), &args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
contactUuid, err := uuid.Parse(args.ContactID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = agent.contactModel.SaveToImage(ctx, info.ImageId, contactUuid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "Saved", nil
|
||||
})
|
||||
|
||||
return agent, nil
|
||||
return agentClient
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/agents/client"
|
||||
"screenmark/screenmark/models"
|
||||
@ -14,24 +13,43 @@ import (
|
||||
)
|
||||
|
||||
const eventPrompt = `
|
||||
You are an agent.
|
||||
**Role:** You are an Event Processing AI Assistant specialized in extracting event information from images, managing event data using provided tools, and ensuring accuracy and avoiding duplicates.
|
||||
|
||||
The user will send you images and you have to identify if they have any events or a place.
|
||||
This could be a friend suggesting to meet, a conference, or anything that looks like an event.
|
||||
**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.
|
||||
|
||||
There are various tools you can use to perform this task.
|
||||
**Core Workflow:**
|
||||
|
||||
listEvents
|
||||
Lists the users already existing events, you should do this before using createEvents to avoid creating duplicates.
|
||||
**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).**
|
||||
|
||||
createEvent
|
||||
Use this to create a new events.
|
||||
**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.**
|
||||
|
||||
linkEvent
|
||||
Links an image to a events.
|
||||
**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.
|
||||
|
||||
finish
|
||||
Call when there is nothing else to do.
|
||||
**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 = `
|
||||
@ -40,7 +58,7 @@ const eventTools = `
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "listEvents",
|
||||
"description": "List the events the user already has.",
|
||||
"description": "Retrieves the list of the user's currently scheduled events. Essential for checking if an event identified in the image already exists to prevent duplicates. Must be called before potentially creating an event.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -48,51 +66,75 @@ const eventTools = `
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "createEvent",
|
||||
"description": "Use to create a new events",
|
||||
"description": "Creates a new event in the user's calendar or list. Use only after listEvents confirms the event is new. Provide all extracted details.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "The name or title of the event. This field is mandatory."
|
||||
},
|
||||
"startDateTime": {
|
||||
"type": "string",
|
||||
"description": "The start time as an ISO string"
|
||||
"description": "The event's start date and time in ISO 8601 format (e.g., '2025-04-18T10:00:00Z'). Include if available."
|
||||
},
|
||||
"endDateTime": {
|
||||
"type": "string",
|
||||
"description": "The end time as an ISO string"
|
||||
"description": "The event's end date and time in ISO 8601 format. Optional, include if available and different from startDateTime."
|
||||
},
|
||||
"locationId": {
|
||||
"type": "string",
|
||||
"description": "The unique identifier (UUID or similar) for the event's location. Use this if available, do not invent it."
|
||||
}
|
||||
},
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "linkEvent",
|
||||
"description": "Use to link an already existing events to the image you were sent",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"required": ["eventsId"]
|
||||
"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": "finish",
|
||||
"description": "Call this when there is nothing left to do.",
|
||||
"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": {},
|
||||
@ -102,41 +144,32 @@ const eventTools = `
|
||||
}
|
||||
]`
|
||||
|
||||
type EventAgent struct {
|
||||
client client.AgentClient
|
||||
|
||||
eventsModel models.EventModel
|
||||
}
|
||||
|
||||
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 linkEventArguments struct {
|
||||
type updateEventArguments struct {
|
||||
EventID string `json:"eventId"`
|
||||
}
|
||||
|
||||
func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
|
||||
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: "Events 📍",
|
||||
}))
|
||||
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",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return EventAgent{}, err
|
||||
}
|
||||
|
||||
agent := EventAgent{
|
||||
client: agentClient,
|
||||
eventsModel: eventsModel,
|
||||
}
|
||||
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 agent.eventsModel.List(context.Background(), info.UserId)
|
||||
return eventsModel.List(context.Background(), info.UserId)
|
||||
})
|
||||
|
||||
agentClient.ToolHandler.AddTool("createEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
||||
@ -160,17 +193,23 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
|
||||
return model.Events{}, err
|
||||
}
|
||||
|
||||
events, err := agent.eventsModel.Save(ctx, info.UserId, model.Events{
|
||||
locationId, err := uuid.Parse(*args.LocationID)
|
||||
if err != nil {
|
||||
return model.Events{}, err
|
||||
}
|
||||
|
||||
events, err := eventsModel.Save(ctx, info.UserId, model.Events{
|
||||
Name: args.Name,
|
||||
StartDateTime: &startTime,
|
||||
EndDateTime: &endTime,
|
||||
LocationID: &locationId,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return model.Events{}, err
|
||||
}
|
||||
|
||||
_, err = agent.eventsModel.SaveToImage(ctx, info.ImageId, events.ID)
|
||||
_, err = eventsModel.SaveToImage(ctx, info.ImageId, events.ID)
|
||||
if err != nil {
|
||||
return model.Events{}, err
|
||||
}
|
||||
@ -178,8 +217,8 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
|
||||
return events, nil
|
||||
})
|
||||
|
||||
agentClient.ToolHandler.AddTool("linkEvent", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
||||
args := linkEventArguments{}
|
||||
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
|
||||
@ -192,9 +231,17 @@ func NewEventAgent(eventsModel models.EventModel) (EventAgent, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
agent.eventsModel.SaveToImage(ctx, info.ImageId, contactUuid)
|
||||
eventsModel.SaveToImage(ctx, info.ImageId, contactUuid)
|
||||
return "Saved", nil
|
||||
})
|
||||
|
||||
return agent, nil
|
||||
agentClient.ToolHandler.AddTool("getEventLocationId", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||
// TODO: reenable this when I'm creating the agent locally instead of getting it from above.
|
||||
locationAgent.RunAgent(info.UserId, info.ImageId, info.ImageName, *info.Image)
|
||||
|
||||
log.Debugf("Reply from location %s\n", locationAgent.Reply)
|
||||
return locationAgent.Reply, nil
|
||||
})
|
||||
|
||||
return agentClient
|
||||
}
|
||||
|
@ -3,43 +3,72 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"fmt"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/agents/client"
|
||||
"screenmark/screenmark/models"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const locationPrompt = `
|
||||
You are an agent.
|
||||
Role: Location AI Assistant
|
||||
|
||||
The user will send you images and you have to identify if they have any location or a place. This could a picture of a real place, an address, or it's name.
|
||||
Objective: Identify locations from images/text, manage a saved list (create, update), and answer user queries about saved locations using the provided tools.
|
||||
|
||||
There are various tools you can use to perform this task.
|
||||
Core Logic:
|
||||
|
||||
listLocations
|
||||
Lists the users already existing locations, you should do this before using createLocation to avoid creating duplicates.
|
||||
**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.
|
||||
|
||||
createLocation
|
||||
Use this to create a new location. Avoid making duplicates and only create a new location if listLocations doesnt contain the location on the image.
|
||||
**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.
|
||||
|
||||
linkLocation
|
||||
Links an image to a location.
|
||||
**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.
|
||||
|
||||
finish
|
||||
Call when there is nothing else to do.
|
||||
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": "List the locations the user already has.",
|
||||
"description": "Retrieves the list of the user's currently saved locations (names, addresses, IDs). Use this first to check if a location from an image already exists, or to find the ID of a location the user is asking about.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -47,92 +76,83 @@ const locationTools = `
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "createLocation",
|
||||
"description": "Use to create a new location",
|
||||
"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"
|
||||
"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"
|
||||
"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": "linkLocation",
|
||||
"description": "Use to link an already existing location to the image you were sent",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"locationId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["locationId"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "finish",
|
||||
"description": "Call this when there is nothing left to do.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}
|
||||
"type": "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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]`
|
||||
|
||||
type LocationAgent struct {
|
||||
client client.AgentClient
|
||||
|
||||
locationModel models.LocationModel
|
||||
func getLocationAgentTools(allowReply bool) string {
|
||||
if allowReply {
|
||||
return fmt.Sprintf(locationTools, replyTool)
|
||||
} else {
|
||||
return fmt.Sprintf(locationTools, "")
|
||||
}
|
||||
}
|
||||
|
||||
type listLocationArguments struct{}
|
||||
type createLocationArguments struct {
|
||||
Name string `json:"name"`
|
||||
Address *string `json:"address"`
|
||||
}
|
||||
type linkLocationArguments struct {
|
||||
LocationID string `json:"locationId"`
|
||||
type upsertLocationArguments struct {
|
||||
Name string `json:"name"`
|
||||
LocationID *string `json:"locationId"`
|
||||
Address *string `json:"address"`
|
||||
}
|
||||
|
||||
func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error) {
|
||||
agentClient, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: "Locations 📍",
|
||||
}))
|
||||
func NewLocationAgentWithComm(log *log.Logger, locationModel models.LocationModel) client.AgentClient {
|
||||
client := NewLocationAgent(log, locationModel)
|
||||
|
||||
if err != nil {
|
||||
return LocationAgent{}, err
|
||||
}
|
||||
client.Options.JsonTools = getLocationAgentTools(true)
|
||||
|
||||
agent := LocationAgent{
|
||||
client: agentClient,
|
||||
locationModel: locationModel,
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
agentClient.ToolHandler.AddTool("listLocations", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||
return agent.locationModel.List(context.Background(), info.UserId)
|
||||
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("createLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
||||
args := createLocationArguments{}
|
||||
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
|
||||
@ -140,7 +160,18 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
location, err := agent.locationModel.Save(ctx, info.UserId, model.Locations{
|
||||
locationId := uuid.Nil
|
||||
if args.LocationID != nil {
|
||||
locationUuid, err := uuid.Parse(*args.LocationID)
|
||||
if err != nil {
|
||||
return model.Locations{}, err
|
||||
}
|
||||
|
||||
locationId = locationUuid
|
||||
}
|
||||
|
||||
location, err := locationModel.Save(ctx, info.UserId, model.Locations{
|
||||
ID: locationId,
|
||||
Name: args.Name,
|
||||
Address: args.Address,
|
||||
})
|
||||
@ -149,7 +180,7 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
|
||||
return model.Locations{}, err
|
||||
}
|
||||
|
||||
_, err = agent.locationModel.SaveToImage(ctx, info.ImageId, location.ID)
|
||||
_, err = locationModel.SaveToImage(ctx, info.ImageId, location.ID)
|
||||
if err != nil {
|
||||
return model.Locations{}, err
|
||||
}
|
||||
@ -157,23 +188,9 @@ func NewLocationAgent(locationModel models.LocationModel) (LocationAgent, error)
|
||||
return location, nil
|
||||
})
|
||||
|
||||
agentClient.ToolHandler.AddTool("linkLocation", func(info client.ToolHandlerInfo, _args string, call client.ToolCall) (any, error) {
|
||||
args := linkLocationArguments{}
|
||||
err := json.Unmarshal([]byte(_args), &args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
contactUuid, err := uuid.Parse(args.LocationID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
agent.locationModel.SaveToImage(ctx, info.ImageId, contactUuid)
|
||||
return "Saved", nil
|
||||
agentClient.ToolHandler.AddTool("reply", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
|
||||
return agent, nil
|
||||
return agentClient
|
||||
}
|
||||
|
@ -2,11 +2,9 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/agents/client"
|
||||
"screenmark/screenmark/models"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/google/uuid"
|
||||
@ -43,7 +41,7 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
|
||||
}
|
||||
|
||||
request.Chat.AddSystem(noteAgentPrompt)
|
||||
request.Chat.AddImage(imageName, imageData)
|
||||
request.Chat.AddImage(imageName, imageData, nil)
|
||||
|
||||
resp, err := agent.client.Request(&request)
|
||||
if err != nil {
|
||||
@ -70,20 +68,16 @@ func (agent NoteAgent) GetNotes(userId uuid.UUID, imageId uuid.UUID, imageName s
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewNoteAgent(noteModel models.NoteModel) (NoteAgent, error) {
|
||||
client, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: "Notes 📝",
|
||||
}))
|
||||
if err != nil {
|
||||
return NoteAgent{}, err
|
||||
}
|
||||
func NewNoteAgent(log *log.Logger, noteModel models.NoteModel) NoteAgent {
|
||||
client := client.CreateAgentClient(client.CreateAgentClientOptions{
|
||||
SystemPrompt: noteAgentPrompt,
|
||||
Log: log,
|
||||
})
|
||||
|
||||
agent := NoteAgent{
|
||||
client: client,
|
||||
noteModel: noteModel,
|
||||
}
|
||||
|
||||
return agent, nil
|
||||
return agent
|
||||
}
|
||||
|
@ -1,52 +1,42 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"screenmark/screenmark/agents/client"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
const OrchestratorPrompt = `
|
||||
You are an Orchestrator for various AI agents.
|
||||
const orchestratorPrompt = `
|
||||
**Role:** You are an Orchestrator AI responsible for analyzing images provided by the user.
|
||||
|
||||
The user will send you images and you have to determine which agents you have to call, in order to best help the user.
|
||||
**Primary Task:** Examine the input image and determine which specialized AI agent(s), available as tool calls, should be invoked to process the relevant information within the image, or if no specialized processing is needed. Your goal is to either extract and structure useful information for the user by selecting the most appropriate tool(s) or explicitly indicate that no specific action is required.
|
||||
|
||||
You might decide no agent needs to be called.
|
||||
**Analysis Process & Decision Logic:**
|
||||
|
||||
The agents are available as tool calls.
|
||||
1. **Analyze Image Content:** Scrutinize the image for distinct types of information:
|
||||
* General text/writing (including code, formulas)
|
||||
* Information about a person or contact details
|
||||
* Information about a place, location, or address
|
||||
* Information about an event
|
||||
* Content that doesn't fit any specific category or lacks actionable information.
|
||||
|
||||
Agents available:
|
||||
2. **Agent Selection - Determine ALL that apply:**
|
||||
* **contactAgent:** Is there information specifically related to a person or their contact details (e.g., business card, name/email/phone)? If YES, select contactAgent.
|
||||
* **locationAgent:** Is there information specifically identifying a place, location, or address (e.g., map, street sign, address text)? If YES, select locationAgent.
|
||||
* **eventAgent:** Is there information specifically related to an event (e.g., invitation, poster with date/time, schedule)? If YES, select eventAgent.
|
||||
* **noteAgent** Does the image contain *any* text/writing (including code, formulas)?
|
||||
* **noAgent**: Call this when you are done working on this image.
|
||||
|
||||
noteAgent
|
||||
Use when there is ANY text on the image.
|
||||
|
||||
contactAgent
|
||||
Use it when the image contains information relating a person.
|
||||
|
||||
locationAgent
|
||||
Use it when the image contains some address or a place.
|
||||
|
||||
eventAgent
|
||||
Use it when the image contains an event, this can be a date, a message suggesting an event.
|
||||
|
||||
noAction
|
||||
When you think there is no more information to extract from the image.
|
||||
|
||||
Always call agents in parallel if you need to call more than 1.
|
||||
|
||||
Do not call the agent if you do not think it is relevant for the image.
|
||||
* Call all applicable specialized agents (noteAgent, contactAgent, locationAgent, eventAgent) simultaneously (in parallel).
|
||||
`
|
||||
|
||||
const OrchestratorTools = `
|
||||
const orchestratorTools = `
|
||||
[
|
||||
{
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "noteAgent",
|
||||
"description": "Use when there is any text on the image, this can be code/text/formulas any writing",
|
||||
"description": "Extracts general textual content like handwritten notes, paragraphs in documents, presentation slides, code snippets, or mathematical formulas. Use this for significant text that isn't primarily contact details, an address, or specific event information.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -54,11 +44,11 @@ const OrchestratorTools = `
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "contactAgent",
|
||||
"description": "Use when then image contains some person or contact",
|
||||
"description": "Extracts personal contact information. Use when the image clearly shows details like names, phone numbers, email addresses, job titles, or company names, especially from sources like business cards, email signatures, or contact lists.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -66,11 +56,11 @@ const OrchestratorTools = `
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "locationAgent",
|
||||
"description": "Use when then image contains some place, location or address",
|
||||
"description": "Identifies and extracts specific geographic locations or addresses. Use for content like street addresses on mail or signs, place names (e.g., restaurant, shop), map snippets, or recognizable landmarks.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -78,11 +68,11 @@ const OrchestratorTools = `
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "eventAgent",
|
||||
"description": "Use when then image contains some event",
|
||||
"description": "Extracts details related to scheduled events, appointments, or specific occasions. Use when the image contains information like event titles, dates, times, venues, agendas, or descriptions, typically found on invitations, posters, calendar entries, or schedules.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -93,8 +83,8 @@ const OrchestratorTools = `
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "noAction",
|
||||
"description": "Use when you are sure nothing can be done about this image anymore",
|
||||
"name": "noAgent",
|
||||
"description": "Extracts details related to scheduled events, appointments, or specific occasions. Use when the image contains information like event titles, dates, times, venues, agendas, or descriptions, typically found on invitations, posters, calendar entries, or schedules.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -102,7 +92,8 @@ const OrchestratorTools = `
|
||||
}
|
||||
}
|
||||
}
|
||||
]`
|
||||
]
|
||||
`
|
||||
|
||||
type OrchestratorAgent struct {
|
||||
Client client.AgentClient
|
||||
@ -114,58 +105,41 @@ type Status struct {
|
||||
Ok bool `json:"ok"`
|
||||
}
|
||||
|
||||
func NewOrchestratorAgent(noteAgent NoteAgent, contactAgent ContactAgent, locationAgent LocationAgent, eventAgent EventAgent, imageName string, imageData []byte) (OrchestratorAgent, error) {
|
||||
agent, err := client.CreateAgentClient(log.NewWithOptions(os.Stdout, log.Options{
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: "Orchestrator 🎼",
|
||||
}))
|
||||
|
||||
if err != nil {
|
||||
return OrchestratorAgent{}, err
|
||||
}
|
||||
func NewOrchestratorAgent(log *log.Logger, noteAgent NoteAgent, contactAgent client.AgentClient, locationAgent client.AgentClient, eventAgent client.AgentClient, imageName string, imageData []byte) client.AgentClient {
|
||||
agent := client.CreateAgentClient(client.CreateAgentClientOptions{
|
||||
SystemPrompt: orchestratorPrompt,
|
||||
JsonTools: orchestratorTools,
|
||||
Log: log,
|
||||
EndToolCall: "noAgent",
|
||||
})
|
||||
|
||||
agent.ToolHandler.AddTool("noteAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||
go noteAgent.GetNotes(info.UserId, info.ImageId, imageName, imageData)
|
||||
// go noteAgent.GetNotes(info.UserId, info.ImageId, imageName, imageData)
|
||||
|
||||
return Status{
|
||||
Ok: true,
|
||||
}, nil
|
||||
return "noteAgent called successfully", nil
|
||||
})
|
||||
|
||||
agent.ToolHandler.AddTool("contactAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||
go contactAgent.client.RunAgent(contactPrompt, contactTools, "finish", info.UserId, info.ImageId, imageName, imageData)
|
||||
go contactAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
|
||||
|
||||
return Status{
|
||||
Ok: true,
|
||||
}, nil
|
||||
return "contactAgent called successfully", nil
|
||||
})
|
||||
|
||||
agent.ToolHandler.AddTool("locationAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||
go locationAgent.client.RunAgent(locationPrompt, locationTools, "finish", info.UserId, info.ImageId, imageName, imageData)
|
||||
// go locationAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
|
||||
|
||||
return Status{
|
||||
Ok: true,
|
||||
}, nil
|
||||
return "locationAgent called successfully", nil
|
||||
})
|
||||
|
||||
agent.ToolHandler.AddTool("eventAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||
go eventAgent.client.RunAgent(eventPrompt, eventTools, "finish", info.UserId, info.ImageId, imageName, imageData)
|
||||
// go eventAgent.RunAgent(info.UserId, info.ImageId, imageName, imageData)
|
||||
|
||||
return Status{
|
||||
Ok: true,
|
||||
}, nil
|
||||
return "eventAgent called successfully", nil
|
||||
})
|
||||
|
||||
agent.ToolHandler.AddTool("noAction", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||
// To nothing
|
||||
|
||||
return Status{
|
||||
Ok: true,
|
||||
}, errors.New("Finished! Kinda bad return type but...")
|
||||
agent.ToolHandler.AddTool("noAgent", func(info client.ToolHandlerInfo, args string, call client.ToolCall) (any, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
|
||||
return OrchestratorAgent{
|
||||
Client: agent,
|
||||
}, nil
|
||||
return agent
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
@ -18,7 +17,7 @@ type Auth struct {
|
||||
mailer Mailer
|
||||
}
|
||||
|
||||
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz")
|
||||
var letterRunes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
func randString(n int) string {
|
||||
b := make([]rune, n)
|
||||
@ -44,7 +43,6 @@ func (a *Auth) CreateCode(email string) error {
|
||||
}
|
||||
|
||||
func (a *Auth) IsCodeValid(email string, code string) bool {
|
||||
fmt.Println(a.codes)
|
||||
existingCode, exists := a.codes[email]
|
||||
if !exists {
|
||||
return false
|
||||
@ -55,7 +53,6 @@ func (a *Auth) IsCodeValid(email string, code string) bool {
|
||||
|
||||
func (a *Auth) UseCode(email string, code string) error {
|
||||
if valid := a.IsCodeValid(email, code); !valid {
|
||||
fmt.Println("returning error?")
|
||||
return errors.New("This code is invalid.")
|
||||
}
|
||||
|
||||
|
23
backend/builder/agents.go
Normal file
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))
|
||||
}
|
@ -3,17 +3,28 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"screenmark/screenmark/agents"
|
||||
"screenmark/screenmark/models"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
func createLogger(prefix string) *log.Logger {
|
||||
logger := log.NewWithOptions(os.Stdout, log.Options{
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.Kitchen,
|
||||
Prefix: prefix,
|
||||
})
|
||||
|
||||
logger.SetLevel(log.DebugLevel)
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) {
|
||||
listener := pq.NewListener(os.Getenv("DB_CONNECTION"), time.Second, time.Second, func(event pq.ListenerEventType, err error) {
|
||||
if err != nil {
|
||||
@ -28,6 +39,9 @@ func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) {
|
||||
imageModel := models.NewImageModel(db)
|
||||
contactModel := models.NewContactModel(db)
|
||||
|
||||
databaseEventLog := createLogger("Database Events 🤖")
|
||||
databaseEventLog.SetLevel(log.DebugLevel)
|
||||
|
||||
err := listener.Listen("new_image")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@ -39,55 +53,41 @@ func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) {
|
||||
imageId := uuid.MustParse(parameters.Extra)
|
||||
eventManager.listeners[parameters.Extra] = make(chan string)
|
||||
|
||||
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
go func() {
|
||||
noteAgent, err := agents.NewNoteAgent(noteModel)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
contactAgent, err := agents.NewContactAgent(contactModel)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
locationAgent, err := agents.NewLocationAgent(locationModel)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
eventAgent, err := agents.NewEventAgent(eventModel)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
noteAgent := agents.NewNoteAgent(createLogger("Notes 📝"), noteModel)
|
||||
contactAgent := agents.NewContactAgent(createLogger("Contacts 👥"), contactModel)
|
||||
locationAgent := agents.NewLocationAgent(createLogger("Locations 📍"), locationModel)
|
||||
eventAgent := agents.NewEventAgent(createLogger("Events 📅"), eventModel, locationModel)
|
||||
|
||||
image, err := imageModel.GetToProcessWithData(ctx, imageId)
|
||||
if err != nil {
|
||||
log.Println("Failed to GetToProcessWithData")
|
||||
log.Println(err)
|
||||
databaseEventLog.Error("Failed to GetToProcessWithData", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := imageModel.StartProcessing(ctx, image.ID); err != nil {
|
||||
log.Println("Failed to FinishProcessing")
|
||||
log.Println(err)
|
||||
databaseEventLog.Error("Failed to FinishProcessing", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
orchestrator, err := agents.NewOrchestratorAgent(noteAgent, contactAgent, locationAgent, eventAgent, image.Image.ImageName, image.Image.Image)
|
||||
orchestrator := agents.NewOrchestratorAgent(createLogger("Orchestrator 🎼"), noteAgent, contactAgent, locationAgent, eventAgent, image.Image.ImageName, image.Image.Image)
|
||||
err = orchestrator.RunAgent(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
databaseEventLog.Error("Orchestrator failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Still need to find some way to hide this complexity away.
|
||||
// I don't think wrapping agents in structs actually works too well.
|
||||
err = orchestrator.Client.RunAgent(agents.OrchestratorPrompt, agents.OrchestratorTools, "noAction", image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
|
||||
_, err = imageModel.FinishProcessing(ctx, image.ID)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
databaseEventLog.Error("Failed to finish processing", "ImageID", imageId)
|
||||
return
|
||||
}
|
||||
|
||||
imageModel.FinishProcessing(ctx, image.ID)
|
||||
databaseEventLog.Debug("Starting processing image", "ImageID", imageId)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@ -122,9 +122,6 @@ func ListenProcessingImageStatus(db *sql.DB, eventManager *EventManager) {
|
||||
stringUuid := data.Extra[0:36]
|
||||
status := data.Extra[36:]
|
||||
|
||||
fmt.Printf("UUID: %s\n", stringUuid)
|
||||
fmt.Printf("Receiving :s\n", data.Extra)
|
||||
|
||||
imageListener, exists := eventManager.listeners[stringUuid]
|
||||
if !exists {
|
||||
continue
|
||||
|
@ -91,19 +91,36 @@ func main() {
|
||||
}
|
||||
|
||||
dataTypes := make([]DataType, 0)
|
||||
|
||||
// lord
|
||||
// forgive me
|
||||
idMap := make(map[uuid.UUID]bool)
|
||||
|
||||
for _, image := range images {
|
||||
for _, location := range image.Locations {
|
||||
_, exists := idMap[location.ID]
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
dataTypes = append(dataTypes, DataType{
|
||||
Type: "location",
|
||||
Data: location,
|
||||
})
|
||||
|
||||
idMap[location.ID] = true
|
||||
}
|
||||
|
||||
for _, event := range image.Events {
|
||||
_, exists := idMap[event.ID]
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
dataTypes = append(dataTypes, DataType{
|
||||
Type: "event",
|
||||
Data: event,
|
||||
})
|
||||
|
||||
idMap[event.ID] = true
|
||||
}
|
||||
|
||||
for _, note := range image.Notes {
|
||||
@ -111,13 +128,20 @@ func main() {
|
||||
Type: "note",
|
||||
Data: note,
|
||||
})
|
||||
idMap[note.ID] = true
|
||||
}
|
||||
|
||||
for _, contact := range image.Contacts {
|
||||
_, exists := idMap[contact.ID]
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
dataTypes = append(dataTypes, DataType{
|
||||
Type: "contact",
|
||||
Data: contact,
|
||||
})
|
||||
idMap[contact.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -333,6 +357,12 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if exists := userModel.DoesUserExist(r.Context(), codeBody.Email); !exists {
|
||||
userModel.Save(r.Context(), model.Users{
|
||||
Email: codeBody.Email,
|
||||
})
|
||||
}
|
||||
|
||||
uuid, err := userModel.GetUserIdFromEmail(r.Context(), codeBody.Email)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
|
@ -29,9 +29,56 @@ func (m ContactModel) List(ctx context.Context, userId uuid.UUID) ([]model.Conta
|
||||
return locations, err
|
||||
}
|
||||
|
||||
func (m ContactModel) Get(ctx context.Context, contactId uuid.UUID) (model.Contacts, error) {
|
||||
getContactStmt := Contacts.
|
||||
SELECT(Contacts.AllColumns).
|
||||
WHERE(Contacts.ID.EQ(UUID(contactId)))
|
||||
|
||||
contact := model.Contacts{}
|
||||
err := getContactStmt.QueryContext(ctx, m.dbPool, &contact)
|
||||
|
||||
return contact, err
|
||||
}
|
||||
|
||||
func (m ContactModel) Update(ctx context.Context, contact model.Contacts) (model.Contacts, error) {
|
||||
existingContact, err := m.Get(ctx, contact.ID)
|
||||
if err != nil {
|
||||
return model.Contacts{}, err
|
||||
}
|
||||
|
||||
existingContact.Name = contact.Name
|
||||
|
||||
if contact.Description != nil {
|
||||
existingContact.Description = contact.Description
|
||||
}
|
||||
|
||||
if contact.PhoneNumber != nil {
|
||||
existingContact.PhoneNumber = contact.PhoneNumber
|
||||
}
|
||||
|
||||
if contact.Email != nil {
|
||||
existingContact.Email = contact.Email
|
||||
}
|
||||
|
||||
updateContactStmt := Contacts.
|
||||
UPDATE(Contacts.MutableColumns).
|
||||
MODEL(existingContact).
|
||||
WHERE(Contacts.ID.EQ(UUID(contact.ID))).
|
||||
RETURNING(Contacts.AllColumns)
|
||||
|
||||
updatedContact := model.Contacts{}
|
||||
err = updateContactStmt.QueryContext(ctx, m.dbPool, &updatedContact)
|
||||
|
||||
return updatedContact, err
|
||||
}
|
||||
|
||||
func (m ContactModel) Save(ctx context.Context, userId uuid.UUID, contact model.Contacts) (model.Contacts, error) {
|
||||
// TODO: make this a transaction
|
||||
|
||||
if contact.ID != uuid.Nil {
|
||||
return m.Update(ctx, contact)
|
||||
}
|
||||
|
||||
insertContactStmt := Contacts.
|
||||
INSERT(Contacts.Name, Contacts.Description, Contacts.PhoneNumber, Contacts.Email).
|
||||
VALUES(contact.Name, contact.Description, contact.PhoneNumber, contact.Email).
|
||||
|
@ -31,8 +31,8 @@ func (m EventModel) List(ctx context.Context, userId uuid.UUID) ([]model.Events,
|
||||
func (m EventModel) Save(ctx context.Context, userId uuid.UUID, event model.Events) (model.Events, error) {
|
||||
// TODO tx here
|
||||
insertEventStmt := Events.
|
||||
INSERT(Events.Name, Events.Description, Events.StartDateTime, Events.EndDateTime).
|
||||
VALUES(event.Name, event.Description, event.StartDateTime, event.EndDateTime).
|
||||
INSERT(Events.MutableColumns).
|
||||
MODEL(event).
|
||||
RETURNING(Events.AllColumns)
|
||||
|
||||
insertedEvent := model.Events{}
|
||||
|
@ -30,7 +30,50 @@ func (m LocationModel) List(ctx context.Context, userId uuid.UUID) ([]model.Loca
|
||||
return locations, err
|
||||
}
|
||||
|
||||
func (m LocationModel) Get(ctx context.Context, locationId uuid.UUID) (model.Locations, error) {
|
||||
getLocationStmt := Locations.
|
||||
SELECT(Locations.AllColumns).
|
||||
WHERE(Locations.ID.EQ(UUID(locationId)))
|
||||
|
||||
location := model.Locations{}
|
||||
err := getLocationStmt.QueryContext(ctx, m.dbPool, &location)
|
||||
|
||||
return location, err
|
||||
}
|
||||
|
||||
func (m LocationModel) Update(ctx context.Context, location model.Locations) (model.Locations, error) {
|
||||
existingLocation, err := m.Get(ctx, location.ID)
|
||||
if err != nil {
|
||||
return model.Locations{}, err
|
||||
}
|
||||
|
||||
existingLocation.Name = location.Name
|
||||
|
||||
if location.Description != nil {
|
||||
existingLocation.Description = location.Description
|
||||
}
|
||||
|
||||
if location.Address != nil {
|
||||
existingLocation.Address = location.Address
|
||||
}
|
||||
|
||||
updateLocationStmt := Locations.
|
||||
UPDATE(Locations.MutableColumns).
|
||||
MODEL(existingLocation).
|
||||
WHERE(Locations.ID.EQ(UUID(location.ID))).
|
||||
RETURNING(Locations.AllColumns)
|
||||
|
||||
updatedLocation := model.Locations{}
|
||||
err = updateLocationStmt.QueryContext(ctx, m.dbPool, &updatedLocation)
|
||||
|
||||
return updatedLocation, err
|
||||
}
|
||||
|
||||
func (m LocationModel) Save(ctx context.Context, userId uuid.UUID, location model.Locations) (model.Locations, error) {
|
||||
if location.ID != uuid.Nil {
|
||||
return m.Update(ctx, location)
|
||||
}
|
||||
|
||||
insertLocationStmt := Locations.
|
||||
INSERT(Locations.Name, Locations.Address, Locations.Description).
|
||||
VALUES(location.Name, location.Address, location.Description).
|
||||
|
@ -4,12 +4,12 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
. "screenmark/screenmark/.gen/haystack/haystack/table"
|
||||
|
||||
. "github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@ -30,17 +30,9 @@ type ImageWithProperties struct {
|
||||
Text []model.ImageText
|
||||
|
||||
Locations []model.Locations
|
||||
|
||||
Events []struct {
|
||||
model.Events
|
||||
|
||||
Location *model.Locations
|
||||
Organizer *model.Contacts
|
||||
}
|
||||
|
||||
Notes []model.Notes
|
||||
|
||||
Contacts []model.Contacts
|
||||
Events []model.Events
|
||||
Notes []model.Notes
|
||||
Contacts []model.Contacts
|
||||
}
|
||||
|
||||
func getUserIdFromImage(ctx context.Context, dbPool *sql.DB, imageId uuid.UUID) (uuid.UUID, error) {
|
||||
@ -95,11 +87,9 @@ func (m UserModel) ListWithProperties(ctx context.Context, userId uuid.UUID) ([]
|
||||
LEFT_JOIN(Notes, Notes.ID.EQ(ImageNotes.NoteID))).
|
||||
WHERE(UserImages.UserID.EQ(UUID(userId)))
|
||||
|
||||
fmt.Println(listWithPropertiesStmt.DebugSql())
|
||||
|
||||
images := []ImageWithProperties{}
|
||||
|
||||
err := listWithPropertiesStmt.QueryContext(ctx, m.dbPool, &images)
|
||||
|
||||
if err != nil {
|
||||
return images, err
|
||||
}
|
||||
@ -115,6 +105,24 @@ func (m UserModel) GetUserIdFromEmail(ctx context.Context, email string) (uuid.U
|
||||
return user.ID, err
|
||||
}
|
||||
|
||||
func (m UserModel) DoesUserExist(ctx context.Context, email string) bool {
|
||||
getUserIdStmt := Users.SELECT(Users.ID).WHERE(Users.Email.EQ(String(email)))
|
||||
|
||||
user := model.Users{}
|
||||
err := getUserIdStmt.QueryContext(ctx, m.dbPool, &user)
|
||||
|
||||
return err != qrm.ErrNoRows
|
||||
}
|
||||
|
||||
func (m UserModel) Save(ctx context.Context, user model.Users) (model.Users, error) {
|
||||
insertUserStmt := Users.INSERT(Users.Email).VALUES(user.Email).RETURNING(Users.AllColumns)
|
||||
|
||||
insertedUser := model.Users{}
|
||||
err := insertUserStmt.QueryContext(ctx, m.dbPool, &insertedUser)
|
||||
|
||||
return insertedUser, err
|
||||
}
|
||||
|
||||
func NewUserModel(db *sql.DB) UserModel {
|
||||
return UserModel{dbPool: db}
|
||||
}
|
||||
|
@ -1,6 +1,10 @@
|
||||
DROP SCHEMA IF EXISTS haystack CASCADE;
|
||||
DROP SCHEMA IF EXISTS agents CASCADE;
|
||||
|
||||
CREATE SCHEMA haystack;
|
||||
CREATE SCHEMA agents;
|
||||
|
||||
/** -----| Haystack |----- **/
|
||||
|
||||
/* -----| Enums |----- */
|
||||
|
||||
@ -181,6 +185,29 @@ ON haystack.user_images_to_process
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE notify_new_processing_image_status();
|
||||
|
||||
/** -----| Agents |----- **/
|
||||
|
||||
/* -----| Schema tables |----- */
|
||||
|
||||
CREATE TABLE agents.agents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE agents.system_prompts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
prompt TEXT NOT NULL,
|
||||
|
||||
agent_id UUID NOT NULL REFERENCES agents.agents (id)
|
||||
);
|
||||
|
||||
CREATE TABLE agents.tools (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tool JSONB NOT NULL,
|
||||
|
||||
agent_id UUID NOT NULL REFERENCES agents.agents (id)
|
||||
);
|
||||
|
||||
/* -----| Test Data |----- */
|
||||
|
||||
-- Insert a user
|
||||
|
Binary file not shown.
@ -1,43 +1,46 @@
|
||||
{
|
||||
"name": "haystack",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"lint": "bunx @biomejs/biome lint .",
|
||||
"format": "bunx @biomejs/biome format . --write"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.9",
|
||||
"@kobalte/tailwindcss": "^0.9.0",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@tabler/icons-solidjs": "^3.30.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"solid-js": "^1.9.3",
|
||||
"solid-motionone": "^1.0.3",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"valibot": "^1.0.0-rc.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"tailwindcss": "3.4.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-solid": "^2.11.0"
|
||||
}
|
||||
"name": "haystack",
|
||||
"version": "0.1.0",
|
||||
"description": "Screenshots that organize themselves",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"lint": "bunx @biomejs/biome lint .",
|
||||
"format": "bunx @biomejs/biome format . --write"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.9",
|
||||
"@kobalte/tailwindcss": "^0.9.0",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@tabler/icons-solidjs": "^3.30.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"solid-js": "^1.9.3",
|
||||
"solid-markdown": "^2.0.14",
|
||||
"solid-motionone": "^1.0.3",
|
||||
"solidjs-markdown": "^0.2.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"valibot": "^1.0.0-rc.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"tailwindcss": "3.4.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-solid": "^2.11.0"
|
||||
}
|
||||
}
|
||||
|
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]
|
||||
name = "haystack"
|
||||
name = "Haystack"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
description = "Screenshots that organize themselves"
|
||||
authors = ["Dmytro Kondakov", "John Costa"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
@ -15,17 +15,22 @@ name = "haystack_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
tauri-build = { version = "2.0.0-beta.12", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["macos-private-api"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri = { version = "2.0.0-beta.12", features = ["macos-private-api"] }
|
||||
tauri-plugin-opener = "2.0.0-beta.12"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-dialog = "2.0.0-beta.12"
|
||||
notify = "6.1.1"
|
||||
base64 = "0.21.7"
|
||||
tokio = { version = "1.36.0", features = ["full"] }
|
||||
tauri-plugin-store = "2.0.0-beta.12"
|
||||
tauri-plugin-http = "2.0.0-beta.12"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
cocoa = "0.26"
|
||||
|
||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||
tauri-plugin-global-shortcut = "2.0.0-beta.12"
|
||||
|
@ -7,6 +7,22 @@
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"core:window:allow-start-dragging"
|
||||
"core:window:allow-start-dragging",
|
||||
"global-shortcut:allow-is-registered",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"global-shortcut:allow-unregister-all",
|
||||
"http:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://haystack.johncosta.tech"
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:3040"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
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 _};
|
||||
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::channel;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Emitter;
|
||||
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||
mod commands;
|
||||
mod shortcut;
|
||||
mod state;
|
||||
mod utils;
|
||||
mod window;
|
||||
|
||||
struct WatcherState {
|
||||
watcher: Option<RecommendedWatcher>,
|
||||
}
|
||||
|
||||
impl WatcherState {
|
||||
fn new() -> Self {
|
||||
Self { watcher: None }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PNG file processing
|
||||
fn process_png_file(path: &PathBuf, app: AppHandle) -> Result<(), String> {
|
||||
println!("Processing PNG file: {}", path.display());
|
||||
|
||||
// Read the file
|
||||
let contents = fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
|
||||
// Convert to base64
|
||||
let base64_string = BASE64.encode(&contents);
|
||||
println!("Generated base64 string of length: {}", base64_string.len());
|
||||
|
||||
// Emit the base64 to frontend
|
||||
app.emit("png-processed", base64_string)
|
||||
.map_err(|e| format!("Failed to emit event: {}", e))?;
|
||||
|
||||
println!("Successfully processed file: {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn handle_selected_folder(
|
||||
path: String,
|
||||
state: tauri::State<'_, Arc<Mutex<WatcherState>>>,
|
||||
app: AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let path_buf = PathBuf::from(&path);
|
||||
|
||||
if !path_buf.exists() || !path_buf.is_dir() {
|
||||
return Err("Invalid directory path".to_string());
|
||||
}
|
||||
|
||||
// Stop existing watcher if any
|
||||
let mut state = state
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock state".to_string())?;
|
||||
state.watcher = None;
|
||||
|
||||
// Create a channel to receive file system events
|
||||
let (tx, rx) = channel();
|
||||
|
||||
// Create a new watcher
|
||||
let mut watcher = RecommendedWatcher::new(tx, Config::default())
|
||||
.map_err(|e| format!("Failed to create watcher: {}", e))?;
|
||||
|
||||
// Start watching the directory
|
||||
watcher
|
||||
.watch(path_buf.as_ref(), RecursiveMode::Recursive)
|
||||
.map_err(|e| format!("Failed to watch directory: {}", e))?;
|
||||
|
||||
// Store the watcher in state
|
||||
state.watcher = Some(watcher);
|
||||
|
||||
let path_clone = path.clone();
|
||||
let app_clone = app.clone();
|
||||
tokio::spawn(async move {
|
||||
println!("Starting to watch directory: {}", path_clone);
|
||||
for res in rx {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
println!("Received event: {:?}", event);
|
||||
match event.kind {
|
||||
notify::EventKind::Create(_) | notify::EventKind::Modify(_) => {
|
||||
for path in event.paths {
|
||||
println!("Processing path: {}", path.display());
|
||||
if let Some(extension) = path.extension() {
|
||||
if extension.to_string_lossy().to_lowercase() == "png" {
|
||||
if let Err(e) = process_png_file(&path, app_clone.clone()) {
|
||||
eprintln!("Error processing PNG file: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Watch error: {:?}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(format!("Now watching directory: {}", path))
|
||||
}
|
||||
use state::new_shared_watcher_state;
|
||||
use window::setup_window;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let watcher_state = Arc::new(Mutex::new(WatcherState::new()));
|
||||
let watcher_state = new_shared_watcher_state();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(watcher_state)
|
||||
.invoke_handler(tauri::generate_handler![handle_selected_folder])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::handle_selected_folder,
|
||||
shortcut::change_shortcut,
|
||||
shortcut::unregister_shortcut,
|
||||
shortcut::get_current_shortcut,
|
||||
])
|
||||
.setup(|app| {
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.inner_size(480.0, 360.0)
|
||||
// .hidden_title(true)
|
||||
.resizable(true);
|
||||
// set transparent title bar only when building for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
let win_builder = win_builder.title_bar_style(TitleBarStyle::Transparent);
|
||||
|
||||
let window = win_builder.build().unwrap();
|
||||
|
||||
// set background color only when building for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use cocoa::appkit::{NSColor, NSWindow};
|
||||
use cocoa::base::{id, nil};
|
||||
|
||||
let ns_window = window.ns_window().unwrap() as id;
|
||||
unsafe {
|
||||
let bg_color = NSColor::colorWithRed_green_blue_alpha_(
|
||||
nil,
|
||||
245.0 / 255.0,
|
||||
245.0 / 255.0,
|
||||
245.0 / 255.0,
|
||||
1.0,
|
||||
);
|
||||
ns_window.setBackgroundColor_(bg_color);
|
||||
}
|
||||
}
|
||||
setup_window(app)?;
|
||||
shortcut::enable_shortcut(app);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
|
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",
|
||||
"productName": "haystack",
|
||||
"productName": "Haystack",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.haystack.app",
|
||||
"build": {
|
||||
|
@ -1,176 +1,35 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { IconSearch } from "@tabler/icons-solidjs";
|
||||
import clsx from "clsx";
|
||||
import Fuse from "fuse.js";
|
||||
import { For, createEffect, createResource, createSignal } from "solid-js";
|
||||
import { SearchCardEvent } from "./components/search-card/SearchCardEvent";
|
||||
import { SearchCardLocation } from "./components/search-card/SearchCardLocation";
|
||||
import { SearchCardNote } from "./components/search-card/SearchCardNote";
|
||||
import { type UserImage, getUserImages } from "./network";
|
||||
import { getCardSize } from "./utils/getCardSize";
|
||||
import { SearchCardContact } from "./components/search-card/SearchCardContact";
|
||||
|
||||
const getCardComponent = (item: UserImage) => {
|
||||
switch (item.type) {
|
||||
case "location":
|
||||
return <SearchCardLocation item={item} />;
|
||||
case "event":
|
||||
return <SearchCardEvent item={item} />;
|
||||
case "note":
|
||||
return <SearchCardNote item={item} />;
|
||||
case "contact":
|
||||
return <SearchCardContact item={item} />;
|
||||
// case "Website":
|
||||
// return <SearchCardWebsite item={item} />;
|
||||
// case "Note":
|
||||
// return <SearchCardNote item={item} />;
|
||||
// case "Receipt":
|
||||
// return <SearchCardReceipt item={item} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// How wonderfully functional
|
||||
const getAllValues = (object: object): Array<string> => {
|
||||
const loop = (acc: Array<string>, next: object): Array<string> => {
|
||||
for (const _value of Object.values(next)) {
|
||||
const value: unknown = _value;
|
||||
switch (typeof value) {
|
||||
case "object":
|
||||
if (value != null) {
|
||||
acc.push(...loop(acc, value));
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
acc.push(value.toString());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
};
|
||||
|
||||
return loop([], object);
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [searchResults, setSearchResults] = createSignal<UserImage[]>([]);
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [selectedItem, setSelectedItem] = createSignal<UserImage | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [data] = createResource(() =>
|
||||
getUserImages().then((data) =>
|
||||
data.map((d) => ({
|
||||
...d,
|
||||
rawData: getAllValues(d),
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
let fuze = new Fuse<UserImage>(data() ?? [], {
|
||||
keys: [
|
||||
{ name: "rawData", weight: 1 },
|
||||
{ name: "title", weight: 1 },
|
||||
],
|
||||
threshold: 0.4,
|
||||
});
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { createEffect, onCleanup } from "solid-js";
|
||||
import { Login } from "./Login";
|
||||
import { ProtectedRoute } from "./ProtectedRoute";
|
||||
import { Search } from "./Search";
|
||||
import { Settings } from "./Settings";
|
||||
import { ImageViewer } from "./components/ImageViewer";
|
||||
|
||||
export const App = () => {
|
||||
createEffect(() => {
|
||||
fuze = new Fuse<UserImage>(data() ?? [], {
|
||||
keys: [
|
||||
{ name: "data.Name", weight: 2 },
|
||||
{ name: "rawData", weight: 1 },
|
||||
],
|
||||
threshold: 0.4,
|
||||
// TODO: Don't use window.location.href
|
||||
const unlisten = listen("focus-search", () => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unlisten.then((fn) => fn());
|
||||
});
|
||||
});
|
||||
|
||||
const onInputChange = (event: InputEvent) => {
|
||||
const query = (event.target as HTMLInputElement).value;
|
||||
setSearchQuery(query);
|
||||
setSearchResults(fuze.search(query).map((s) => s.item));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<main class="container pt-2">
|
||||
<A href="login">login</A>
|
||||
<div class="px-4">
|
||||
<div class="inline-flex justify-between w-full rounded-xl text-base leading-none outline-none bg-white border border-neutral-200 text-neutral-900">
|
||||
<div class="appearance-none inline-flex justify-center items-center w-auto outline-none rounded-l-md px-2.5 text-gray-900">
|
||||
<IconSearch
|
||||
size={20}
|
||||
class="m-auto size-5 text-neutral-600"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery()}
|
||||
onInput={onInputChange}
|
||||
placeholder="Search for stuff..."
|
||||
class="appearance-none inline-flex w-full min-h-[40px] text-base bg-transparent rounded-l-md outline-none placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ImageViewer />
|
||||
<Router>
|
||||
<Route path="/login" component={Login} />
|
||||
|
||||
<div class="px-4 mt-4 bg-white rounded-t-2xl">
|
||||
<div class="h-[254px] mt-4 overflow-scroll scrollbar-hide">
|
||||
{searchResults().length > 0 ? (
|
||||
<div class="w-full grid grid-cols-9 gap-2 grid-flow-row-dense py-4">
|
||||
<For each={searchResults()}>
|
||||
{(item) => (
|
||||
<div
|
||||
onClick={() =>
|
||||
setSelectedItem(item)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setSelectedItem(item);
|
||||
}
|
||||
}}
|
||||
class={clsx(
|
||||
"h-[144px] border relative border-neutral-200 cursor-pointer overflow-hidden rounded-xl",
|
||||
{
|
||||
"col-span-3":
|
||||
getCardSize(
|
||||
item.type,
|
||||
) === "1/1",
|
||||
"col-span-6":
|
||||
getCardSize(
|
||||
item.type,
|
||||
) === "2/1",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span class="sr-only">
|
||||
{item.data.Name}
|
||||
</span>
|
||||
{getCardComponent(item)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
) : searchQuery() !== "" ? (
|
||||
<div class="text-center text-lg m-auto text-neutral-700">
|
||||
No results found
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full border-t h-10 bg-white px-4 flex items-center border-neutral-100">
|
||||
footer
|
||||
</div>
|
||||
</main>
|
||||
<Route path="/" component={ProtectedRoute}>
|
||||
<Route path="/" component={Search} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
</Route>
|
||||
</Router>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
};
|
||||
|
@ -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 { TextField } from "@kobalte/core/text-field";
|
||||
import { createSignal, Show, type Component } from "solid-js";
|
||||
import { postCode, postLogin } from "./network";
|
||||
import { isTokenValid } from "./ProtectedRoute";
|
||||
import { Navigate } from "@solidjs/router";
|
||||
import { type Component, Show, createSignal } from "solid-js";
|
||||
import { isTokenValid } from "./ProtectedRoute";
|
||||
import { base, postCode, postLogin } from "./network";
|
||||
|
||||
export const Login: Component = () => {
|
||||
let form: HTMLFormElement | undefined;
|
||||
@ -34,26 +34,31 @@ export const Login: Component = () => {
|
||||
|
||||
localStorage.setItem("access", access);
|
||||
localStorage.setItem("refresh", refresh);
|
||||
|
||||
window.location.href = "/";
|
||||
}
|
||||
};
|
||||
|
||||
const isAuthorized = isTokenValid();
|
||||
|
||||
return (
|
||||
<Show when={!isAuthorized} fallback={<Navigate href="/" />}>
|
||||
<form ref={form} onSubmit={onSubmit}>
|
||||
<TextField name="email">
|
||||
<TextField.Label>Email</TextField.Label>
|
||||
<TextField.Input />
|
||||
</TextField>
|
||||
<Show when={submitted()}>
|
||||
<TextField name="code">
|
||||
<TextField.Label>Code</TextField.Label>
|
||||
<>
|
||||
{base}
|
||||
<Show when={!isAuthorized} fallback={<Navigate href="/" />}>
|
||||
<form ref={form} onSubmit={onSubmit}>
|
||||
<TextField name="email">
|
||||
<TextField.Label>Email</TextField.Label>
|
||||
<TextField.Input />
|
||||
</TextField>
|
||||
</Show>
|
||||
<Button type="submit">Submit</Button>
|
||||
</form>
|
||||
</Show>
|
||||
<Show when={submitted()}>
|
||||
<TextField name="code">
|
||||
<TextField.Label>Code</TextField.Label>
|
||||
<TextField.Input />
|
||||
</TextField>
|
||||
</Show>
|
||||
<Button type="submit">Submit</Button>
|
||||
</form>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
216
frontend/src/Search.tsx
Normal file
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 { FolderPicker } from "./FolderPicker";
|
||||
import { createEffect } from "solid-js";
|
||||
import { sendImage } from "../network";
|
||||
|
||||
export function ImageViewer() {
|
||||
const [latestImage, setLatestImage] = createSignal<string | null>(null);
|
||||
// const [latestImage, setLatestImage] = createSignal<string | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
createEffect(async () => {
|
||||
// Listen for PNG processing events
|
||||
const unlisten = listen("png-processed", (event) => {
|
||||
const unlisten = listen("png-processed", async (event) => {
|
||||
console.log("Received processed PNG", event);
|
||||
const base64Data = event.payload as string;
|
||||
|
||||
setLatestImage(`data:image/png;base64,${base64Data}`);
|
||||
sendImage("test-image.png", base64Data);
|
||||
// setLatestImage(`data:image/png;base64,${base64Data}`);
|
||||
const result = await sendImage("test-image.png", base64Data);
|
||||
|
||||
window.location.reload();
|
||||
console.log("DBG: ", result);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@ -21,20 +23,22 @@ export function ImageViewer() {
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FolderPicker />
|
||||
return null;
|
||||
|
||||
{latestImage() && (
|
||||
<div class="mt-4">
|
||||
<h3>Latest Processed Image:</h3>
|
||||
<img
|
||||
src={latestImage() || undefined}
|
||||
alt="Latest processed"
|
||||
class="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// return (
|
||||
// <div>
|
||||
// <FolderPicker />
|
||||
|
||||
// {latestImage() && (
|
||||
// <div class="mt-4">
|
||||
// <h3>Latest Processed Image:</h3>
|
||||
// <img
|
||||
// src={latestImage() || undefined}
|
||||
// alt="Latest processed"
|
||||
// class="max-w-md"
|
||||
// />
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
}
|
||||
|
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;
|
||||
}
|
||||
};
|
@ -12,17 +12,15 @@ export const SearchCardContact = ({ item }: Props) => {
|
||||
|
||||
return (
|
||||
<div class="absolute inset-0 p-3 bg-orange-50">
|
||||
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
|
||||
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
|
||||
<IconUser size={20} class="text-neutral-500 mt-1" />
|
||||
<div class="flex mb-1 items-center gap-1">
|
||||
<IconUser size={14} class="text-neutral-500" />
|
||||
<p class="text-xs text-neutral-500">Contact</p>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500">{data.PhoneNumber}</p>
|
||||
<Separator class="my-2" />
|
||||
<p class="text-xs text-neutral-500">{data.Email}</p>
|
||||
<Separator class="my-2" />
|
||||
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
|
||||
{data.Description}
|
||||
<p class="text-sm text-neutral-900 font-bold mb-1">
|
||||
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
|
||||
</p>
|
||||
<p class="text-xs text-neutral-700">Phone: {data.PhoneNumber}</p>
|
||||
<p class="text-xs text-neutral-700">Mail: {data.Email}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { Separator } from "@kobalte/core/separator";
|
||||
|
||||
import { IconCalendar } from "@tabler/icons-solidjs";
|
||||
import type { UserImage } from "../../network";
|
||||
|
||||
@ -12,21 +10,22 @@ export const SearchCardEvent = ({ item }: Props) => {
|
||||
|
||||
return (
|
||||
<div class="absolute inset-0 p-3 bg-purple-50">
|
||||
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
|
||||
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
|
||||
<IconCalendar size={20} class="text-neutral-500 mt-1" />
|
||||
<div class="flex mb-1 items-center gap-1">
|
||||
<IconCalendar size={14} class="text-neutral-500" />
|
||||
<p class="text-xs text-neutral-500">Event</p>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500">
|
||||
Organized by {data.Organizer?.Name ?? "unknown"} on{" "}
|
||||
{new Date(data.StartDateTime).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
<p class="text-sm text-neutral-900 font-bold mb-1">
|
||||
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
|
||||
</p>
|
||||
<Separator class="my-2" />
|
||||
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
|
||||
{data.Description}
|
||||
<p class="text-xs text-neutral-700">
|
||||
Organized by {data.Organizer?.Name ?? "unknown"} on{" "}
|
||||
{data.StartDateTime
|
||||
? new Date(data.StartDateTime).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: "unknown date"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { Separator } from "@kobalte/core/separator";
|
||||
|
||||
import { IconMapPin } from "@tabler/icons-solidjs";
|
||||
import type { UserImage } from "../../network";
|
||||
|
||||
@ -12,15 +10,14 @@ export const SearchCardLocation = ({ item }: Props) => {
|
||||
|
||||
return (
|
||||
<div class="absolute inset-0 p-3 bg-red-50">
|
||||
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
|
||||
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
|
||||
<IconMapPin size={20} class="text-neutral-500 mt-1" />
|
||||
<div class="flex mb-1 items-center gap-1">
|
||||
<IconMapPin size={14} class="text-neutral-500" />
|
||||
<p class="text-xs text-neutral-500">Location</p>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500">{data.Address}</p>
|
||||
<Separator class="my-2" />
|
||||
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
|
||||
{data.Description}
|
||||
<p class="text-sm text-neutral-900 font-bold mb-1">
|
||||
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
|
||||
</p>
|
||||
<p class="text-xs text-neutral-700">Address: {data.Address}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Separator } from "@kobalte/core/separator";
|
||||
import SolidjsMarkdown from "solidjs-markdown";
|
||||
|
||||
import { IconNote } from "@tabler/icons-solidjs";
|
||||
import type { UserImage } from "../../network";
|
||||
@ -12,14 +13,15 @@ export const SearchCardNote = ({ item }: Props) => {
|
||||
|
||||
return (
|
||||
<div class="absolute inset-0 p-3 bg-green-50">
|
||||
<div class="grid grid-cols-[auto_20px] gap-1 mb-1">
|
||||
<p class="text-sm text-neutral-900 font-bold">{data.Name}</p>
|
||||
<IconNote size={20} class="text-neutral-500 mt-1" />
|
||||
<div class="flex mb-1 items-center gap-1">
|
||||
<IconNote size={14} class="text-neutral-500" />
|
||||
<p class="text-xs text-neutral-500">Note</p>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500">Keywords TODO</p>
|
||||
<Separator class="my-2" />
|
||||
<p class="text-xs text-neutral-500 line-clamp-2 overflow-hidden">
|
||||
{data.Content}
|
||||
<p class="text-sm text-neutral-900 font-bold mb-1">
|
||||
{data.Name.length > 0 ? data.Name : "Unknown 🐞"}
|
||||
</p>
|
||||
<p class="text-xs text-neutral-700">
|
||||
<SolidjsMarkdown>{data.Content}</SolidjsMarkdown>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
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 */
|
||||
import { render } from "solid-js/web";
|
||||
import App from "./App";
|
||||
|
||||
import "./index.css";
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import { ImagePage } from "./ImagePage";
|
||||
import { Login } from "./Login";
|
||||
import { ProtectedRoute } from "./ProtectedRoute";
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="/login" component={Login} />
|
||||
import { App } from "./App";
|
||||
|
||||
<Route path="/" component={ProtectedRoute}>
|
||||
<Route path="/" component={App} />
|
||||
<Route path="/image/:imageId" component={ImagePage} />
|
||||
</Route>
|
||||
</Router>
|
||||
),
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
render(() => <App />, document.getElementById("root") as HTMLElement);
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
import {
|
||||
type InferOutput,
|
||||
array,
|
||||
literal,
|
||||
nullable,
|
||||
strictObject,
|
||||
parse,
|
||||
pipe,
|
||||
strictObject,
|
||||
string,
|
||||
uuid,
|
||||
literal,
|
||||
variant,
|
||||
} from "valibot";
|
||||
|
||||
@ -17,8 +19,12 @@ type BaseRequestParams = Partial<{
|
||||
method: "GET" | "POST";
|
||||
}>;
|
||||
|
||||
export const base = import.meta.env.DEV
|
||||
? "http://localhost:3040"
|
||||
: "https://haystack.johncosta.tech";
|
||||
|
||||
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
|
||||
return new Request(`http://localhost:3040/${path}`, {
|
||||
return new Request(`${base}/${path}`, {
|
||||
body,
|
||||
method,
|
||||
});
|
||||
@ -29,7 +35,7 @@ const getBaseAuthorizedRequest = ({
|
||||
body,
|
||||
method,
|
||||
}: BaseRequestParams): Request => {
|
||||
return new Request(`http://localhost:3040/${path}`, {
|
||||
return new Request(`${base}/${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("access")?.toString()}`,
|
||||
},
|
||||
@ -83,9 +89,9 @@ const eventValidator = strictObject({
|
||||
EndDateTime: nullable(pipe(string())),
|
||||
Description: nullable(string()),
|
||||
LocationID: nullable(pipe(string(), uuid())),
|
||||
Location: nullable(locationValidator),
|
||||
// Location: nullable(locationValidator),
|
||||
OrganizerID: nullable(pipe(string(), uuid())),
|
||||
Organizer: nullable(contactValidator),
|
||||
// Organizer: nullable(contactValidator),
|
||||
});
|
||||
|
||||
const noteValidator = strictObject({
|
||||
@ -130,6 +136,8 @@ export const getUserImages = async (): Promise<UserImage[]> => {
|
||||
|
||||
const res = await fetch(request).then((res) => res.json());
|
||||
|
||||
console.log("BACKEND RESPONSE: ", res);
|
||||
|
||||
return parse(getUserImagesResponseValidator, res);
|
||||
};
|
||||
|
||||
|
Reference in New Issue
Block a user