diff --git a/neo/conversation/mongo.go b/neo/conversation/mongo.go index a30461fe..b406ffac 100644 --- a/neo/conversation/mongo.go +++ b/neo/conversation/mongo.go @@ -1,5 +1,7 @@ package conversation +import "github.com/yaoapp/xun" + // Mongo conversation type Mongo struct{} @@ -58,3 +60,29 @@ func (conv *Mongo) DeleteChat(sid string, cid string) error { func (conv *Mongo) DeleteAllChats(sid string) error { return nil } + +// SaveAssistant creates or updates an assistant +func (conv *Mongo) SaveAssistant(assistant map[string]interface{}) error { + return nil +} + +// DeleteAssistant deletes an assistant by assistant_id +func (conv *Mongo) DeleteAssistant(assistantID string) error { + return nil +} + +// GetAssistants retrieves assistants with pagination and tag filtering +func (conv *Mongo) GetAssistants(filter AssistantFilter) (*AssistantResponse, error) { + return &AssistantResponse{ + P: xun.P{ + Items: []interface{}{}, + Total: 0, + TotalPages: 0, + PageSize: filter.PageSize, + CurrentPage: filter.Page, + NextPage: 0, + PreviousPage: 0, + LastPage: 0, + }, + }, nil +} diff --git a/neo/conversation/redis.go b/neo/conversation/redis.go index 7da3f4d2..9aef5998 100644 --- a/neo/conversation/redis.go +++ b/neo/conversation/redis.go @@ -1,5 +1,7 @@ package conversation +import "github.com/yaoapp/xun" + // Redis conversation type Redis struct{} @@ -58,3 +60,29 @@ func (conv *Redis) DeleteChat(sid string, cid string) error { func (conv *Redis) DeleteAllChats(sid string) error { return nil } + +// SaveAssistant creates or updates an assistant +func (conv *Redis) SaveAssistant(assistant map[string]interface{}) error { + return nil +} + +// DeleteAssistant deletes an assistant by assistant_id +func (conv *Redis) DeleteAssistant(assistantID string) error { + return nil +} + +// GetAssistants retrieves assistants with pagination and tag filtering +func (conv *Redis) GetAssistants(filter AssistantFilter) (*AssistantResponse, error) { + return &AssistantResponse{ + P: xun.P{ + Items: []interface{}{}, + Total: 0, + TotalPages: 0, + PageSize: filter.PageSize, + CurrentPage: filter.Page, + NextPage: 0, + PreviousPage: 0, + LastPage: 0, + }, + }, nil +} diff --git a/neo/conversation/types.go b/neo/conversation/types.go index 63375e90..fd9e2d40 100644 --- a/neo/conversation/types.go +++ b/neo/conversation/types.go @@ -1,5 +1,7 @@ package conversation +import "github.com/yaoapp/xun" + // Setting the conversation config type Setting struct { Connector string `json:"connector,omitempty"` @@ -38,6 +40,18 @@ type ChatGroupResponse struct { LastPage int `json:"last_page"` // 最后一页页码 } +// AssistantFilter represents the filter parameters for GetAssistants +type AssistantFilter struct { + Tags []string `json:"tags,omitempty"` + Page int `json:"page,omitempty"` // Page number, starting from 1 + PageSize int `json:"pagesize,omitempty"` // Items per page +} + +// AssistantResponse represents paginated assistant results +type AssistantResponse struct { + xun.P +} + // Conversation the store interface type Conversation interface { GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error) @@ -47,4 +61,7 @@ type Conversation interface { DeleteChat(sid string, cid string) error DeleteAllChats(sid string) error UpdateChatTitle(sid string, cid string, title string) error + SaveAssistant(assistant map[string]interface{}) error + DeleteAssistant(assistantID string) error + GetAssistants(filter AssistantFilter) (*AssistantResponse, error) } diff --git a/neo/conversation/weaviate.go b/neo/conversation/weaviate.go index 0131251e..932aa22a 100644 --- a/neo/conversation/weaviate.go +++ b/neo/conversation/weaviate.go @@ -1,5 +1,7 @@ package conversation +import "github.com/yaoapp/xun" + // Weaviate Database conversation type Weaviate struct{} @@ -58,3 +60,29 @@ func (conv *Weaviate) DeleteChat(sid string, cid string) error { func (conv *Weaviate) DeleteAllChats(sid string) error { return nil } + +// SaveAssistant creates or updates an assistant +func (conv *Weaviate) SaveAssistant(assistant map[string]interface{}) error { + return nil +} + +// DeleteAssistant deletes an assistant by assistant_id +func (conv *Weaviate) DeleteAssistant(assistantID string) error { + return nil +} + +// GetAssistants retrieves assistants with pagination and tag filtering +func (conv *Weaviate) GetAssistants(filter AssistantFilter) (*AssistantResponse, error) { + return &AssistantResponse{ + P: xun.P{ + Items: []interface{}{}, + Total: 0, + TotalPages: 0, + PageSize: filter.PageSize, + CurrentPage: filter.Page, + NextPage: 0, + PreviousPage: 0, + LastPage: 0, + }, + }, nil +} diff --git a/neo/conversation/xun.go b/neo/conversation/xun.go index 3c394912..33eb6180 100644 --- a/neo/conversation/xun.go +++ b/neo/conversation/xun.go @@ -217,6 +217,7 @@ func (conv *Xun) initAssistantTable() error { table.JSON("flows").Null() // assistant flows table.JSON("files").Null() // assistant files table.JSON("functions").Null() // assistant functions + table.JSON("tags").Null() // assistant tags table.Boolean("readonly").SetDefault(false).Index() // assistant readonly table.JSON("permissions").Null() // assistant permissions table.Boolean("automated").SetDefault(true).Index() // assistant autoable @@ -237,7 +238,7 @@ func (conv *Xun) initAssistantTable() error { return err } - fields := []string{"id", "assistant_id", "type", "name", "avatar", "connector", "description", "options", "prompts", "flows", "files", "functions", "mentionable", "created_at", "updated_at"} + fields := []string{"id", "assistant_id", "type", "name", "avatar", "connector", "description", "options", "prompts", "flows", "files", "functions", "tags", "mentionable", "created_at", "updated_at"} for _, field := range fields { if !tab.HasColumn(field) { return fmt.Errorf("%s is required", field) @@ -648,3 +649,83 @@ func (conv *Xun) DeleteAllChats(sid string) error { Delete() return err } + +// SaveAssistant creates or updates an assistant +func (conv *Xun) SaveAssistant(assistant map[string]interface{}) error { + assistantID, ok := assistant["assistant_id"].(string) + if !ok || assistantID == "" { + assistantID = uuid.New().String() + assistant["assistant_id"] = assistantID + } + + // Check if assistant exists + exists, err := conv.query.New(). + Table(conv.getAssistantTable()). + Where("assistant_id", assistantID). + Exists() + if err != nil { + return err + } + + now := time.Now() + assistant["updated_at"] = now + + if exists { + // Update existing assistant + _, err = conv.query.New(). + Table(conv.getAssistantTable()). + Where("assistant_id", assistantID). + Update(assistant) + } else { + // Create new assistant + assistant["created_at"] = now + err = conv.query.New(). + Table(conv.getAssistantTable()). + Insert(assistant) + } + + return err +} + +// DeleteAssistant deletes an assistant by assistant_id +func (conv *Xun) DeleteAssistant(assistantID string) error { + _, err := conv.query.New(). + Table(conv.getAssistantTable()). + Where("assistant_id", assistantID). + Delete() + return err +} + +// GetAssistants retrieves assistants with pagination and tag filtering +func (conv *Xun) GetAssistants(filter AssistantFilter) (*AssistantResponse, error) { + qb := conv.query.New(). + Table(conv.getAssistantTable()) + + // Apply tag filter if provided + if filter.Tags != nil && len(filter.Tags) > 0 { + for i, tag := range filter.Tags { + if i == 0 { + qb.Where("tags", "like", fmt.Sprintf("%%\"%s\"%%", tag)) + } else { + qb.OrWhere("tags", "like", fmt.Sprintf("%%\"%s\"%%", tag)) + } + } + } + + // Set defaults for pagination + if filter.PageSize <= 0 { + filter.PageSize = 20 + } + if filter.Page <= 0 { + filter.Page = 1 + } + + // Get paginated results + paginator, err := qb.OrderBy("created_at", "desc"). + Paginate(filter.PageSize, filter.Page) + if err != nil { + return nil, err + } + + return &AssistantResponse{P: paginator}, nil +} diff --git a/neo/conversation/xun_test.go b/neo/conversation/xun_test.go index 34037b5c..adc9306b 100644 --- a/neo/conversation/xun_test.go +++ b/neo/conversation/xun_test.go @@ -5,8 +5,10 @@ import ( "testing" "time" + jsoniter "github.com/json-iterator/go" "github.com/stretchr/testify/assert" "github.com/yaoapp/gou/connector" + "github.com/yaoapp/xun" "github.com/yaoapp/xun/capsule" "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/test" @@ -15,9 +17,21 @@ import ( func TestNewXunDefault(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() - defer capsule.Schema().DropTableIfExists("__unit_test_conversation") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_assistant") - err := capsule.Schema().DropTableIfExists("__unit_test_conversation") + err := capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + if err != nil { + t.Fatal(err) + } + + err = capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") + if err != nil { + t.Fatal(err) + } + + err = capsule.Schema().DropTableIfExists("__unit_test_conversation_assistant") if err != nil { t.Fatal(err) } @@ -32,15 +46,29 @@ func TestNewXunDefault(t *testing.T) { return } - has, err := capsule.Schema().HasTable("__unit_test_conversation") + // Check history table + has, err := capsule.Schema().HasTable("__unit_test_conversation_history") if err != nil { t.Fatal(err) } - assert.Equal(t, true, has) - // validate the table - tab, err := conv.schema.GetTable(conv.setting.Table) + // Check chat table + has, err = capsule.Schema().HasTable("__unit_test_conversation_chat") + if err != nil { + t.Fatal(err) + } + assert.Equal(t, true, has) + + // Check assistant table + has, err = capsule.Schema().HasTable("__unit_test_conversation_assistant") + if err != nil { + t.Fatal(err) + } + assert.Equal(t, true, has) + + // validate the history table + tab, err := conv.schema.GetTable(conv.getHistoryTable()) if err != nil { t.Fatal(err) } @@ -50,17 +78,27 @@ func TestNewXunDefault(t *testing.T) { assert.Equal(t, true, tab.HasColumn(field)) } - conv, err = NewXun(Setting{ - Connector: "default", - Table: "__unit_test_conversation", - }) - - has, err = capsule.Schema().HasTable("__unit_test_conversation") + // validate the chat table + tab, err = conv.schema.GetTable(conv.getChatTable()) if err != nil { t.Fatal(err) } - assert.Equal(t, true, has) + chatFields := []string{"id", "chat_id", "title", "sid", "created_at", "updated_at"} + for _, field := range chatFields { + assert.Equal(t, true, tab.HasColumn(field)) + } + + // validate the assistant table + tab, err = conv.schema.GetTable(conv.getAssistantTable()) + if err != nil { + t.Fatal(err) + } + + assistantFields := []string{"id", "assistant_id", "type", "name", "avatar", "connector", "description", "options", "prompts", "flows", "files", "functions", "tags", "readonly", "permissions", "automated", "mentionable", "created_at", "updated_at"} + for _, field := range assistantFields { + assert.Equal(t, true, tab.HasColumn(field)) + } } func TestNewXunConnector(t *testing.T) { @@ -77,9 +115,14 @@ func TestNewXunConnector(t *testing.T) { t.Fatal(err) } - defer sch.DropTableIfExists("__unit_test_conversation") + defer sch.DropTableIfExists("__unit_test_conversation_history") + defer sch.DropTableIfExists("__unit_test_conversation_chat") + defer sch.DropTableIfExists("__unit_test_conversation_assistant") + + sch.DropTableIfExists("__unit_test_conversation_history") + sch.DropTableIfExists("__unit_test_conversation_chat") + sch.DropTableIfExists("__unit_test_conversation_assistant") - sch.DropTableIfExists("__unit_test_conversation") conv, err := NewXun(Setting{ Connector: "mysql", Table: "__unit_test_conversation", @@ -90,15 +133,29 @@ func TestNewXunConnector(t *testing.T) { return } - has, err := sch.HasTable("__unit_test_conversation") + // Check history table + has, err := sch.HasTable("__unit_test_conversation_history") if err != nil { t.Fatal(err) } - assert.Equal(t, true, has) - // validate the table - tab, err := conv.schema.GetTable(conv.setting.Table) + // Check chat table + has, err = sch.HasTable("__unit_test_conversation_chat") + if err != nil { + t.Fatal(err) + } + assert.Equal(t, true, has) + + // Check assistant table + has, err = sch.HasTable("__unit_test_conversation_assistant") + if err != nil { + t.Fatal(err) + } + assert.Equal(t, true, has) + + // validate the history table + tab, err := conv.schema.GetTable(conv.getHistoryTable()) if err != nil { t.Fatal(err) } @@ -108,26 +165,41 @@ func TestNewXunConnector(t *testing.T) { assert.Equal(t, true, tab.HasColumn(field)) } - conv, err = NewXun(Setting{ - Connector: "default", - Table: "__unit_test_conversation", - }) - - has, err = sch.HasTable("__unit_test_conversation") + // validate the chat table + tab, err = conv.schema.GetTable(conv.getChatTable()) if err != nil { t.Fatal(err) } - assert.Equal(t, true, has) + chatFields := []string{"id", "chat_id", "title", "sid", "created_at", "updated_at"} + for _, field := range chatFields { + assert.Equal(t, true, tab.HasColumn(field)) + } + + // validate the assistant table + tab, err = conv.schema.GetTable(conv.getAssistantTable()) + if err != nil { + t.Fatal(err) + } + + assistantFields := []string{"id", "assistant_id", "type", "name", "avatar", "connector", "description", "options", "prompts", "flows", "files", "functions", "tags", "readonly", "permissions", "automated", "mentionable", "created_at", "updated_at"} + for _, field := range assistantFields { + assert.Equal(t, true, tab.HasColumn(field)) + } } func TestXunSaveAndGetHistory(t *testing.T) { - test.Prepare(t, config.Conf) defer test.Clean() - defer capsule.Schema().DropTableIfExists("__unit_test_conversation") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") - err := capsule.Schema().DropTableIfExists("__unit_test_conversation") + err := capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + if err != nil { + t.Fatal(err) + } + + err = capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") if err != nil { t.Fatal(err) } @@ -157,9 +229,15 @@ func TestXunSaveAndGetHistory(t *testing.T) { func TestXunSaveAndGetHistoryWithCID(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() - defer capsule.Schema().DropTableIfExists("__unit_test_conversation") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") - err := capsule.Schema().DropTableIfExists("__unit_test_conversation") + err := capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + if err != nil { + t.Fatal(err) + } + + err = capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") if err != nil { t.Fatal(err) } @@ -220,11 +298,11 @@ func TestXunSaveAndGetHistoryWithCID(t *testing.T) { func TestXunGetChats(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() - defer capsule.Schema().DropTableIfExists("__unit_test_conversation") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_history") defer capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") // Drop both tables before test - err := capsule.Schema().DropTableIfExists("__unit_test_conversation") + err := capsule.Schema().DropTableIfExists("__unit_test_conversation_history") if err != nil { t.Fatal(err) } @@ -293,7 +371,7 @@ func TestXunGetChats(t *testing.T) { func TestXunDeleteChat(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() - defer capsule.Schema().DropTableIfExists("__unit_test_conversation") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_history") defer capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") conv, err := NewXun(Setting{ @@ -333,7 +411,7 @@ func TestXunDeleteChat(t *testing.T) { func TestXunDeleteAllChats(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() - defer capsule.Schema().DropTableIfExists("__unit_test_conversation") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_history") defer capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") conv, err := NewXun(Setting{ @@ -371,3 +449,172 @@ func TestXunDeleteAllChats(t *testing.T) { assert.Nil(t, err) assert.Equal(t, int64(0), response.Total) } + +func TestXunAssistantCRUD(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_assistant") + + // Drop assistant table before test + err := capsule.Schema().DropTableIfExists("__unit_test_conversation_assistant") + if err != nil { + t.Fatal(err) + } + + conv, err := NewXun(Setting{ + Connector: "default", + Table: "__unit_test_conversation", + }) + if err != nil { + t.Fatal(err) + } + + // Test creating a new assistant + tagsJSON, err := jsoniter.MarshalToString([]string{"tag1", "tag2", "tag3"}) + if err != nil { + t.Fatal(err) + } + + optionsJSON, err := jsoniter.MarshalToString(map[string]interface{}{ + "model": "gpt-4", + }) + if err != nil { + t.Fatal(err) + } + + assistant := map[string]interface{}{ + "name": "Test Assistant", + "type": "assistant", + "avatar": "https://example.com/avatar.png", + "connector": "openai", + "description": "Test Description", + "tags": tagsJSON, + "options": optionsJSON, + } + + // Test SaveAssistant (Create) + err = conv.SaveAssistant(assistant) + assert.Nil(t, err) + assistantID := assistant["assistant_id"].(string) + assert.NotEmpty(t, assistantID) + + // Test GetAssistants with no filter + resp, err := conv.GetAssistants(AssistantFilter{}) + assert.Nil(t, err) + assert.Equal(t, 1, len(resp.P.Items)) + + // Test GetAssistants with tag filter (single tag) + resp, err = conv.GetAssistants(AssistantFilter{ + Tags: []string{"tag1"}, + }) + assert.Nil(t, err) + assert.Equal(t, 1, len(resp.P.Items)) + + // Test GetAssistants with tag filter (multiple tags) + resp, err = conv.GetAssistants(AssistantFilter{ + Tags: []string{"tag1", "tag4"}, + }) + assert.Nil(t, err) + assert.Equal(t, 1, len(resp.P.Items)) + + // Test GetAssistants with non-existent tag + resp, err = conv.GetAssistants(AssistantFilter{ + Tags: []string{"nonexistent"}, + }) + assert.Nil(t, err) + assert.Equal(t, 0, len(resp.P.Items)) + + // Test SaveAssistant (Update) + assistant["name"] = "Updated Assistant" + err = conv.SaveAssistant(assistant) + assert.Nil(t, err) + + resp, err = conv.GetAssistants(AssistantFilter{}) + assert.Nil(t, err) + assert.Equal(t, 1, len(resp.P.Items)) + item := resp.P.Items[0].(xun.R) + assert.Equal(t, "Updated Assistant", item["name"]) + + // Test DeleteAssistant + err = conv.DeleteAssistant(assistantID) + assert.Nil(t, err) + + resp, err = conv.GetAssistants(AssistantFilter{}) + assert.Nil(t, err) + assert.Equal(t, 0, len(resp.P.Items)) +} + +func TestXunAssistantPagination(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + defer capsule.Schema().DropTableIfExists("__unit_test_conversation_assistant") + + // Drop assistant table before test + err := capsule.Schema().DropTableIfExists("__unit_test_conversation_assistant") + if err != nil { + t.Fatal(err) + } + + conv, err := NewXun(Setting{ + Connector: "default", + Table: "__unit_test_conversation", + }) + if err != nil { + t.Fatal(err) + } + + // Create multiple assistants for pagination testing + for i := 0; i < 25; i++ { + tagsJSON, err := jsoniter.MarshalToString([]string{fmt.Sprintf("tag%d", i%5)}) + if err != nil { + t.Fatal(err) + } + + assistant := map[string]interface{}{ + "name": fmt.Sprintf("Assistant %d", i), + "type": "assistant", + "connector": "openai", + "description": fmt.Sprintf("Description %d", i), + "tags": tagsJSON, + } + err = conv.SaveAssistant(assistant) + assert.Nil(t, err) + } + + // Test first page + resp, err := conv.GetAssistants(AssistantFilter{ + Page: 1, + PageSize: 10, + }) + assert.Nil(t, err) + assert.Equal(t, 10, len(resp.P.Items)) + assert.Equal(t, 25, resp.P.Total) + assert.Equal(t, 3, resp.P.LastPage) + + // Test second page + resp, err = conv.GetAssistants(AssistantFilter{ + Page: 2, + PageSize: 10, + }) + assert.Nil(t, err) + assert.Equal(t, 10, len(resp.P.Items)) + + // Test last page + resp, err = conv.GetAssistants(AssistantFilter{ + Page: 3, + PageSize: 10, + }) + assert.Nil(t, err) + assert.Equal(t, 5, len(resp.P.Items)) + + // Test filtering with tags + resp, err = conv.GetAssistants(AssistantFilter{ + Tags: []string{"tag0"}, + Page: 1, + PageSize: 10, + }) + assert.Nil(t, err) + assert.Equal(t, 5, len(resp.P.Items)) +}