Merge pull request #812 from trheyi/main

Refactor Neo (60%)
This commit is contained in:
Max 2024-12-31 07:17:11 +08:00 committed by GitHub
commit d55bfef374
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2496 additions and 401 deletions

View file

@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/yaoapp/gou/api" "github.com/yaoapp/gou/api"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/process" "github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/helper" "github.com/yaoapp/yao/helper"
"github.com/yaoapp/yao/neo/conversation" "github.com/yaoapp/yao/neo/conversation"
@ -36,32 +37,120 @@ func (neo *DSL) API(router *gin.Engine, path string) error {
router.OPTIONS(path+"/upload", neo.optionsHandler) router.OPTIONS(path+"/upload", neo.optionsHandler)
router.OPTIONS(path+"/download", neo.optionsHandler) router.OPTIONS(path+"/download", neo.optionsHandler)
router.OPTIONS(path+"/mentions", neo.optionsHandler) router.OPTIONS(path+"/mentions", neo.optionsHandler)
router.OPTIONS(path+"/generate", neo.optionsHandler)
router.OPTIONS(path+"/generate/title", neo.optionsHandler)
router.OPTIONS(path+"/generate/prompts", neo.optionsHandler)
router.OPTIONS(path+"/dangerous/clear_chats", neo.optionsHandler) router.OPTIONS(path+"/dangerous/clear_chats", neo.optionsHandler)
router.OPTIONS(path+"/assistants", neo.optionsHandler)
router.OPTIONS(path+"/assistants/:id", neo.optionsHandler)
// Register endpoints with middlewares // Chat endpoint
// Example:
// curl -X GET 'http://localhost:5099/api/__yao/neo?content=Hello&chat_id=chat_123&context=previous_context&token=xxx'
// curl -X POST 'http://localhost:5099/api/__yao/neo' \
// -H 'Content-Type: application/json' \
// -d '{"content": "Hello", "chat_id": "chat_123", "context": "previous_context", "token": "xxx"}'
router.GET(path, append(middlewares, neo.handleChat)...) router.GET(path, append(middlewares, neo.handleChat)...)
router.POST(path, append(middlewares, neo.handleChat)...) router.POST(path, append(middlewares, neo.handleChat)...)
// Status check // Status check endpoint
// Example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/status?token=xxx'
router.GET(path+"/status", append(middlewares, neo.handleStatus)...) router.GET(path+"/status", append(middlewares, neo.handleStatus)...)
// Chat api // Assistant API endpoints
// List assistants example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/assistants?page=1&pagesize=20&tags=tag1,tag2&token=xxx'
router.GET(path+"/assistants", append(middlewares, neo.handleAssistantList)...)
// Get assistant details example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/assistants/assistant_123?token=xxx'
router.GET(path+"/assistants/:id", append(middlewares, neo.handleAssistantDetail)...)
// Create/Update assistant example:
// curl -X POST 'http://localhost:5099/api/__yao/neo/assistants' \
// -H 'Content-Type: application/json' \
// -d '{"name": "My Assistant", "type": "chat", "tags": ["tag1", "tag2"], "mentionable": true, "avatar": "path/to/avatar.png", "token": "xxx"}'
router.POST(path+"/assistants", append(middlewares, neo.handleAssistantSave)...)
// Delete assistant example:
// curl -X DELETE 'http://localhost:5099/api/__yao/neo/assistants/assistant_123?token=xxx'
router.DELETE(path+"/assistants/:id", append(middlewares, neo.handleAssistantDelete)...)
// Chat management endpoints
// List chats example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/chats?page=1&pagesize=20&keywords=search+term&order=desc&token=xxx'
router.GET(path+"/chats", append(middlewares, neo.handleChatList)...) router.GET(path+"/chats", append(middlewares, neo.handleChatList)...)
// Get chat details example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/chats/chat_123?token=xxx'
router.GET(path+"/chats/:id", append(middlewares, neo.handleChatDetail)...) router.GET(path+"/chats/:id", append(middlewares, neo.handleChatDetail)...)
// Update chat example:
// curl -X POST 'http://localhost:5099/api/__yao/neo/chats/chat_123' \
// -H 'Content-Type: application/json' \
// -d '{"title": "New Title", "content": "Chat content for title generation", "token": "xxx"}'
router.POST(path+"/chats/:id", append(middlewares, neo.handleChatUpdate)...) router.POST(path+"/chats/:id", append(middlewares, neo.handleChatUpdate)...)
// Delete chat example:
// curl -X DELETE 'http://localhost:5099/api/__yao/neo/chats/chat_123?token=xxx'
router.DELETE(path+"/chats/:id", append(middlewares, neo.handleChatDelete)...) router.DELETE(path+"/chats/:id", append(middlewares, neo.handleChatDelete)...)
// History api // Chat history endpoint
// Example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/history?chat_id=chat_123&token=xxx'
router.GET(path+"/history", append(middlewares, neo.handleChatHistory)...) router.GET(path+"/history", append(middlewares, neo.handleChatHistory)...)
// File api // File management endpoints
// Upload file example:
// curl -X POST 'http://localhost:5099/api/__yao/neo/upload?chat_id=chat_123&token=xxx' \
// -F 'file=@/path/to/file.txt'
router.POST(path+"/upload", append(middlewares, neo.handleUpload)...) router.POST(path+"/upload", append(middlewares, neo.handleUpload)...)
// Download file example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/download?file_id=file_123&disposition=attachment&token=xxx' \
// -o downloaded_file.txt
router.GET(path+"/download", append(middlewares, neo.handleDownload)...) router.GET(path+"/download", append(middlewares, neo.handleDownload)...)
// Mention api // Mentions endpoint
// Example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/mentions?keywords=assistant&token=xxx'
router.GET(path+"/mentions", append(middlewares, neo.handleMentions)...) router.GET(path+"/mentions", append(middlewares, neo.handleMentions)...)
// Generation endpoints
// Generate custom content example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/generate?content=Generate+something&type=custom&system_prompt=You+are+a+helpful+assistant&chat_id=chat_123&token=xxx'
// curl -X POST 'http://localhost:5099/api/__yao/neo/generate' \
// -H 'Content-Type: application/json' \
// -d '{"content": "Generate something", "type": "custom", "system_prompt": "You are a helpful assistant", "chat_id": "chat_123", "token": "xxx"}'
router.GET(path+"/generate", append(middlewares, neo.handleGenerateCustom)...)
router.POST(path+"/generate", append(middlewares, neo.handleGenerateCustom)...)
// Generate title example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/generate/title?content=Chat+content&chat_id=chat_123&token=xxx'
// curl -X POST 'http://localhost:5099/api/__yao/neo/generate/title' \
// -H 'Content-Type: application/json' \
// -d '{"content": "Chat content", "chat_id": "chat_123", "token": "xxx"}'
router.GET(path+"/generate/title", append(middlewares, neo.handleGenerateTitle)...)
router.POST(path+"/generate/title", append(middlewares, neo.handleGenerateTitle)...)
// Generate prompts example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/generate/prompts?content=Generate+prompts&chat_id=chat_123&token=xxx'
// curl -X POST 'http://localhost:5099/api/__yao/neo/generate/prompts' \
// -H 'Content-Type: application/json' \
// -d '{"content": "Generate prompts", "chat_id": "chat_123", "token": "xxx"}'
router.GET(path+"/generate/prompts", append(middlewares, neo.handleGeneratePrompts)...)
router.POST(path+"/generate/prompts", append(middlewares, neo.handleGeneratePrompts)...)
// Utility endpoints
// List connectors example:
// curl -X GET 'http://localhost:5099/api/__yao/neo/utility/connectors?token=xxx'
router.GET(path+"/utility/connectors", append(middlewares, neo.handleConnectors)...)
// Dangerous operations // Dangerous operations
// Dangerous operations
// Clear all chats example:
// curl -X DELETE 'http://localhost:5099/api/__yao/neo/dangerous/clear_chats?token=xxx'
router.DELETE(path+"/dangerous/clear_chats", append(middlewares, neo.handleChatsDeleteAll)...) router.DELETE(path+"/dangerous/clear_chats", append(middlewares, neo.handleChatsDeleteAll)...)
return nil return nil
@ -383,48 +472,34 @@ func (neo *DSL) handleMentions(c *gin.Context) {
// Get keywords from query parameter // Get keywords from query parameter
keywords := strings.ToLower(c.Query("keywords")) keywords := strings.ToLower(c.Query("keywords"))
mentions, err := neo.GetMentions(keywords) mentionable := true
// Query mentionable assistants
filter := conversation.AssistantFilter{
Keywords: keywords,
Mentionable: &mentionable,
Page: 1,
PageSize: 20,
}
response, err := neo.Conversation.GetAssistants(filter)
if err != nil { if err != nil {
c.JSON(500, gin.H{"message": err.Error(), "code": 500}) c.JSON(500, gin.H{"message": err.Error(), "code": 500})
c.Done() c.Done()
return return
} }
// Add test data // Convert assistants to mentions
testMentions := []Mention{ mentions := []Mention{}
{ for _, item := range response.Data {
ID: "assistant_1", mention := Mention{
Name: "Alice AI", ID: item["assistant_id"].(string),
Type: "assistant", Name: item["name"].(string),
Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Alice", Type: item["type"].(string),
}, Avatar: item["avatar"].(string),
{
ID: "assistant_2",
Name: "Bob Bot",
Type: "assistant",
Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bob",
},
{
ID: "assistant_3",
Name: "Carol AI",
Type: "assistant",
Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Carol",
},
} }
mentions = append(mentions, mention)
// Filter mentions by keywords
if keywords != "" {
filtered := []Mention{}
for _, m := range testMentions {
if strings.Contains(strings.ToLower(m.Name), keywords) {
filtered = append(filtered, m)
} }
}
testMentions = filtered
}
// Append test data to actual mentions
mentions = append(mentions, testMentions...)
c.JSON(200, map[string]interface{}{"data": mentions}) c.JSON(200, map[string]interface{}{"data": mentions})
c.Done() c.Done()
@ -462,7 +537,7 @@ func (neo *DSL) handleChatUpdate(c *gin.Context) {
ctx, cancel := NewContextWithCancel(sid, c.Query("chat_id"), "") ctx, cancel := NewContextWithCancel(sid, c.Query("chat_id"), "")
defer cancel() defer cancel()
title, err := neo.GenerateChatTitle(ctx, body.Content, c) title, err := neo.GenerateChatTitle(ctx, body.Content, c, true)
if err != nil { if err != nil {
c.JSON(500, gin.H{"message": err.Error(), "code": 500}) c.JSON(500, gin.H{"message": err.Error(), "code": 500})
c.Done() c.Done()
@ -534,3 +609,417 @@ func (neo *DSL) handleChatsDeleteAll(c *gin.Context) {
c.JSON(200, gin.H{"message": "ok"}) c.JSON(200, gin.H{"message": "ok"})
c.Done() c.Done()
} }
// generateResponse is a helper struct to handle both SSE and HTTP responses
type generateResponse struct {
c *gin.Context
sid string
content string
result interface{}
err error
}
// validate checks common validation rules
func (r *generateResponse) validate() bool {
if r.sid == "" {
if strings.Contains(r.c.GetHeader("Accept"), "text/event-stream") {
r.c.Header("Content-Type", "text/event-stream;charset=utf-8")
r.c.Header("Cache-Control", "no-cache")
r.c.Header("Connection", "keep-alive")
msg := message.New().
Error("sid is required").
Done()
msg.Write(r.c.Writer)
} else {
r.c.JSON(400, gin.H{"message": "sid is required", "code": 400})
}
return false
}
if r.content == "" {
if strings.Contains(r.c.GetHeader("Accept"), "text/event-stream") {
r.c.Header("Content-Type", "text/event-stream;charset=utf-8")
r.c.Header("Cache-Control", "no-cache")
r.c.Header("Connection", "keep-alive")
msg := message.New().
Error("content is required").
Done()
msg.Write(r.c.Writer)
} else {
r.c.JSON(400, gin.H{"message": "content is required", "code": 400})
}
return false
}
return true
}
// send handles both SSE and HTTP responses
func (r *generateResponse) send(key string) {
if r.err != nil {
if strings.Contains(r.c.GetHeader("Accept"), "text/event-stream") {
r.c.Header("Content-Type", "text/event-stream;charset=utf-8")
r.c.Header("Cache-Control", "no-cache")
r.c.Header("Connection", "keep-alive")
msg := message.New().
Error(r.err.Error()).
Done()
msg.Write(r.c.Writer)
} else {
r.c.JSON(500, gin.H{"message": r.err.Error(), "code": 500})
}
return
}
if strings.Contains(r.c.GetHeader("Accept"), "text/event-stream") {
r.c.Header("Content-Type", "text/event-stream;charset=utf-8")
r.c.Header("Cache-Control", "no-cache")
r.c.Header("Connection", "keep-alive")
msg := message.New().
Map(gin.H{key: r.result}).
Done()
msg.Write(r.c.Writer)
} else {
r.c.JSON(200, gin.H{key: r.result})
}
}
// handleGenerateTitle handles generating a chat title
func (neo *DSL) handleGenerateTitle(c *gin.Context) {
var content string
if c.Request.Method == "GET" {
content = c.Query("content")
} else {
var body struct {
Content string `json:"content"`
}
if err := c.BindJSON(&body); err != nil {
// For SSE requests, send error message in SSE format
if strings.Contains(c.GetHeader("Accept"), "text/event-stream") {
c.Header("Content-Type", "text/event-stream;charset=utf-8")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
msg := message.New().Error("invalid request body").Done()
msg.Write(c.Writer)
return
}
c.JSON(400, gin.H{"message": "invalid request body", "code": 400})
return
}
content = body.Content
}
resp := &generateResponse{
c: c,
sid: c.GetString("__sid"),
content: content,
}
// For SSE requests, set headers before validation
if strings.Contains(c.GetHeader("Accept"), "text/event-stream") {
c.Header("Content-Type", "text/event-stream;charset=utf-8")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
}
if !resp.validate() {
return
}
ctx, cancel := NewContextWithCancel(resp.sid, c.Query("chat_id"), "")
defer cancel()
// Use silent mode for regular HTTP requests, streaming for SSE
silent := !strings.Contains(c.GetHeader("Accept"), "text/event-stream")
resp.result, resp.err = neo.GenerateChatTitle(ctx, resp.content, c, silent)
resp.send("result")
}
// handleGeneratePrompts handles generating prompts
func (neo *DSL) handleGeneratePrompts(c *gin.Context) {
var content string
if c.Request.Method == "GET" {
content = c.Query("content")
} else {
var body struct {
Content string `json:"content"`
}
if err := c.BindJSON(&body); err != nil {
// For SSE requests, send error message in SSE format
if strings.Contains(c.GetHeader("Accept"), "text/event-stream") {
c.Header("Content-Type", "text/event-stream;charset=utf-8")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
msg := message.New().Error("invalid request body").Done()
msg.Write(c.Writer)
return
}
c.JSON(400, gin.H{"message": "invalid request body", "code": 400})
return
}
content = body.Content
}
resp := &generateResponse{
c: c,
sid: c.GetString("__sid"),
content: content,
}
// For SSE requests, set headers before validation
if strings.Contains(c.GetHeader("Accept"), "text/event-stream") {
c.Header("Content-Type", "text/event-stream;charset=utf-8")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
}
if !resp.validate() {
return
}
ctx, cancel := NewContextWithCancel(resp.sid, c.Query("chat_id"), "")
defer cancel()
// Use silent mode for regular HTTP requests, streaming for SSE
silent := !strings.Contains(c.GetHeader("Accept"), "text/event-stream")
resp.result, resp.err = neo.GeneratePrompts(ctx, resp.content, c, silent)
resp.send("result")
}
// handleGenerateCustom handles generating custom content
func (neo *DSL) handleGenerateCustom(c *gin.Context) {
var content, genType, systemPrompt string
if c.Request.Method == "GET" {
content = c.Query("content")
genType = c.Query("type")
systemPrompt = c.Query("system_prompt")
} else {
var body struct {
Content string `json:"content"`
Type string `json:"type"`
SystemPrompt string `json:"system_prompt"`
}
if err := c.BindJSON(&body); err != nil {
c.JSON(400, gin.H{"message": "invalid request body", "code": 400})
return
}
content = body.Content
genType = body.Type
systemPrompt = body.SystemPrompt
}
resp := &generateResponse{
c: c,
sid: c.GetString("__sid"),
content: content,
}
if !resp.validate() {
return
}
// Additional validations for custom generation
if genType == "" {
c.JSON(400, gin.H{"message": "type is required", "code": 400})
return
}
if systemPrompt == "" {
c.JSON(400, gin.H{"message": "system_prompt is required", "code": 400})
return
}
ctx, cancel := NewContextWithCancel(resp.sid, c.Query("chat_id"), "")
defer cancel()
// Use silent mode for regular HTTP requests, streaming for SSE
silent := !strings.Contains(c.GetHeader("Accept"), "text/event-stream")
resp.result, resp.err = neo.GenerateWithAI(ctx, resp.content, genType, systemPrompt, c, silent)
resp.send("result")
}
// handleAssistantList handles listing assistants
func (neo *DSL) handleAssistantList(c *gin.Context) {
// Parse filter parameters
filter := conversation.AssistantFilter{
Page: 1,
PageSize: 20,
}
// Parse page and pagesize
if page := c.Query("page"); page != "" {
if n, err := strconv.Atoi(page); err == nil {
filter.Page = n
}
}
if pageSize := c.Query("pagesize"); pageSize != "" {
if n, err := strconv.Atoi(pageSize); err == nil {
filter.PageSize = n
}
}
// Parse tags
if tags := c.Query("tags"); tags != "" {
filter.Tags = strings.Split(tags, ",")
}
// Parse keywords
if keywords := c.Query("keywords"); keywords != "" {
filter.Keywords = keywords
}
// Parse connector
if connector := c.Query("connector"); connector != "" {
filter.Connector = connector
}
// Parse select fields
if selectFields := c.Query("select"); selectFields != "" {
filter.Select = strings.Split(selectFields, ",")
}
// Parse mentionable (support various boolean formats)
if mentionable := c.Query("mentionable"); mentionable != "" {
val := parseBoolValue(mentionable)
if val != nil {
filter.Mentionable = val
}
}
// Parse automated (support various boolean formats)
if automated := c.Query("automated"); automated != "" {
val := parseBoolValue(automated)
if val != nil {
filter.Automated = val
}
}
response, err := neo.Conversation.GetAssistants(filter)
if err != nil {
c.JSON(500, gin.H{"message": err.Error(), "code": 500})
c.Done()
return
}
c.JSON(200, response)
c.Done()
}
// parseBoolValue parses various string formats into a boolean pointer
// Supports: 1, 0, "1", "0", "true", "false", etc.
func parseBoolValue(value string) *bool {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "1", "true", "yes", "on":
v := true
return &v
case "0", "false", "no", "off":
v := false
return &v
default:
return nil
}
}
// handleAssistantDetail handles getting a single assistant's details
func (neo *DSL) handleAssistantDetail(c *gin.Context) {
assistantID := c.Param("id")
if assistantID == "" {
c.JSON(400, gin.H{"message": "assistant id is required", "code": 400})
c.Done()
return
}
filter := conversation.AssistantFilter{
AssistantID: assistantID,
Page: 1,
PageSize: 1,
}
response, err := neo.Conversation.GetAssistants(filter)
if err != nil {
c.JSON(500, gin.H{"message": err.Error(), "code": 500})
c.Done()
return
}
if len(response.Data) == 0 {
c.JSON(404, gin.H{"message": "assistant not found", "code": 404})
c.Done()
return
}
c.JSON(200, map[string]interface{}{"data": response.Data[0]})
c.Done()
}
// handleAssistantSave handles creating or updating an assistant
func (neo *DSL) handleAssistantSave(c *gin.Context) {
var assistant map[string]interface{}
if err := c.BindJSON(&assistant); err != nil {
c.JSON(400, gin.H{"message": "invalid request body", "code": 400})
c.Done()
return
}
id, err := neo.Conversation.SaveAssistant(assistant)
if err != nil {
c.JSON(500, gin.H{"message": err.Error(), "code": 500})
c.Done()
return
}
// Update the assistant map with the returned ID if it's not already set
if _, ok := assistant["assistant_id"]; !ok {
assistant["assistant_id"] = id
}
c.JSON(200, gin.H{"message": "ok", "data": assistant})
c.Done()
}
// handleAssistantDelete handles deleting an assistant
func (neo *DSL) handleAssistantDelete(c *gin.Context) {
assistantID := c.Param("id")
if assistantID == "" {
c.JSON(400, gin.H{"message": "assistant id is required", "code": 400})
c.Done()
return
}
err := neo.Conversation.DeleteAssistant(assistantID)
if err != nil {
c.JSON(500, gin.H{"message": err.Error(), "code": 500})
c.Done()
return
}
c.JSON(200, gin.H{"message": "ok"})
c.Done()
}
// handleConnectors handles listing connectors
func (neo *DSL) handleConnectors(c *gin.Context) {
options := []map[string]interface{}{}
// Filter and format connectors
for id, conn := range connector.Connectors {
if conn.Is(connector.OPENAI) || conn.Is(connector.MOAPI) {
setting := conn.Setting()
label := setting["label"]
if label == nil || label == "" {
label = setting["name"]
}
if label == nil || label == "" {
label = id
}
options = append(options, map[string]interface{}{
"label": label,
"value": id,
})
}
}
c.JSON(200, gin.H{"data": options})
c.Done()
}

View file

@ -1,4 +1,4 @@
package base package local
import ( import (
"context" "context"
@ -6,7 +6,7 @@ import (
) )
// Chat the chat // Chat the chat
func (ast *Base) Chat(ctx context.Context, messages []map[string]interface{}, option map[string]interface{}, cb func(data []byte) int) error { func (ast *Local) Chat(ctx context.Context, messages []map[string]interface{}, option map[string]interface{}, cb func(data []byte) int) error {
if ast.openai == nil { if ast.openai == nil {
return fmt.Errorf("api is not initialized") return fmt.Errorf("api is not initialized")

View file

@ -1,4 +1,4 @@
package base package local
import ( import (
"context" "context"
@ -31,7 +31,7 @@ var AllowedFileTypes = map[string]string{
var MaxSize int64 = 20 * 1024 * 1024 var MaxSize int64 = 20 * 1024 * 1024
// Upload the file // Upload the file
func (ast *Base) Upload(ctx context.Context, file *multipart.FileHeader, reader io.Reader, option map[string]interface{}) (*assistant.File, error) { func (ast *Local) Upload(ctx context.Context, file *multipart.FileHeader, reader io.Reader, option map[string]interface{}) (*assistant.File, error) {
// check file size // check file size
if file.Size > MaxSize { if file.Size > MaxSize {
@ -69,13 +69,13 @@ func (ast *Base) Upload(ctx context.Context, file *multipart.FileHeader, reader
}, nil }, nil
} }
func (ast *Base) id(temp string, ext string) (string, error) { func (ast *Local) id(temp string, ext string) (string, error) {
date := time.Now().Format("20060102") date := time.Now().Format("20060102")
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(temp)))[:8] hash := fmt.Sprintf("%x", sha256.Sum256([]byte(temp)))[:8]
return fmt.Sprintf("/__assistants/%s/%s/%s%s", ast.ID, date, hash, ext), nil return fmt.Sprintf("/__assistants/%s/%s/%s%s", ast.ID, date, hash, ext), nil
} }
func (ast *Base) allowed(contentType string) bool { func (ast *Local) allowed(contentType string) bool {
if _, ok := AllowedFileTypes[contentType]; ok { if _, ok := AllowedFileTypes[contentType]; ok {
return true return true
} }
@ -87,7 +87,7 @@ func (ast *Base) allowed(contentType string) bool {
} }
// Download downloads a file // Download downloads a file
func (ast *Base) Download(ctx context.Context, fileID string) (*assistant.FileResponse, error) { func (ast *Local) Download(ctx context.Context, fileID string) (*assistant.FileResponse, error) {
// Get the data filesystem // Get the data filesystem
data, err := fs.Get("data") data, err := fs.Get("data")

View file

@ -1,4 +1,4 @@
package base package local
import ( import (
"context" "context"
@ -8,16 +8,16 @@ import (
"github.com/yaoapp/yao/openai" "github.com/yaoapp/yao/openai"
) )
// Base the base assistant // Local the local assistant
type Base struct { type Local struct {
ID string `json:"assistant_id"` ID string `json:"assistant_id"`
Prompts []assistant.Prompt `json:"prompts,omitempty"` Prompts []assistant.Prompt `json:"prompts,omitempty"`
Connector connector.Connector `json:"-" yaml:"-"` Connector connector.Connector `json:"-" yaml:"-"`
openai *openai.OpenAI openai *openai.OpenAI
} }
// New create a new base assistant // New create a new local assistant
func New(connector connector.Connector, prompts []assistant.Prompt, id string) (*Base, error) { func New(connector connector.Connector, prompts []assistant.Prompt, id string) (*Local, error) {
setting := connector.Setting() setting := connector.Setting()
api, err := openai.NewOpenAI(setting) api, err := openai.NewOpenAI(setting)
@ -25,10 +25,10 @@ func New(connector connector.Connector, prompts []assistant.Prompt, id string) (
return nil, err return nil, err
} }
return &Base{Connector: connector, ID: id, Prompts: prompts, openai: api}, nil return &Local{Connector: connector, ID: id, Prompts: prompts, openai: api}, nil
} }
// List list all assistants // List list all assistants
func (ast *Base) List(ctx context.Context, param assistant.QueryParam) ([]assistant.Assistant, error) { func (ast *Local) List(ctx context.Context, param assistant.QueryParam) ([]assistant.Assistant, error) {
return nil, nil return nil, nil
} }

View file

@ -31,11 +31,14 @@ type QueryParam struct {
// Assistant the assistant // Assistant the assistant
type Assistant struct { type Assistant struct {
ID string `json:"assistant_id"` // Assistant ID ID string `json:"assistant_id"` // Assistant ID
Type string `json:"type,omitempty"` // Assistant Type, default is assistant
Name string `json:"name,omitempty"` // Assistant Name Name string `json:"name,omitempty"` // Assistant Name
Avatar string `json:"avatar,omitempty"` // Assistant Avatar
Connector string `json:"connector"` // AI Connector Connector string `json:"connector"` // AI Connector
Description string `json:"description,omitempty"` // Assistant Description Description string `json:"description,omitempty"` // Assistant Description
Option map[string]interface{} `json:"option,omitempty"` // AI Option Option map[string]interface{} `json:"option,omitempty"` // AI Option
Prompts []Prompt `json:"prompts,omitempty"` // AI Prompts Prompts []Prompt `json:"prompts,omitempty"` // AI Prompts
Flows []map[string]interface{} `json:"flows,omitempty"` // Assistant Flows
API API `json:"-" yaml:"-"` // Assistant API API API `json:"-" yaml:"-"` // Assistant API
} }

View file

@ -1,60 +1,59 @@
package conversation package conversation
// Mongo conversation // Mongo represents a MongoDB-based conversation storage
type Mongo struct{} type Mongo struct{}
// NewMongo create a new conversation // NewMongo creates a new MongoDB conversation storage
func NewMongo() *Mongo { func NewMongo() *Mongo {
return &Mongo{} return &Mongo{}
} }
// UpdateChatTitle update the chat title // GetChats retrieves a list of chats
func (conv *Mongo) UpdateChatTitle(sid string, cid string, title string) error { func (m *Mongo) GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error) {
return nil return &ChatGroupResponse{}, nil
} }
// GetChats get the chat list // GetChat retrieves a single chat's information
func (conv *Mongo) GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error) { func (m *Mongo) GetChat(sid string, cid string) (*ChatInfo, error) {
return &ChatGroupResponse{ return &ChatInfo{}, nil
Groups: []ChatGroup{},
Page: filter.Page,
PageSize: filter.PageSize,
Total: 0,
LastPage: 1,
}, nil
} }
// GetHistory get the history // GetHistory retrieves chat history
func (conv *Mongo) GetHistory(sid string, cid string) ([]map[string]interface{}, error) { func (m *Mongo) GetHistory(sid string, cid string) ([]map[string]interface{}, error) {
return []map[string]interface{}{}, nil return []map[string]interface{}{}, nil
} }
// SaveHistory save the history // SaveHistory saves chat history
func (conv *Mongo) SaveHistory(sid string, messages []map[string]interface{}, cid string) error { func (m *Mongo) SaveHistory(sid string, messages []map[string]interface{}, cid string, context map[string]interface{}) error {
return nil return nil
} }
// GetRequest get the request // DeleteChat deletes a single chat
func (conv *Mongo) GetRequest(sid string, rid string) ([]map[string]interface{}, error) { func (m *Mongo) DeleteChat(sid string, cid string) error {
return nil, nil
}
// SaveRequest save the request
func (conv *Mongo) SaveRequest(sid string, rid string, cid string, messages []map[string]interface{}) error {
return nil return nil
} }
// GetChat get the chat info and its history // DeleteAllChats deletes all chats
func (conv *Mongo) GetChat(sid string, cid string) (*ChatInfo, error) { func (m *Mongo) DeleteAllChats(sid string) error {
return nil, nil
}
// DeleteChat deletes a specific chat and its history
func (conv *Mongo) DeleteChat(sid string, cid string) error {
return nil return nil
} }
// DeleteAllChats deletes all chats and their histories for a user // UpdateChatTitle updates chat title
func (conv *Mongo) DeleteAllChats(sid string) error { func (m *Mongo) UpdateChatTitle(sid string, cid string, title string) error {
return nil return nil
} }
// SaveAssistant saves assistant information
func (m *Mongo) SaveAssistant(assistant map[string]interface{}) (interface{}, error) {
return assistant["assistant_id"], nil
}
// DeleteAssistant deletes an assistant
func (m *Mongo) DeleteAssistant(assistantID string) error {
return nil
}
// GetAssistants retrieves a list of assistants
func (m *Mongo) GetAssistants(filter AssistantFilter) (*AssistantResponse, error) {
return &AssistantResponse{}, nil
}

View file

@ -1,60 +1,59 @@
package conversation package conversation
// Redis conversation // Redis represents a Redis-based conversation storage
type Redis struct{} type Redis struct{}
// NewRedis create a new conversation // NewRedis creates a new Redis conversation storage
func NewRedis() *Redis { func NewRedis() *Redis {
return &Redis{} return &Redis{}
} }
// UpdateChatTitle update the chat title // GetChats retrieves a list of chats
func (conv *Redis) UpdateChatTitle(sid string, cid string, title string) error { func (r *Redis) GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error) {
return nil return &ChatGroupResponse{}, nil
} }
// GetChats get the chat list // GetChat retrieves a single chat's information
func (conv *Redis) GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error) { func (r *Redis) GetChat(sid string, cid string) (*ChatInfo, error) {
return &ChatGroupResponse{ return &ChatInfo{}, nil
Groups: []ChatGroup{},
Page: filter.Page,
PageSize: filter.PageSize,
Total: 0,
LastPage: 1,
}, nil
} }
// GetHistory get the history // GetHistory retrieves chat history
func (conv *Redis) GetHistory(sid string, cid string) ([]map[string]interface{}, error) { func (r *Redis) GetHistory(sid string, cid string) ([]map[string]interface{}, error) {
return []map[string]interface{}{}, nil return []map[string]interface{}{}, nil
} }
// SaveHistory save the history // SaveHistory saves chat history
func (conv *Redis) SaveHistory(sid string, messages []map[string]interface{}, cid string) error { func (r *Redis) SaveHistory(sid string, messages []map[string]interface{}, cid string, context map[string]interface{}) error {
return nil return nil
} }
// GetRequest get the request // DeleteChat deletes a single chat
func (conv *Redis) GetRequest(sid string, rid string) ([]map[string]interface{}, error) { func (r *Redis) DeleteChat(sid string, cid string) error {
return nil, nil
}
// SaveRequest save the request
func (conv *Redis) SaveRequest(sid string, rid string, cid string, messages []map[string]interface{}) error {
return nil return nil
} }
// GetChat get the chat info and its history // DeleteAllChats deletes all chats
func (conv *Redis) GetChat(sid string, cid string) (*ChatInfo, error) { func (r *Redis) DeleteAllChats(sid string) error {
return nil, nil
}
// DeleteChat deletes a specific chat and its history
func (conv *Redis) DeleteChat(sid string, cid string) error {
return nil return nil
} }
// DeleteAllChats deletes all chats and their histories for a user // UpdateChatTitle updates chat title
func (conv *Redis) DeleteAllChats(sid string) error { func (r *Redis) UpdateChatTitle(sid string, cid string, title string) error {
return nil return nil
} }
// SaveAssistant saves assistant information
func (r *Redis) SaveAssistant(assistant map[string]interface{}) (interface{}, error) {
return assistant["assistant_id"], nil
}
// DeleteAssistant deletes an assistant
func (r *Redis) DeleteAssistant(assistantID string) error {
return nil
}
// GetAssistants retrieves a list of assistants
func (r *Redis) GetAssistants(filter AssistantFilter) (*AssistantResponse, error) {
return &AssistantResponse{}, nil
}

View file

@ -1,52 +1,133 @@
package conversation package conversation
// Setting the conversation config // Setting represents the conversation configuration structure
// Used to configure basic conversation parameters including connector, user field, table name, etc.
type Setting struct { type Setting struct {
Connector string `json:"connector,omitempty"` Connector string `json:"connector,omitempty"` // Name of the connector used to specify data storage method
UserField string `json:"user_field,omitempty"` // the user id field name, default is user_id UserField string `json:"user_field,omitempty"` // User ID field name, defaults to "user_id"
Table string `json:"table,omitempty"` Table string `json:"table,omitempty"` // Database table name
MaxSize int `json:"max_size,omitempty" yaml:"max_size,omitempty"` MaxSize int `json:"max_size,omitempty" yaml:"max_size,omitempty"` // Maximum storage size limit
TTL int `json:"ttl,omitempty" yaml:"ttl,omitempty"` TTL int `json:"ttl,omitempty" yaml:"ttl,omitempty"` // Time To Live in seconds
} }
// ChatInfo represents the chat information and its history // ChatInfo represents the chat information structure
// Contains basic information and history for a single chat
type ChatInfo struct { type ChatInfo struct {
Chat map[string]interface{} `json:"chat"` Chat map[string]interface{} `json:"chat"` // Basic chat information
History []map[string]interface{} `json:"history"` History []map[string]interface{} `json:"history"` // Chat history records
} }
// ChatFilter represents the filter parameters for GetChats // ChatFilter represents the chat filter structure
// Used for filtering and pagination when retrieving chat lists
type ChatFilter struct { type ChatFilter struct {
Keywords string `json:"keywords,omitempty"` Keywords string `json:"keywords,omitempty"` // Keyword search
Page int `json:"page,omitempty"` // 页码从1开始 Page int `json:"page,omitempty"` // Page number, starting from 1
PageSize int `json:"pagesize,omitempty"` // 每页数量 PageSize int `json:"pagesize,omitempty"` // Number of items per page
Order string `json:"order,omitempty"` // desc/asc Order string `json:"order,omitempty"` // Sort order: desc/asc
} }
// ChatGroup represents a group of chats by date // ChatGroup represents the chat group structure
// Groups chats by date
type ChatGroup struct { type ChatGroup struct {
Label string `json:"label"` Label string `json:"label"` // Group label (typically a date)
Chats []map[string]interface{} `json:"chats"` Chats []map[string]interface{} `json:"chats"` // List of chats in this group
} }
// ChatGroupResponse represents paginated chat groups // ChatGroupResponse represents the paginated chat group response
// Contains paginated chat group information
type ChatGroupResponse struct { type ChatGroupResponse struct {
Groups []ChatGroup `json:"groups"` Groups []ChatGroup `json:"groups"` // List of chat groups
Page int `json:"page"` // 当前页码 Page int `json:"page"` // Current page number
PageSize int `json:"pagesize"` // 每页数量 PageSize int `json:"pagesize"` // Items per page
Total int64 `json:"total"` // 总记录数 Total int64 `json:"total"` // Total number of records
LastPage int `json:"last_page"` // 最后一页页码 LastPage int `json:"last_page"` // Last page number
} }
// Conversation the store interface // AssistantFilter represents the assistant filter structure
type Conversation interface { // Used for filtering and pagination when retrieving assistant lists
GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error) type AssistantFilter struct {
GetChat(sid string, cid string) (*ChatInfo, error) Tags []string `json:"tags,omitempty"` // Filter by tags
GetHistory(sid string, cid string) ([]map[string]interface{}, error) Keywords string `json:"keywords,omitempty"` // Search in name and description
SaveHistory(sid string, messages []map[string]interface{}, cid string) error Connector string `json:"connector,omitempty"` // Filter by connector
GetRequest(sid string, rid string) ([]map[string]interface{}, error) AssistantID string `json:"assistant_id,omitempty"` // Filter by assistant ID
SaveRequest(sid string, rid string, cid string, messages []map[string]interface{}) error Mentionable *bool `json:"mentionable,omitempty"` // Filter by mentionable status
DeleteChat(sid string, cid string) error Automated *bool `json:"automated,omitempty"` // Filter by automation status
DeleteAllChats(sid string) error Page int `json:"page,omitempty"` // Page number, starting from 1
UpdateChatTitle(sid string, cid string, title string) error PageSize int `json:"pagesize,omitempty"` // Items per page
Select []string `json:"select,omitempty"` // Fields to return, returns all fields if empty
}
// AssistantResponse represents the assistant response structure
// Used for returning paginated assistant lists
type AssistantResponse struct {
Data []map[string]interface{} `json:"data"` // The paginated data
Page int `json:"page"` // Current page number
PageSize int `json:"pagesize"` // Number of items per page
PageCnt int `json:"pagecnt"` // Total number of pages
Next int `json:"next"` // Next page number
Prev int `json:"prev"` // Previous page number
Total int64 `json:"total"` // Total number of items
}
// Conversation defines the conversation storage interface
// Provides basic operations required for conversation management
type Conversation interface {
// GetChats retrieves a list of chats
// sid: Session ID
// filter: Filter conditions
// Returns: Grouped chat list and potential error
GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error)
// GetChat retrieves a single chat's information
// sid: Session ID
// cid: Chat ID
// Returns: Chat information and potential error
GetChat(sid string, cid string) (*ChatInfo, error)
// GetHistory retrieves chat history
// sid: Session ID
// cid: Chat ID
// Returns: History record list and potential error
GetHistory(sid string, cid string) ([]map[string]interface{}, error)
// SaveHistory saves chat history
// sid: Session ID
// messages: Message list
// cid: Chat ID
// context: Context information
// Returns: Potential error
SaveHistory(sid string, messages []map[string]interface{}, cid string, context map[string]interface{}) error
// DeleteChat deletes a single chat
// sid: Session ID
// cid: Chat ID
// Returns: Potential error
DeleteChat(sid string, cid string) error
// DeleteAllChats deletes all chats
// sid: Session ID
// Returns: Potential error
DeleteAllChats(sid string) error
// UpdateChatTitle updates chat title
// sid: Session ID
// cid: Chat ID
// title: New title
// Returns: Potential error
UpdateChatTitle(sid string, cid string, title string) error
// SaveAssistant saves assistant information
// assistant: Assistant information
// Returns: Potential error
SaveAssistant(assistant map[string]interface{}) (interface{}, error)
// DeleteAssistant deletes an assistant
// assistantID: Assistant ID
// Returns: Potential error
DeleteAssistant(assistantID string) error
// GetAssistants retrieves a list of assistants
// filter: Filter conditions
// Returns: Paginated assistant list and potential error
GetAssistants(filter AssistantFilter) (*AssistantResponse, error)
} }

View file

@ -1,60 +1,59 @@
package conversation package conversation
// Weaviate Database conversation // Weaviate represents a Weaviate-based conversation storage
type Weaviate struct{} type Weaviate struct{}
// NewWeaviate create a new conversation // NewWeaviate creates a new Weaviate conversation storage
func NewWeaviate() *Weaviate { func NewWeaviate() *Weaviate {
return &Weaviate{} return &Weaviate{}
} }
// UpdateChatTitle update the chat title // GetChats retrieves a list of chats
func (conv *Weaviate) UpdateChatTitle(sid string, cid string, title string) error { func (w *Weaviate) GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error) {
return nil return &ChatGroupResponse{}, nil
} }
// GetChats get the chat list // GetChat retrieves a single chat's information
func (conv *Weaviate) GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error) { func (w *Weaviate) GetChat(sid string, cid string) (*ChatInfo, error) {
return &ChatGroupResponse{ return &ChatInfo{}, nil
Groups: []ChatGroup{},
Page: filter.Page,
PageSize: filter.PageSize,
Total: 0,
LastPage: 1,
}, nil
} }
// GetHistory get the history // GetHistory retrieves chat history
func (conv *Weaviate) GetHistory(sid string, cid string) ([]map[string]interface{}, error) { func (w *Weaviate) GetHistory(sid string, cid string) ([]map[string]interface{}, error) {
return []map[string]interface{}{}, nil return []map[string]interface{}{}, nil
} }
// SaveHistory save the history // SaveHistory saves chat history
func (conv *Weaviate) SaveHistory(sid string, messages []map[string]interface{}, cid string) error { func (w *Weaviate) SaveHistory(sid string, messages []map[string]interface{}, cid string, context map[string]interface{}) error {
return nil return nil
} }
// GetRequest get the request // DeleteChat deletes a single chat
func (conv *Weaviate) GetRequest(sid string, rid string) ([]map[string]interface{}, error) { func (w *Weaviate) DeleteChat(sid string, cid string) error {
return nil, nil
}
// SaveRequest save the request
func (conv *Weaviate) SaveRequest(sid string, rid string, cid string, messages []map[string]interface{}) error {
return nil return nil
} }
// GetChat get the chat info and its history // DeleteAllChats deletes all chats
func (conv *Weaviate) GetChat(sid string, cid string) (*ChatInfo, error) { func (w *Weaviate) DeleteAllChats(sid string) error {
return nil, nil
}
// DeleteChat deletes a specific chat and its history
func (conv *Weaviate) DeleteChat(sid string, cid string) error {
return nil return nil
} }
// DeleteAllChats deletes all chats and their histories for a user // UpdateChatTitle updates chat title
func (conv *Weaviate) DeleteAllChats(sid string) error { func (w *Weaviate) UpdateChatTitle(sid string, cid string, title string) error {
return nil return nil
} }
// SaveAssistant saves assistant information
func (w *Weaviate) SaveAssistant(assistant map[string]interface{}) (interface{}, error) {
return assistant["assistant_id"], nil
}
// DeleteAssistant deletes an assistant
func (w *Weaviate) DeleteAssistant(assistantID string) error {
return nil
}
// GetAssistants retrieves a list of assistants
func (w *Weaviate) GetAssistants(filter AssistantFilter) (*AssistantResponse, error) {
return &AssistantResponse{}, nil
}

View file

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/session" "github.com/yaoapp/gou/session"
"github.com/yaoapp/kun/log" "github.com/yaoapp/kun/log"
@ -15,32 +16,34 @@ import (
"github.com/yaoapp/xun/dbal/schema" "github.com/yaoapp/xun/dbal/schema"
) )
// Xun Database conversation // Package conversation provides functionality for managing chat conversations and assistants.
// Xun implements the Conversation interface using a database backend.
// It provides functionality for:
// - Managing chat conversations and their message histories
// - Organizing chats with pagination and date-based grouping
// - Handling chat metadata like titles and creation dates
// - Managing AI assistants with their configurations and metadata
// - Supporting data expiration through TTL settings
type Xun struct { type Xun struct {
query query.Query query query.Query
schema schema.Schema schema schema.Schema
setting Setting setting Setting
} }
type row struct { // Public interface methods:
Role string `json:"role"` //
Name string `json:"name"` // User name // NewXun creates a new conversation instance with the given settings
Content string `json:"content"` // UpdateChatTitle updates the title of a specific chat
Sid string `json:"sid"` // GetChats retrieves a paginated list of chats grouped by date
Rid string `json:"rid"` // GetChat retrieves a specific chat and its message history
Cid string `json:"cid"` // Chat ID from chat history // GetHistory retrieves the message history for a specific chat
ExpiredAt interface{} `json:"expired_at"` // SaveHistory saves new messages to a chat's history
} // DeleteChat deletes a specific chat and its history
// DeleteAllChats deletes all chats and their histories for a user
// Public interface methods and constructor remain exported: // SaveAssistant creates or updates an assistant
// - NewXun // DeleteAssistant deletes an assistant by assistant_id
// - UpdateChatTitle // GetAssistants retrieves a paginated list of assistants with filtering
// - GetChats
// - GetChat
// - GetHistory
// - SaveHistory
// - GetRequest
// - SaveRequest
// NewXun create a new conversation // NewXun create a new conversation
func NewXun(setting Setting) (*Xun, error) { func NewXun(setting Setting) (*Xun, error) {
@ -111,6 +114,11 @@ func (conv *Xun) initialize() error {
return err return err
} }
// Initialize assistant table
if err := conv.initAssistantTable(); err != nil {
return err
}
return nil return nil
} }
@ -126,11 +134,12 @@ func (conv *Xun) initHistoryTable() error {
err = conv.schema.CreateTable(historyTable, func(table schema.Blueprint) { err = conv.schema.CreateTable(historyTable, func(table schema.Blueprint) {
table.ID("id") table.ID("id")
table.String("sid", 255).Index() table.String("sid", 255).Index()
table.String("rid", 255).Null().Index()
table.String("cid", 200).Null().Index() table.String("cid", 200).Null().Index()
table.String("uid", 255).Null().Index()
table.String("role", 200).Null().Index() table.String("role", 200).Null().Index()
table.String("name", 200).Null().Index() table.String("name", 200).Null().Index()
table.Text("content").Null() table.Text("content").Null()
table.JSON("context").Null()
table.TimestampTz("created_at").SetDefaultRaw("NOW()").Index() table.TimestampTz("created_at").SetDefaultRaw("NOW()").Index()
table.TimestampTz("updated_at").Null().Index() table.TimestampTz("updated_at").Null().Index()
table.TimestampTz("expired_at").Null().Index() table.TimestampTz("expired_at").Null().Index()
@ -148,7 +157,7 @@ func (conv *Xun) initHistoryTable() error {
return err return err
} }
fields := []string{"id", "sid", "rid", "cid", "role", "name", "content", "created_at", "updated_at", "expired_at"} fields := []string{"id", "sid", "cid", "uid", "role", "name", "content", "context", "created_at", "updated_at", "expired_at"}
for _, field := range fields { for _, field := range fields {
if !tab.HasColumn(field) { if !tab.HasColumn(field) {
return fmt.Errorf("%s is required", field) return fmt.Errorf("%s is required", field)
@ -198,6 +207,59 @@ func (conv *Xun) initChatTable() error {
return nil return nil
} }
func (conv *Xun) initAssistantTable() error {
assistantTable := conv.getAssistantTable()
has, err := conv.schema.HasTable(assistantTable)
if err != nil {
return err
}
// Create the assistant table
if !has {
err = conv.schema.CreateTable(assistantTable, func(table schema.Blueprint) {
table.ID("id")
table.String("assistant_id", 200).Unique().Index()
table.String("type", 200).SetDefault("assistant").Index() // default is assistant
table.String("name", 200).Null() // assistant name
table.String("avatar", 200).Null() // assistant avatar
table.String("connector", 200).NotNull() // assistant connector
table.Text("description").Null() // assistant description
table.JSON("options").Null() // assistant options
table.JSON("prompts").Null() // assistant prompts
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
table.Boolean("mentionable").SetDefault(true).Index() // Whether this assistant can appear in @ mention list
table.TimestampTz("created_at").SetDefaultRaw("NOW()").Index()
table.TimestampTz("updated_at").Null().Index()
})
if err != nil {
return err
}
log.Trace("Create the assistant table: %s", assistantTable)
}
// Validate the table
tab, err := conv.schema.GetTable(assistantTable)
if err != nil {
return err
}
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)
}
}
return nil
}
func (conv *Xun) getUserID(sid string) (string, error) { func (conv *Xun) getUserID(sid string) (string, error) {
field := "user_id" field := "user_id"
if conv.setting.UserField != "" { if conv.setting.UserField != "" {
@ -217,13 +279,17 @@ func (conv *Xun) getUserID(sid string) (string, error) {
} }
func (conv *Xun) getHistoryTable() string { func (conv *Xun) getHistoryTable() string {
return conv.setting.Table return conv.setting.Table + "_history"
} }
func (conv *Xun) getChatTable() string { func (conv *Xun) getChatTable() string {
return conv.setting.Table + "_chat" return conv.setting.Table + "_chat"
} }
func (conv *Xun) getAssistantTable() string {
return conv.setting.Table + "_assistant"
}
// UpdateChatTitle update the chat title // UpdateChatTitle update the chat title
func (conv *Xun) UpdateChatTitle(sid string, cid string, title string) error { func (conv *Xun) UpdateChatTitle(sid string, cid string, title string) error {
userID, err := conv.getUserID(sid) userID, err := conv.getUserID(sid)
@ -380,7 +446,7 @@ func (conv *Xun) GetHistory(sid string, cid string) ([]map[string]interface{}, e
} }
qb := conv.newQuery(). qb := conv.newQuery().
Select("role", "name", "content"). Select("role", "name", "content", "context", "uid", "created_at", "updated_at").
Where("sid", userID). Where("sid", userID).
Where("cid", cid). Where("cid", cid).
OrderBy("id", "desc") OrderBy("id", "desc")
@ -401,18 +467,23 @@ func (conv *Xun) GetHistory(sid string, cid string) ([]map[string]interface{}, e
res := []map[string]interface{}{} res := []map[string]interface{}{}
for _, row := range rows { for _, row := range rows {
res = append([]map[string]interface{}{{ message := map[string]interface{}{
"role": row.Get("role"), "role": row.Get("role"),
"name": row.Get("name"), "name": row.Get("name"),
"content": row.Get("content"), "content": row.Get("content"),
}}, res...) "context": row.Get("context"),
"uid": row.Get("uid"),
"created_at": row.Get("created_at"),
"updated_at": row.Get("updated_at"),
}
res = append([]map[string]interface{}{message}, res...)
} }
return res, nil return res, nil
} }
// SaveHistory save the history // SaveHistory save the history
func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid string) error { func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid string, context map[string]interface{}) error {
if cid == "" { if cid == "" {
cid = uuid.New().String() // Generate a new UUID if cid is empty cid = uuid.New().String() // Generate a new UUID if cid is empty
@ -450,24 +521,49 @@ func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid
// Save message history // Save message history
defer conv.clean() defer conv.clean()
var expiredAt interface{} = nil var expiredAt interface{} = nil
values := []row{} values := []map[string]interface{}{}
if conv.setting.TTL > 0 { if conv.setting.TTL > 0 {
expiredAt = time.Now().Add(time.Duration(conv.setting.TTL) * time.Second) expiredAt = time.Now().Add(time.Duration(conv.setting.TTL) * time.Second)
} }
now := time.Now()
for _, message := range messages { for _, message := range messages {
value := row{ // Type assertion safety checks
Role: message["role"].(string), role, ok := message["role"].(string)
Name: "", if !ok {
Content: message["content"].(string), return fmt.Errorf("invalid role type in message: %v", message["role"])
Sid: userID,
Cid: cid,
ExpiredAt: expiredAt,
} }
if message["name"] != nil { content, ok := message["content"].(string)
value.Name = message["name"].(string) if !ok {
return fmt.Errorf("invalid content type in message: %v", message["content"])
} }
var contextRaw interface{} = nil
if context != nil {
contextRaw, err = jsoniter.MarshalToString(context)
if err != nil {
return err
}
}
value := map[string]interface{}{
"role": role,
"name": "",
"content": content,
"sid": userID,
"cid": cid,
"uid": userID,
"context": contextRaw,
"created_at": now,
"updated_at": nil,
"expired_at": expiredAt,
}
if name, ok := message["name"].(string); ok {
value["name"] = name
}
values = append(values, value) values = append(values, value)
} }
@ -479,79 +575,6 @@ func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid
return nil return nil
} }
// GetRequest get the request history
func (conv *Xun) GetRequest(sid string, rid string) ([]map[string]interface{}, error) {
userID, err := conv.getUserID(sid)
if err != nil {
return nil, err
}
qb := conv.newQuery().
Select("role", "name", "content", "sid").
Where("rid", rid).
Where("sid", userID).
OrderBy("id", "desc")
if conv.setting.TTL > 0 {
qb.Where("expired_at", ">", time.Now())
}
limit := 20
if conv.setting.MaxSize > 0 {
limit = conv.setting.MaxSize
}
rows, err := qb.Limit(limit).Get()
if err != nil {
return nil, err
}
res := []map[string]interface{}{}
for _, row := range rows {
res = append([]map[string]interface{}{{
"role": row.Get("role"),
"name": row.Get("name"),
"content": row.Get("content"),
}}, res...)
}
return res, nil
}
// SaveRequest save the request history
func (conv *Xun) SaveRequest(sid string, rid string, cid string, messages []map[string]interface{}) error {
userID, err := conv.getUserID(sid)
if err != nil {
return err
}
defer conv.clean()
var expiredAt interface{} = nil
values := []row{}
if conv.setting.TTL > 0 {
expiredAt = time.Now().Add(time.Duration(conv.setting.TTL) * time.Second)
}
for _, message := range messages {
value := row{
Role: message["role"].(string),
Name: "",
Content: message["content"].(string),
Sid: userID,
Cid: cid,
Rid: rid,
ExpiredAt: expiredAt,
}
if message["name"] != nil {
value.Name = message["name"].(string)
}
values = append(values, value)
}
return conv.newQuery().Insert(values)
}
// GetChat get the chat info and its history // GetChat get the chat info and its history
func (conv *Xun) GetChat(sid string, cid string) (*ChatInfo, error) { func (conv *Xun) GetChat(sid string, cid string) (*ChatInfo, error) {
userID, err := conv.getUserID(sid) userID, err := conv.getUserID(sid)
@ -638,3 +661,265 @@ func (conv *Xun) DeleteAllChats(sid string) error {
Delete() Delete()
return err return err
} }
// processJSONField processes a field that should be stored as JSON string
func (conv *Xun) processJSONField(field interface{}) (interface{}, error) {
if field == nil {
return nil, nil
}
switch v := field.(type) {
case string:
return v, nil
default:
jsonStr, err := jsoniter.MarshalToString(v)
if err != nil {
return nil, fmt.Errorf("failed to marshal %v to JSON: %v", field, err)
}
return jsonStr, nil
}
}
// parseJSONFields parses JSON string fields into their corresponding Go types
func (conv *Xun) parseJSONFields(data map[string]interface{}, fields []string) {
for _, field := range fields {
if val := data[field]; val != nil {
if strVal, ok := val.(string); ok && strVal != "" {
var parsed interface{}
if err := jsoniter.UnmarshalFromString(strVal, &parsed); err == nil {
data[field] = parsed
}
}
}
}
}
// SaveAssistant saves assistant information
func (conv *Xun) SaveAssistant(assistant map[string]interface{}) (interface{}, error) {
// Validate required fields
requiredFields := []string{"name", "type", "connector"}
for _, field := range requiredFields {
if _, ok := assistant[field]; !ok {
return nil, fmt.Errorf("field %s is required", field)
}
if assistant[field] == nil || assistant[field] == "" {
return nil, fmt.Errorf("field %s cannot be empty", field)
}
}
// Create a copy of the assistant map to avoid modifying the original
assistantCopy := make(map[string]interface{})
for k, v := range assistant {
assistantCopy[k] = v
}
// Process JSON fields
jsonFields := []string{"tags", "options", "prompts", "flows", "files", "functions", "permissions"}
for _, field := range jsonFields {
if val, ok := assistantCopy[field]; ok && val != nil {
// If it's a string, try to parse it first
if strVal, ok := val.(string); ok && strVal != "" {
var parsed interface{}
if err := jsoniter.UnmarshalFromString(strVal, &parsed); err == nil {
assistantCopy[field] = parsed
}
}
}
}
// Generate assistant_id if not provided
if _, ok := assistantCopy["assistant_id"]; !ok {
assistantCopy["assistant_id"] = uuid.New().String()
}
// Check if assistant exists
exists, err := conv.query.New().
Table(conv.getAssistantTable()).
Where("assistant_id", assistantCopy["assistant_id"]).
Exists()
if err != nil {
return nil, err
}
// Convert JSON fields to strings for storage
for _, field := range jsonFields {
if val, ok := assistantCopy[field]; ok && val != nil {
jsonStr, err := jsoniter.MarshalToString(val)
if err != nil {
return nil, fmt.Errorf("failed to marshal %s to JSON: %v", field, err)
}
assistantCopy[field] = jsonStr
}
}
// Update or insert
if exists {
_, err := conv.query.New().
Table(conv.getAssistantTable()).
Where("assistant_id", assistantCopy["assistant_id"]).
Update(assistantCopy)
if err != nil {
return nil, err
}
return assistantCopy["assistant_id"], nil
}
err = conv.query.New().
Table(conv.getAssistantTable()).
Insert(assistantCopy)
if err != nil {
return nil, err
}
return assistantCopy["assistant_id"], nil
}
// DeleteAssistant deletes an assistant by assistant_id
func (conv *Xun) DeleteAssistant(assistantID string) error {
// Check if assistant exists
exists, err := conv.query.New().
Table(conv.getAssistantTable()).
Where("assistant_id", assistantID).
Exists()
if err != nil {
return err
}
if !exists {
return fmt.Errorf("assistant %s not found", assistantID)
}
_, err = conv.query.New().
Table(conv.getAssistantTable()).
Where("assistant_id", assistantID).
Delete()
return err
}
// GetAssistants retrieves assistants with pagination and 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 {
qb.Where(func(qb query.Query) {
for i, tag := range filter.Tags {
// For each tag, we need to match it as part of a JSON array
// This will match both single tag arrays ["tag1"] and multi-tag arrays ["tag1","tag2"]
pattern := fmt.Sprintf("%%\"%s\"%%", tag)
if i == 0 {
qb.Where("tags", "like", pattern)
} else {
qb.OrWhere("tags", "like", pattern)
}
}
})
}
// Apply keyword filter if provided
if filter.Keywords != "" {
qb.Where(func(qb query.Query) {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Keywords)).
OrWhere("description", "like", fmt.Sprintf("%%%s%%", filter.Keywords))
})
}
// Apply connector filter if provided
if filter.Connector != "" {
qb.Where("connector", filter.Connector)
}
// Apply assistant_id filter if provided
if filter.AssistantID != "" {
qb.Where("assistant_id", filter.AssistantID)
}
// Apply mentionable filter if provided
if filter.Mentionable != nil {
qb.Where("mentionable", *filter.Mentionable)
}
// Apply automated filter if provided
if filter.Automated != nil {
qb.Where("automated", *filter.Automated)
}
// Set defaults for pagination
if filter.PageSize <= 0 {
filter.PageSize = 20
}
if filter.Page <= 0 {
filter.Page = 1
}
// Get total count
total, err := qb.Clone().Count()
if err != nil {
return nil, err
}
// Calculate pagination
offset := (filter.Page - 1) * filter.PageSize
totalPages := int(math.Ceil(float64(total) / float64(filter.PageSize)))
nextPage := filter.Page + 1
if nextPage > totalPages {
nextPage = 0
}
prevPage := filter.Page - 1
if prevPage < 1 {
prevPage = 0
}
// Apply select fields if provided
if filter.Select != nil && len(filter.Select) > 0 {
selectFields := make([]interface{}, len(filter.Select))
for i, field := range filter.Select {
selectFields[i] = field
}
qb.Select(selectFields...)
}
// Get paginated results
rows, err := qb.OrderBy("created_at", "desc").
Offset(offset).
Limit(filter.PageSize).
Get()
if err != nil {
return nil, err
}
// Convert rows to map slice and parse JSON fields
data := make([]map[string]interface{}, len(rows))
jsonFields := []string{"tags", "options", "prompts", "flows", "files", "functions", "permissions"}
for i, row := range rows {
data[i] = row
// Only parse JSON fields if they are selected or no select filter is provided
if filter.Select == nil || len(filter.Select) == 0 {
conv.parseJSONFields(data[i], jsonFields)
} else {
// Parse only selected JSON fields
selectedJSONFields := []string{}
for _, field := range jsonFields {
for _, selected := range filter.Select {
if selected == field {
selectedJSONFields = append(selectedJSONFields, field)
break
}
}
}
if len(selectedJSONFields) > 0 {
conv.parseJSONFields(data[i], selectedJSONFields)
}
}
}
return &AssistantResponse{
Data: data,
Page: filter.Page,
PageSize: filter.PageSize,
PageCnt: totalPages,
Next: nextPage,
Prev: prevPage,
Total: total,
}, nil
}

View file

@ -5,6 +5,7 @@ import (
"testing" "testing"
"time" "time"
jsoniter "github.com/json-iterator/go"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
"github.com/yaoapp/xun/capsule" "github.com/yaoapp/xun/capsule"
@ -15,13 +16,28 @@ import (
func TestNewXunDefault(t *testing.T) { func TestNewXunDefault(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
defer test.Clean() 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 { if err != nil {
t.Fatal(err) 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)
}
// Add a small delay to ensure table is created
time.Sleep(100 * time.Millisecond)
conv, err := NewXun(Setting{ conv, err := NewXun(Setting{
Connector: "default", Connector: "default",
Table: "__unit_test_conversation", Table: "__unit_test_conversation",
@ -32,35 +48,59 @@ func TestNewXunDefault(t *testing.T) {
return return
} }
has, err := capsule.Schema().HasTable("__unit_test_conversation") // Check history table
has, err := capsule.Schema().HasTable("__unit_test_conversation_history")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, true, has) assert.Equal(t, true, has)
// validate the table // Check chat table
tab, err := conv.schema.GetTable(conv.setting.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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
fields := []string{"id", "sid", "cid", "rid", "role", "name", "content", "created_at", "updated_at", "expired_at"} fields := []string{"id", "sid", "cid", "uid", "role", "name", "content", "context", "created_at", "updated_at", "expired_at"}
for _, field := range fields { for _, field := range fields {
assert.Equal(t, true, tab.HasColumn(field)) assert.Equal(t, true, tab.HasColumn(field))
} }
conv, err = NewXun(Setting{ // validate the chat table
Connector: "default", tab, err = conv.schema.GetTable(conv.getChatTable())
Table: "__unit_test_conversation",
})
has, err = capsule.Schema().HasTable("__unit_test_conversation")
if err != nil { if err != nil {
t.Fatal(err) 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) { func TestNewXunConnector(t *testing.T) {
@ -77,9 +117,17 @@ func TestNewXunConnector(t *testing.T) {
t.Fatal(err) 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")
// Add a small delay to ensure table is created
time.Sleep(100 * time.Millisecond)
sch.DropTableIfExists("__unit_test_conversation")
conv, err := NewXun(Setting{ conv, err := NewXun(Setting{
Connector: "mysql", Connector: "mysql",
Table: "__unit_test_conversation", Table: "__unit_test_conversation",
@ -90,44 +138,73 @@ func TestNewXunConnector(t *testing.T) {
return return
} }
has, err := sch.HasTable("__unit_test_conversation") // Check history table
has, err := sch.HasTable("__unit_test_conversation_history")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, true, has) assert.Equal(t, true, has)
// validate the table // Check chat table
tab, err := conv.schema.GetTable(conv.setting.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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
fields := []string{"id", "sid", "cid", "rid", "role", "name", "content", "created_at", "updated_at", "expired_at"} fields := []string{"id", "sid", "cid", "uid", "role", "name", "content", "context", "created_at", "updated_at", "expired_at"}
for _, field := range fields { for _, field := range fields {
assert.Equal(t, true, tab.HasColumn(field)) assert.Equal(t, true, tab.HasColumn(field))
} }
conv, err = NewXun(Setting{ // validate the chat table
Connector: "default", tab, err = conv.schema.GetTable(conv.getChatTable())
Table: "__unit_test_conversation",
})
has, err = sch.HasTable("__unit_test_conversation")
if err != nil { if err != nil {
t.Fatal(err) 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) { func TestXunSaveAndGetHistory(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
defer test.Clean() 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -143,7 +220,7 @@ func TestXunSaveAndGetHistory(t *testing.T) {
err = conv.SaveHistory("123456", []map[string]interface{}{ err = conv.SaveHistory("123456", []map[string]interface{}{
{"role": "user", "name": "user1", "content": "hello"}, {"role": "user", "name": "user1", "content": "hello"},
{"role": "assistant", "name": "user1", "content": "Hello there, how"}, {"role": "assistant", "name": "user1", "content": "Hello there, how"},
}, cid) }, cid, nil)
assert.Nil(t, err) assert.Nil(t, err)
// get the history // get the history
@ -154,44 +231,18 @@ func TestXunSaveAndGetHistory(t *testing.T) {
assert.Equal(t, 2, len(data)) assert.Equal(t, 2, len(data))
} }
func TestXunSaveAndGetRequest(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
defer capsule.Schema().DropTableIfExists("__unit_test_conversation")
err := capsule.Schema().DropTableIfExists("__unit_test_conversation")
if err != nil {
t.Fatal(err)
}
conv, err := NewXun(Setting{
Connector: "default",
Table: "__unit_test_conversation",
TTL: 3600,
})
// save the history
err = conv.SaveRequest("123456", "912836", "test.command", []map[string]interface{}{
{"role": "user", "name": "user1", "content": "hello"},
{"role": "assistant", "name": "user1", "content": "Hello there, how"},
})
assert.Nil(t, err)
// get the history
data, err := conv.GetRequest("123456", "912836")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 2, len(data))
}
func TestXunSaveAndGetHistoryWithCID(t *testing.T) { func TestXunSaveAndGetHistoryWithCID(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
defer test.Clean() 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -209,7 +260,7 @@ func TestXunSaveAndGetHistoryWithCID(t *testing.T) {
{"role": "user", "name": "user1", "content": "hello"}, {"role": "user", "name": "user1", "content": "hello"},
{"role": "assistant", "name": "assistant1", "content": "Hi! How can I help you?"}, {"role": "assistant", "name": "assistant1", "content": "Hi! How can I help you?"},
} }
err = conv.SaveHistory(sid, messages, cid) err = conv.SaveHistory(sid, messages, cid, nil)
assert.Nil(t, err) assert.Nil(t, err)
// get the history for specific cid // get the history for specific cid
@ -224,7 +275,7 @@ func TestXunSaveAndGetHistoryWithCID(t *testing.T) {
moreMessages := []map[string]interface{}{ moreMessages := []map[string]interface{}{
{"role": "user", "name": "user1", "content": "another message"}, {"role": "user", "name": "user1", "content": "another message"},
} }
err = conv.SaveHistory(sid, moreMessages, anotherCID) err = conv.SaveHistory(sid, moreMessages, anotherCID, nil)
assert.Nil(t, err) assert.Nil(t, err)
// get history for the first cid - should still be 2 messages // get history for the first cid - should still be 2 messages
@ -252,11 +303,11 @@ func TestXunSaveAndGetHistoryWithCID(t *testing.T) {
func TestXunGetChats(t *testing.T) { func TestXunGetChats(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
defer test.Clean() 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_chat")
// Drop both tables before test // Drop both tables before test
err := capsule.Schema().DropTableIfExists("__unit_test_conversation") err := capsule.Schema().DropTableIfExists("__unit_test_conversation_history")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -294,7 +345,7 @@ func TestXunGetChats(t *testing.T) {
} }
// Then save the history // Then save the history
err = conv.SaveHistory(sid, messages, chatID) err = conv.SaveHistory(sid, messages, chatID, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -325,7 +376,7 @@ func TestXunGetChats(t *testing.T) {
func TestXunDeleteChat(t *testing.T) { func TestXunDeleteChat(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
defer test.Clean() 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_chat")
conv, err := NewXun(Setting{ conv, err := NewXun(Setting{
@ -344,7 +395,7 @@ func TestXunDeleteChat(t *testing.T) {
} }
// Save the chat and history // Save the chat and history
err = conv.SaveHistory(sid, messages, cid) err = conv.SaveHistory(sid, messages, cid, nil)
assert.Nil(t, err) assert.Nil(t, err)
// Verify chat exists // Verify chat exists
@ -365,7 +416,7 @@ func TestXunDeleteChat(t *testing.T) {
func TestXunDeleteAllChats(t *testing.T) { func TestXunDeleteAllChats(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
defer test.Clean() 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_chat")
conv, err := NewXun(Setting{ conv, err := NewXun(Setting{
@ -385,7 +436,7 @@ func TestXunDeleteAllChats(t *testing.T) {
// Save multiple chats // Save multiple chats
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
cid := fmt.Sprintf("test_chat_%d", i) cid := fmt.Sprintf("test_chat_%d", i)
err = conv.SaveHistory(sid, messages, cid) err = conv.SaveHistory(sid, messages, cid, nil)
assert.Nil(t, err) assert.Nil(t, err)
} }
@ -403,3 +454,423 @@ func TestXunDeleteAllChats(t *testing.T) {
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, int64(0), response.Total) 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)
}
// Add a small delay to ensure table is created
time.Sleep(100 * time.Millisecond)
conv, err := NewXun(Setting{
Connector: "default",
Table: "__unit_test_conversation",
})
if err != nil {
t.Fatal(err)
}
// Test creating a new assistant with different JSON field formats
// Test case 1: JSON fields as strings
tagsJSON := `["tag1", "tag2", "tag3"]`
optionsJSON := `{"model": "gpt-4"}`
assistant := map[string]interface{}{
"name": "Test Assistant",
"type": "assistant",
"avatar": "https://example.com/avatar.png",
"connector": "openai",
"description": "Test Description",
"tags": tagsJSON,
"options": optionsJSON,
"mentionable": true,
"automated": true,
}
// Test SaveAssistant (Create) with string JSON
v, err := conv.SaveAssistant(assistant)
assert.Nil(t, err)
assistantID := v.(string)
assert.NotEmpty(t, assistantID)
// Test case 2: JSON fields as native types
assistant2 := map[string]interface{}{
"name": "Test Assistant 2",
"type": "assistant",
"avatar": "https://example.com/avatar2.png",
"connector": "openai",
"description": "Test Description 2",
"tags": []string{"tag1", "tag2", "tag3"},
"options": map[string]interface{}{"model": "gpt-4"},
"prompts": []string{"prompt1", "prompt2"},
"flows": []string{"flow1", "flow2"},
"files": []string{"file1", "file2"},
"functions": []map[string]interface{}{{"name": "func1"}, {"name": "func2"}},
"permissions": map[string]interface{}{"read": true, "write": true},
"mentionable": true,
"automated": true,
}
// Test SaveAssistant (Create) with native types
v, err = conv.SaveAssistant(assistant2)
assert.Nil(t, err)
assistant2ID := v.(string)
assert.NotEmpty(t, assistant2ID)
// Test case 3: Test with nil JSON fields
assistant3 := map[string]interface{}{
"name": "Test Assistant 3",
"type": "assistant",
"connector": "openai",
"description": "Test Description 3",
"tags": nil,
"options": nil,
"prompts": nil,
"flows": nil,
"files": nil,
"functions": nil,
"permissions": nil,
"mentionable": true,
"automated": true,
}
// Test SaveAssistant (Create) with nil fields
v, err = conv.SaveAssistant(assistant3)
assert.Nil(t, err)
assistant3ID := v.(string)
assert.NotEmpty(t, assistant3ID)
// Test GetAssistants to verify JSON fields are properly stored
resp, err := conv.GetAssistants(AssistantFilter{})
assert.Nil(t, err)
assert.Equal(t, 3, len(resp.Data))
// Verify first assistant (string JSON)
found := false
for _, item := range resp.Data {
if item["assistant_id"].(string) == assistantID {
found = true
// Now we expect parsed JSON values instead of JSON strings
assert.Equal(t, []interface{}{"tag1", "tag2", "tag3"}, item["tags"])
assert.Equal(t, map[string]interface{}{"model": "gpt-4"}, item["options"])
break
}
}
assert.True(t, found)
// Verify second assistant (native types converted to JSON)
found = false
for _, item := range resp.Data {
if item["assistant_id"].(string) == assistant2ID {
found = true
// Now we expect parsed JSON values directly
assert.Equal(t, []interface{}{"tag1", "tag2", "tag3"}, item["tags"])
assert.Equal(t, map[string]interface{}{"model": "gpt-4"}, item["options"])
// Verify other JSON fields
assert.Equal(t, []interface{}{"prompt1", "prompt2"}, item["prompts"])
assert.Equal(t, []interface{}{"flow1", "flow2"}, item["flows"])
assert.Equal(t, []interface{}{"file1", "file2"}, item["files"])
assert.Equal(t,
[]interface{}{
map[string]interface{}{"name": "func1"},
map[string]interface{}{"name": "func2"},
},
item["functions"])
assert.Equal(t,
map[string]interface{}{
"read": true,
"write": true,
},
item["permissions"])
break
}
}
assert.True(t, found)
// Verify third assistant (nil fields)
found = false
for _, item := range resp.Data {
if item["assistant_id"].(string) == assistant3ID {
found = true
assert.Nil(t, item["tags"])
assert.Nil(t, item["options"])
assert.Nil(t, item["prompts"])
assert.Nil(t, item["flows"])
assert.Nil(t, item["files"])
assert.Nil(t, item["functions"])
assert.Nil(t, item["permissions"])
break
}
}
assert.True(t, found)
// Test updating with mixed JSON formats
assistant2["assistant_id"] = assistant2ID
_, err = conv.SaveAssistant(assistant2)
assert.Nil(t, err)
// Verify update
resp, err = conv.GetAssistants(AssistantFilter{})
assert.Nil(t, err)
for _, item := range resp.Data {
if item["assistant_id"].(string) == assistant2ID {
// Now we expect parsed JSON values
assert.Equal(t, []interface{}{"tag1", "tag2", "tag3"}, item["tags"])
assert.Equal(t, map[string]interface{}{"model": "gpt-4"}, item["options"])
break
}
}
// Test DeleteAssistant
err = conv.DeleteAssistant(assistantID)
assert.Nil(t, err)
err = conv.DeleteAssistant(assistant2ID)
assert.Nil(t, err)
err = conv.DeleteAssistant(assistant3ID)
assert.Nil(t, err)
resp, err = conv.GetAssistants(AssistantFilter{})
assert.Nil(t, err)
assert.Equal(t, 0, len(resp.Data))
}
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_chat")
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)
}
// Add a small delay to ensure table is created
time.Sleep(100 * time.Millisecond)
conv, err := NewXun(Setting{
Connector: "default",
Table: "__unit_test_conversation",
})
if err != nil {
t.Fatal(err)
}
// Create multiple assistants for pagination testing
mentionable := true
automated := true
for i := 0; i < 25; i++ {
tagsJSON, err := jsoniter.MarshalToString([]string{fmt.Sprintf("tag%d", i%5)})
if err != nil {
t.Fatal(err)
}
// Alternate mentionable and automated flags
if i%2 == 0 {
mentionable = !mentionable
}
if i%3 == 0 {
automated = !automated
}
assistant := map[string]interface{}{
"name": fmt.Sprintf("Assistant %d", i),
"type": "assistant",
"connector": fmt.Sprintf("connector%d", i%3),
"description": fmt.Sprintf("Description %d", i),
"tags": tagsJSON,
"mentionable": mentionable,
"automated": automated,
}
_, 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.Data))
assert.Equal(t, int64(25), resp.Total)
assert.Equal(t, 3, resp.PageCnt)
assert.Equal(t, 2, resp.Next)
assert.Equal(t, 0, resp.Prev)
// Test second page
resp, err = conv.GetAssistants(AssistantFilter{
Page: 2,
PageSize: 10,
})
assert.Nil(t, err)
assert.Equal(t, 10, len(resp.Data))
assert.Equal(t, 3, resp.Next)
assert.Equal(t, 1, resp.Prev)
// Test last page
resp, err = conv.GetAssistants(AssistantFilter{
Page: 3,
PageSize: 10,
})
assert.Nil(t, err)
assert.Equal(t, 5, len(resp.Data))
assert.Equal(t, 0, resp.Next)
assert.Equal(t, 2, resp.Prev)
// 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.Data))
// Test filtering with keywords
resp, err = conv.GetAssistants(AssistantFilter{
Keywords: "Assistant 1",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering with connector
resp, err = conv.GetAssistants(AssistantFilter{
Connector: "connector0",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering with mentionable
mentionableTrue := true
resp, err = conv.GetAssistants(AssistantFilter{
Mentionable: &mentionableTrue,
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering with automated
automatedTrue := true
resp, err = conv.GetAssistants(AssistantFilter{
Automated: &automatedTrue,
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by assistant_id
// First get an assistant_id from previous results
firstAssistantID := resp.Data[0]["assistant_id"].(string)
// Test exact match with assistant_id
resp, err = conv.GetAssistants(AssistantFilter{
AssistantID: firstAssistantID,
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Equal(t, 1, len(resp.Data))
assert.Equal(t, firstAssistantID, resp.Data[0]["assistant_id"])
// Test assistant_id with other filters
resp, err = conv.GetAssistants(AssistantFilter{
AssistantID: firstAssistantID,
Select: []string{"name", "assistant_id", "description"},
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Equal(t, 1, len(resp.Data))
assert.Equal(t, firstAssistantID, resp.Data[0]["assistant_id"])
// Verify only selected fields are returned
assert.Contains(t, resp.Data[0], "name")
assert.Contains(t, resp.Data[0], "assistant_id")
assert.Contains(t, resp.Data[0], "description")
assert.NotContains(t, resp.Data[0], "tags")
assert.NotContains(t, resp.Data[0], "options")
// Test non-existent assistant_id
resp, err = conv.GetAssistants(AssistantFilter{
AssistantID: "non-existent-id",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Equal(t, 0, len(resp.Data))
// Test combined filters
resp, err = conv.GetAssistants(AssistantFilter{
Tags: []string{"tag0"},
Keywords: "Assistant",
Connector: "connector0",
Mentionable: &mentionableTrue,
Automated: &automatedTrue,
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
// Test filtering with select fields
resp, err = conv.GetAssistants(AssistantFilter{
Select: []string{"name", "description", "tags"},
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Equal(t, 10, len(resp.Data))
// Verify only selected fields are returned
for _, item := range resp.Data {
// These fields should exist
assert.Contains(t, item, "name")
assert.Contains(t, item, "description")
assert.Contains(t, item, "tags")
// These fields should not exist
assert.NotContains(t, item, "options")
assert.NotContains(t, item, "prompts")
assert.NotContains(t, item, "flows")
assert.NotContains(t, item, "files")
assert.NotContains(t, item, "functions")
assert.NotContains(t, item, "permissions")
}
// Test filtering with select fields and other filters combined
resp, err = conv.GetAssistants(AssistantFilter{
Tags: []string{"tag0"},
Keywords: "Assistant",
Select: []string{"name", "tags"},
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
// Verify only selected fields are returned
for _, item := range resp.Data {
// These fields should exist
assert.Contains(t, item, "name")
assert.Contains(t, item, "tags")
// These fields should not exist
assert.NotContains(t, item, "description")
assert.NotContains(t, item, "options")
assert.NotContains(t, item, "prompts")
assert.NotContains(t, item, "flows")
assert.NotContains(t, item, "files")
assert.NotContains(t, item, "functions")
assert.NotContains(t, item, "permissions")
}
}

View file

@ -11,7 +11,7 @@ import (
"github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/connector"
"github.com/yaoapp/kun/log" "github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/neo/assistant" "github.com/yaoapp/yao/neo/assistant"
"github.com/yaoapp/yao/neo/assistant/base" "github.com/yaoapp/yao/neo/assistant/local"
"github.com/yaoapp/yao/neo/assistant/openai" "github.com/yaoapp/yao/neo/assistant/openai"
"github.com/yaoapp/yao/neo/conversation" "github.com/yaoapp/yao/neo/conversation"
"github.com/yaoapp/yao/neo/message" "github.com/yaoapp/yao/neo/message"
@ -58,9 +58,26 @@ func (neo *DSL) GetMentions(keywords string) ([]Mention, error) {
return neo.HookMention(context.Background(), keywords) return neo.HookMention(context.Background(), keywords)
} }
// GenerateChatTitle generate the chat title // GeneratePrompts generate prompts for the AI assistant
func (neo *DSL) GenerateChatTitle(ctx Context, input string, c *gin.Context) (string, error) { func (neo *DSL) GeneratePrompts(ctx Context, input string, c *gin.Context, silent ...bool) (string, error) {
prompts := `
Optimize the prompts for the AI assistant
1. Optimize prompts based on the user's input
2. The prompts should be clear and specific
3. The prompts should be in the same language as the input
4. Keep the prompts concise but comprehensive
5. DO NOT ASK USER FOR MORE INFORMATION, JUST GENERATE PROMPTS
6. DO NOT ANSWER THE QUESTION, JUST GENERATE PROMPTS
`
isSilent := false
if len(silent) > 0 {
isSilent = silent[0]
}
return neo.GenerateWithAI(ctx, input, "prompts", prompts, c, isSilent)
}
// GenerateChatTitle generate the chat title
func (neo *DSL) GenerateChatTitle(ctx Context, input string, c *gin.Context, silent ...bool) (string, error) {
prompts := ` prompts := `
Help me generate a title for the chat Help me generate a title for the chat
1. The title should be a short and concise description of the chat. 1. The title should be a short and concise description of the chat.
@ -68,10 +85,23 @@ func (neo *DSL) GenerateChatTitle(ctx Context, input string, c *gin.Context) (st
3. The title should be in same language as the chat. 3. The title should be in same language as the chat.
4. The title should be no more than 50 characters. 4. The title should be no more than 50 characters.
` `
isSilent := false
if len(silent) > 0 {
isSilent = silent[0]
}
return neo.GenerateWithAI(ctx, input, "title", prompts, c, isSilent)
}
// GenerateWithAI generate content with AI, type can be "title", "prompts", etc.
func (neo *DSL) GenerateWithAI(ctx Context, input string, messageType string, systemPrompt string, c *gin.Context, silent bool) (string, error) {
messages := []map[string]interface{}{ messages := []map[string]interface{}{
{"role": "system", "content": prompts}, {"role": "system", "content": systemPrompt},
{"role": "user", "content": input}, {
"role": "user",
"content": input,
"type": messageType,
"name": ctx.Sid,
},
} }
res, err := neo.HookCreate(ctx, messages, c) res, err := neo.HookCreate(ctx, messages, c)
@ -119,8 +149,21 @@ func (neo *DSL) GenerateChatTitle(ctx Context, input string, c *gin.Context) (st
// Append content and send message // Append content and send message
content = msg.Append(content) content = msg.Append(content)
// Only send real-time messages if not in silent mode
if !silent && msg.Message != nil && msg.Message.Text != "" {
message.New().
Map(map[string]interface{}{
"text": msg.Message.Text,
"done": msg.Message.Done,
}).
Write(c.Writer)
}
// Complete the stream // Complete the stream
if msg.Message.Done { if msg.Message.Done {
if !silent && msg.Message.Text == "" {
msg.Write(c.Writer)
}
done <- true done <- true
return 0 // break return 0 // break
} }
@ -131,8 +174,10 @@ func (neo *DSL) GenerateChatTitle(ctx Context, input string, c *gin.Context) (st
if err != nil { if err != nil {
log.Error("Chat error: %s", err.Error()) log.Error("Chat error: %s", err.Error())
if !silent {
message.New().Error(err).Done().Write(c.Writer) message.New().Error(err).Done().Write(c.Writer)
} }
}
done <- true done <- true
}() }()
@ -372,9 +417,9 @@ func (neo *DSL) newAssistantByConnector(id string) (assistant.API, error) {
} }
// Base on the assistant list hook // Base on the assistant list hook
api, err := base.New(conn, neo.Prompts, id) api, err := local.New(conn, neo.Prompts, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("Create base assistant error: %s", err.Error()) return nil, fmt.Errorf("Create local assistant error: %s", err.Error())
} }
return api, nil return api, nil
} }
@ -453,6 +498,7 @@ func (neo *DSL) saveHistory(sid string, chatID string, content []byte, messages
{"role": "assistant", "content": string(content), "name": sid}, {"role": "assistant", "content": string(content), "name": sid},
}, },
chatID, chatID,
nil,
) )
if err != nil { if err != nil {

View file

@ -1,15 +1,32 @@
package neo package neo
import ( import (
"fmt"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/gou/process" "github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/exception" "github.com/yaoapp/kun/exception"
"github.com/yaoapp/yao/neo/conversation"
"github.com/yaoapp/yao/neo/message" "github.com/yaoapp/yao/neo/message"
) )
// GetNeo returns the Neo instance
func GetNeo() *DSL {
if Neo == nil {
exception.New("Neo is not initialized", 500).Throw()
}
return Neo
}
func init() { func init() {
process.RegisterGroup("neo", map[string]process.Handler{ process.RegisterGroup("neo", map[string]process.Handler{
"write": ProcessWrite, "write": ProcessWrite,
"assistant.create": processAssistantCreate,
"assistant.save": processAssistantSave,
"assistant.delete": processAssistantDelete,
"assistant.search": processAssistantSearch,
"assistant.find": processAssistantFind,
}) })
} }
@ -39,3 +56,151 @@ func ProcessWrite(process *process.Process) interface{} {
return nil return nil
} }
// processAssistantCreate process the assistant create request
func processAssistantCreate(process *process.Process) interface{} {
process.ValidateArgNums(1)
data := process.ArgsMap(0)
neo := GetNeo()
if neo.Conversation == nil {
exception.New("Neo conversation is not initialized", 500).Throw()
}
id, err := neo.Conversation.SaveAssistant(data)
if err != nil {
exception.New("Failed to create assistant: %s", 500, err.Error()).Throw()
}
return id
}
// processAssistantSave process the assistant save request
func processAssistantSave(process *process.Process) interface{} {
process.ValidateArgNums(1)
data := process.ArgsMap(0)
neo := GetNeo()
if neo.Conversation == nil {
exception.New("Neo conversation is not initialized", 500).Throw()
}
id, err := neo.Conversation.SaveAssistant(data)
if err != nil {
exception.New("Failed to save assistant: %s", 500, err.Error()).Throw()
}
return id
}
// processAssistantDelete process the assistant delete request
func processAssistantDelete(process *process.Process) interface{} {
process.ValidateArgNums(1)
assistantID := process.ArgsString(0)
neo := GetNeo()
if neo.Conversation == nil {
exception.New("Neo conversation is not initialized", 500).Throw()
}
err := neo.Conversation.DeleteAssistant(assistantID)
if err != nil {
exception.New("Failed to delete assistant: %s", 500, err.Error()).Throw()
}
return gin.H{"message": "ok"}
}
// processAssistantSearch process the assistant search request
func processAssistantSearch(process *process.Process) interface{} {
params := process.ArgsMap(0)
filter := conversation.AssistantFilter{}
// Parse page and pagesize
if page, ok := params["page"]; ok {
pageStr := fmt.Sprintf("%v", page)
if pageInt, err := strconv.Atoi(pageStr); err == nil {
filter.Page = pageInt
}
}
if pagesize, ok := params["pagesize"]; ok {
pagesizeStr := fmt.Sprintf("%v", pagesize)
if pagesizeInt, err := strconv.Atoi(pagesizeStr); err == nil {
filter.PageSize = pagesizeInt
}
}
// Parse tags
if tags, ok := params["tags"]; ok {
switch v := tags.(type) {
case []interface{}:
filter.Tags = make([]string, len(v))
for i, tag := range v {
filter.Tags[i] = fmt.Sprintf("%v", tag)
}
case []string:
filter.Tags = v
}
}
// Parse keywords
if keywords, ok := params["keywords"].(string); ok {
filter.Keywords = keywords
}
// Parse connector
if connector, ok := params["connector"].(string); ok {
filter.Connector = connector
}
// Parse mentionable
if mentionable, ok := params["mentionable"].(bool); ok {
filter.Mentionable = &mentionable
}
// Parse automated
if automated, ok := params["automated"].(bool); ok {
filter.Automated = &automated
}
// Get assistants
neo := GetNeo()
if neo.Conversation == nil {
exception.New("Neo conversation is not initialized", 500).Throw()
}
res, err := neo.Conversation.GetAssistants(filter)
if err != nil {
exception.New("get assistants error: %s", 500, err).Throw()
}
return res
}
// processAssistantFind process the assistant find request
func processAssistantFind(process *process.Process) interface{} {
process.ValidateArgNums(1)
assistantID := process.ArgsString(0)
neo := GetNeo()
if neo.Conversation == nil {
exception.New("Neo conversation is not initialized", 500).Throw()
}
filter := conversation.AssistantFilter{
AssistantID: assistantID,
Page: 1,
PageSize: 1,
}
res, err := neo.Conversation.GetAssistants(filter)
if err != nil {
exception.New("Failed to find assistant: %s", 500, err.Error()).Throw()
}
if len(res.Data) == 0 {
exception.New("Assistant not found: %s", 404, assistantID).Throw()
}
return res.Data[0]
}

513
neo/process_test.go Normal file
View file

@ -0,0 +1,513 @@
package neo
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/any"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
func prepare(t *testing.T) {
test.Prepare(t, config.Conf)
err := Load(config.Conf)
if err != nil {
t.Fatal(err)
}
// Clean up the test data before each test
p, err := process.Of("neo.assistant.search", map[string]interface{}{
"page": 1,
"pagesize": 1000, // Use a large page size to get all records
})
if err != nil {
t.Fatal(err)
}
output, err := p.Exec()
if err != nil {
t.Fatal(err)
}
res := any.Of(output).Map()
items := res.Get("data")
if items != nil {
for _, item := range items.([]map[string]interface{}) {
assistantID := item["assistant_id"].(string)
p, err = process.Of("neo.assistant.delete", assistantID)
if err != nil {
t.Fatal(err)
}
_, err = p.Exec()
if err != nil {
t.Fatal(err)
}
}
}
// Verify cleanup
p, err = process.Of("neo.assistant.search")
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
res = any.Of(output).Map()
total := res.Get("total")
if total != nil && any.Of(total).CInt() > 0 {
t.Fatalf("Failed to clean up test data, %d records remaining", any.Of(total).CInt())
}
check(t)
}
func TestProcessAssistantCRUD(t *testing.T) {
prepare(t)
defer test.Clean()
// Create an assistant with string JSON fields
tagsJSON := `["tag1", "tag2", "tag3"]`
optionsJSON := `{"model": "gpt-4"}`
assistant := map[string]interface{}{
"name": "Test Assistant",
"type": "assistant",
"avatar": "https://example.com/avatar.png",
"connector": "openai",
"description": "Test Description",
"tags": tagsJSON,
"options": optionsJSON,
"mentionable": true,
"automated": true,
}
// Test processAssistantCreate with string JSON
p, err := process.Of("neo.assistant.create", assistant)
if err != nil {
t.Fatal(err)
}
output, err := p.Exec()
if err != nil {
t.Fatal(err)
}
assistantID := output
assert.NotNil(t, assistantID)
// Test processAssistantFind
p, err = process.Of("neo.assistant.find", assistantID)
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
foundAssistant := output.(map[string]interface{})
assert.Equal(t, assistantID, foundAssistant["assistant_id"])
assert.Equal(t, "Test Assistant", foundAssistant["name"])
assert.Equal(t, []interface{}{"tag1", "tag2", "tag3"}, foundAssistant["tags"])
assert.Equal(t, map[string]interface{}{"model": "gpt-4"}, foundAssistant["options"])
// Test processAssistantFind with non-existent ID
p, err = process.Of("neo.assistant.find", "non-existent-id")
if err != nil {
t.Fatal(err)
}
_, err = p.Exec()
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "Assistant not found")
// Test with native type JSON fields
assistant2 := map[string]interface{}{
"name": "Test Assistant 2",
"type": "assistant",
"avatar": "https://example.com/avatar2.png",
"connector": "openai",
"description": "Test Description 2",
"tags": []string{"tag1", "tag2", "tag3"},
"options": map[string]interface{}{"model": "gpt-4"},
"prompts": []string{"prompt1", "prompt2"},
"flows": []string{"flow1", "flow2"},
"files": []string{"file1", "file2"},
"functions": []map[string]interface{}{{"name": "func1"}, {"name": "func2"}},
"permissions": map[string]interface{}{"read": true, "write": true},
"mentionable": true,
"automated": true,
}
// Test processAssistantCreate with native types
p, err = process.Of("neo.assistant.create", assistant2)
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
assistant2ID := output
assert.NotNil(t, assistant2ID)
// Test with nil JSON fields
assistant3 := map[string]interface{}{
"name": "Test Assistant 3",
"type": "assistant",
"connector": "openai",
"description": "Test Description 3",
"tags": nil,
"options": nil,
"prompts": nil,
"flows": nil,
"files": nil,
"functions": nil,
"permissions": nil,
"mentionable": true,
"automated": true,
}
// Test processAssistantCreate with nil fields
p, err = process.Of("neo.assistant.create", assistant3)
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
assistant3ID := output
assert.NotNil(t, assistant3ID)
// Test processAssistantSearch to verify all assistants
p, err = process.Of("neo.assistant.search")
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
searchRes := any.Of(output).Map()
total := searchRes.Get("total")
if total == nil {
total = int64(0)
}
assert.Equal(t, int64(3), total)
items := searchRes.Get("data")
if items == nil {
items = []map[string]interface{}{}
}
assert.Equal(t, 3, len(items.([]map[string]interface{})))
// Verify each assistant's JSON fields
for _, item := range items.([]map[string]interface{}) {
switch item["assistant_id"].(string) {
case assistantID:
assert.Equal(t, []interface{}{"tag1", "tag2", "tag3"}, item["tags"])
assert.Equal(t, map[string]interface{}{"model": "gpt-4"}, item["options"])
case assistant2ID:
assert.Equal(t, []interface{}{"tag1", "tag2", "tag3"}, item["tags"])
assert.Equal(t, map[string]interface{}{"model": "gpt-4"}, item["options"])
assert.Equal(t, []interface{}{"prompt1", "prompt2"}, item["prompts"])
assert.Equal(t, []interface{}{"flow1", "flow2"}, item["flows"])
assert.Equal(t, []interface{}{"file1", "file2"}, item["files"])
assert.Equal(t,
[]interface{}{
map[string]interface{}{"name": "func1"},
map[string]interface{}{"name": "func2"},
},
item["functions"])
assert.Equal(t,
map[string]interface{}{
"read": true,
"write": true,
},
item["permissions"])
case assistant3ID:
assert.Nil(t, item["tags"])
assert.Nil(t, item["options"])
assert.Nil(t, item["prompts"])
assert.Nil(t, item["flows"])
assert.Nil(t, item["files"])
assert.Nil(t, item["functions"])
assert.Nil(t, item["permissions"])
}
}
// Test updating with mixed JSON formats
assistant2["assistant_id"] = assistant2ID
assistant2["tags"] = `["tag4", "tag5"]`
assistant2["options"] = map[string]interface{}{"model": "gpt-3.5"}
p, err = process.Of("neo.assistant.save", assistant2)
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
savedID := output
assert.NotNil(t, savedID)
// Double check with a new search
p, err = process.Of("neo.assistant.search")
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
searchRes = any.Of(output).Map()
items = searchRes.Get("data")
found := false
for _, item := range items.([]map[string]interface{}) {
if item["assistant_id"].(string) == assistant2ID {
found = true
assert.Equal(t, []interface{}{"tag4", "tag5"}, item["tags"])
assert.Equal(t, map[string]interface{}{"model": "gpt-3.5"}, item["options"])
break
}
}
assert.True(t, found)
// Test processAssistantDelete
p, err = process.Of("neo.assistant.delete", assistantID)
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
deleteRes := any.Of(output).Map()
assert.Equal(t, "ok", deleteRes.Get("message"))
// Delete remaining assistants
p, err = process.Of("neo.assistant.delete", assistant2ID)
if err != nil {
t.Fatal(err)
}
_, err = p.Exec()
assert.Nil(t, err)
p, err = process.Of("neo.assistant.delete", assistant3ID)
if err != nil {
t.Fatal(err)
}
_, err = p.Exec()
assert.Nil(t, err)
// Verify all assistants are deleted
p, err = process.Of("neo.assistant.search")
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
searchRes = any.Of(output).Map()
total = searchRes.Get("total")
if total == nil {
total = int64(0)
}
assert.Equal(t, int64(0), total)
}
func TestProcessAssistantSearchPagination(t *testing.T) {
prepare(t)
defer test.Clean()
// Create multiple assistants for pagination testing
for i := 0; i < 25; i++ {
assistant := map[string]interface{}{
"name": fmt.Sprintf("Assistant %d", i),
"type": "assistant",
"connector": fmt.Sprintf("connector%d", i%3),
"description": fmt.Sprintf("Description %d", i),
"tags": []string{fmt.Sprintf("tag%d", i%5)},
"mentionable": i%2 == 0,
"automated": i%3 == 0,
}
p, err := process.Of("neo.assistant.create", assistant)
if err != nil {
t.Fatal(err)
}
_, err = p.Exec()
if err != nil {
t.Fatal(err)
}
}
// Test first page
p, err := process.Of("neo.assistant.search", map[string]interface{}{
"page": 1,
"pagesize": 10,
})
if err != nil {
t.Fatal(err)
}
output, err := p.Exec()
if err != nil {
t.Fatal(err)
}
res := any.Of(output).Map()
total := res.Get("total")
if total == nil {
total = int64(0)
}
assert.Equal(t, int64(25), total)
items := res.Get("data")
if items == nil {
items = []map[string]interface{}{}
}
assert.Equal(t, 10, len(items.([]map[string]interface{})))
pageCnt := res.Get("pagecnt")
if pageCnt == nil {
pageCnt = 1
}
assert.Equal(t, 3, pageCnt)
// Test second page
p, err = process.Of("neo.assistant.search", map[string]interface{}{
"page": 2,
"pagesize": 10,
})
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
res = any.Of(output).Map()
items = res.Get("data")
if items == nil {
items = []map[string]interface{}{}
}
assert.Equal(t, 10, len(items.([]map[string]interface{})))
// Test last page
p, err = process.Of("neo.assistant.search", map[string]interface{}{
"page": 3,
"pagesize": 10,
})
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
res = any.Of(output).Map()
items = res.Get("data")
if items == nil {
items = []map[string]interface{}{}
}
assert.Equal(t, 5, len(items.([]map[string]interface{})))
// Test filtering with tags
p, err = process.Of("neo.assistant.search", map[string]interface{}{
"tags": []string{"tag0"},
"page": 1,
"pagesize": 10,
})
if err != nil {
t.Fatal(err)
}
output, err = p.Exec()
if err != nil {
t.Fatal(err)
}
res = any.Of(output).Map()
items = res.Get("data")
if items == nil {
items = []map[string]interface{}{}
}
assert.Equal(t, 5, len(items.([]map[string]interface{})))
}
func TestProcessAssistantValidation(t *testing.T) {
prepare(t)
defer test.Clean()
// Test missing required fields
p, err := process.Of("neo.assistant.create", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
_, err = p.Exec()
assert.NotNil(t, err)
// Test invalid assistant ID for delete
p, err = process.Of("neo.assistant.delete", "non-existent-id")
if err != nil {
t.Fatal(err)
}
_, err = p.Exec()
assert.NotNil(t, err)
// Test invalid assistant ID for find
p, err = process.Of("neo.assistant.find", "non-existent-id")
if err != nil {
t.Fatal(err)
}
_, err = p.Exec()
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "Assistant not found")
// Test invalid page number
p, err = process.Of("neo.assistant.search", map[string]interface{}{
"page": -1,
"pagesize": 10,
})
if err != nil {
t.Fatal(err)
}
output, err := p.Exec()
assert.Nil(t, err)
res := any.Of(output).Map()
total := res.Get("total")
if total == nil {
total = int64(0)
}
assert.Equal(t, int64(0), total)
}

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -82,6 +83,50 @@ func Prepare(t *testing.T, cfg config.Config, rootEnv ...string) {
// cfg.DataRoot = filepath.Join(root, "data") // cfg.DataRoot = filepath.Join(root, "data")
// } // }
var appData []byte
var appFile string
// Read app setting
if has, _ := application.App.Exists("app.yao"); has {
appFile = "app.yao"
appData, err = application.App.Read("app.yao")
if err != nil {
t.Fatal(err)
}
} else if has, _ := application.App.Exists("app.jsonc"); has {
appFile = "app.jsonc"
appData, err = application.App.Read("app.jsonc")
if err != nil {
t.Fatal(err)
}
} else if has, _ := application.App.Exists("app.json"); has {
appFile = "app.json"
appData, err = application.App.Read("app.json")
if err != nil {
t.Fatal(err)
}
} else {
t.Fatal(fmt.Errorf("app.yao or app.jsonc or app.json does not exists"))
}
// Replace $ENV with os.Getenv
var envRe = regexp.MustCompile(`\$ENV\.([0-9a-zA-Z_-]+)`)
appData = envRe.ReplaceAllFunc(appData, func(s []byte) []byte {
key := string(s[5:])
val := os.Getenv(key)
if val == "" {
return s
}
return []byte(val)
})
share.App = share.AppInfo{}
err = application.Parse(appFile, appData, &share.App)
if err != nil {
t.Fatal(err)
}
utils.Init() utils.Init()
dbconnect(t, cfg) dbconnect(t, cfg)
load(t, cfg) load(t, cfg)