From 13c4e65a95e535f1d7b2be50bee4f79c38ff60b2 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Feb 2025 11:20:20 +0800 Subject: [PATCH 01/27] Add retry and silent modes to assistant chat processing - Introduce retry and silent mode flags in context and message structures - Modify API and hooks to support new context modes - Update message handling to propagate retry and silent flags - Replace debug print statements with structured logging - Enhance tool call and message processing with new context options --- neo/assistant/api.go | 35 +++++++++++++--- neo/assistant/hooks.go | 94 +++++++++++++++++++++++++++++++++++------- neo/context/context.go | 2 + neo/message/message.go | 5 ++- 4 files changed, 113 insertions(+), 23 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 59a00e44..94175a8c 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" jsoniter "github.com/json-iterator/go" "github.com/yaoapp/gou/fs" - "github.com/yaoapp/kun/utils" + "github.com/yaoapp/kun/log" chatctx "github.com/yaoapp/yao/neo/context" chatMessage "github.com/yaoapp/yao/neo/message" ) @@ -190,6 +190,14 @@ func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *c return fmt.Errorf("input is required") } + // Retry mode + retry := false + _, has = next.Payload["retry"] + if has { + retry = next.Payload["retry"].(bool) + ctx.Retry = retry + } + switch v := next.Payload["input"].(type) { case string: messages := chatMessage.Message{} @@ -338,6 +346,10 @@ func (ast *Assistant) streamChat( return 1 // continue } + // Retry mode + msg.Retry = ctx.Retry // Retry mode + msg.Silent = ctx.Silent // Silent mode + // Handle error if msg.Type == "error" { value := msg.String() @@ -348,7 +360,10 @@ func (ast *Assistant) streamChat( value = res.Error } } - chatMessage.New().Error(value).Done().Write(c.Writer) + newMsg := chatMessage.New().Error(value).Done() + newMsg.Retry = ctx.Retry + newMsg.Silent = ctx.Silent + newMsg.Write(c.Writer) return 0 // break } @@ -468,6 +483,9 @@ func (ast *Assistant) streamChat( "delta": true, }) + output.Retry = ctx.Retry // Retry mode + output.Silent = ctx.Silent // Silent mode + if isFirst { output.Assistant(ast.ID, ast.Name, ast.Avatar) isFirst = false @@ -489,6 +507,8 @@ func (ast *Assistant) streamChat( "type": "text", "delta": true, "done": true, + "retry": ctx.Retry, + "silent": ctx.Silent, }). Write(c.Writer) } @@ -521,6 +541,8 @@ func (ast *Assistant) streamChat( output := chatMessage.New().Done() if res != nil && res.Output != nil { output = chatMessage.New().Map(map[string]interface{}{"text": res.Output, "done": true}) + output.Retry = ctx.Retry + output.Silent = ctx.Silent } output.Write(c.Writer) done <- true @@ -542,6 +564,8 @@ func (ast *Assistant) streamChat( if err != nil { return fmt.Errorf("error: %s", err.Error()) } + msg.Retry = ctx.Retry + msg.Silent = ctx.Silent msg.Done().Write(c.Writer) } @@ -826,9 +850,10 @@ func (ast *Assistant) requestMessages(ctx context.Context, messages []chatMessag // For debug environment, print the request messages if os.Getenv("YAO_AGENT_PRINT_REQUEST_MESSAGES") == "true" { - fmt.Println("--- REQUEST_MESSAGES -----------------------------") - utils.Dump(newMessages) - fmt.Println("--- END REQUEST_MESSAGES -----------------------------") + for _, message := range newMessages { + raw, _ := jsoniter.MarshalToString(message) + log.Trace("[Request Message] %s", raw) + } } return newMessages, nil diff --git a/neo/assistant/hooks.go b/neo/assistant/hooks.go index ad61278b..754df070 100644 --- a/neo/assistant/hooks.go +++ b/neo/assistant/hooks.go @@ -8,8 +8,10 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" jsoniter "github.com/json-iterator/go" "github.com/yaoapp/gou/runtime/v8/bridge" + "github.com/yaoapp/kun/log" chatctx "github.com/yaoapp/yao/neo/context" "github.com/yaoapp/yao/neo/message" chatMessage "github.com/yaoapp/yao/neo/message" @@ -168,9 +170,7 @@ func (ast *Assistant) HookDone(c *gin.Context, context chatctx.Context, input [] text = content[:endIndex] text = strings.TrimSpace(text) if os.Getenv("YAO_AGENT_PRINT_TOOL_CALL") == "true" { - fmt.Println("---- EXTRACTED TOOL CALL ----") - fmt.Println(text) - fmt.Println("---- END EXTRACTED TOOL CALL ----") + log.Trace("[TOOL CALL] %s", text) } } } @@ -309,7 +309,81 @@ func (ast *Assistant) call(ctx context.Context, method string, c *gin.Context, c defer scriptCtx.Close() // Add sendMessage function to the script context - scriptCtx.WithFunction("SendMessage", func(info *v8go.FunctionCallbackInfo) *v8go.Value { + scriptCtx.WithFunction("SendMessage", sendMessage(c, contents)) + scriptCtx.WithFunction("Run", run(c, context)) + + // Check if the method exists + if !scriptCtx.Global().Has(method) { + return nil, fmt.Errorf(HookErrorMethodNotFound) + } + + // Call the method directly in the current thread + args = append([]interface{}{context.Map()}, args...) + if scriptCtx != nil { + return scriptCtx.CallWith(ctx, method, args...) + } + return nil, nil +} + +// Execute the assistant +func run(c *gin.Context, context chatctx.Context) func(info *v8go.FunctionCallbackInfo) *v8go.Value { + return func(info *v8go.FunctionCallbackInfo) *v8go.Value { + + // Get the args + args := info.Args() + if len(args) < 2 { + return bridge.JsException(info.Context(), "Run requires at least two arguments") + } + + // Get the assistant id + assistantID := args[0].String() + + // Get the assistant + assistant, err := Get(assistantID) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // input []chatMessage.Message + input := args[1].String() + + options := map[string]interface{}{} + if len(args) > 2 { + optionsRaw, err := bridge.GoValue(args[2], info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Parse the options + if optionsRaw != nil { + switch v := optionsRaw.(type) { + case string: + err := jsoniter.UnmarshalFromString(v, &options) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + case map[string]interface{}: + options = v + default: + return bridge.JsException(info.Context(), "Invalid options") + } + } + } + + // Execute the assistant + context.AssistantID = assistantID + context.ChatID = fmt.Sprintf("chat_%s", uuid.New().String()) // New chat id + context.Silent = true // Silent mode + err = assistant.Execute(c, context, input, options) // Execute the assistant + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + return nil + } +} + +func sendMessage(c *gin.Context, contents *chatMessage.Contents) func(info *v8go.FunctionCallbackInfo) *v8go.Value { + return func(info *v8go.FunctionCallbackInfo) *v8go.Value { // Get the message args := info.Args() @@ -354,17 +428,5 @@ func (ast *Assistant) call(ctx context.Context, method string, c *gin.Context, c default: return bridge.JsException(info.Context(), "SendMessage requires a string or a map") } - }) - - // Check if the method exists - if !scriptCtx.Global().Has(method) { - return nil, fmt.Errorf(HookErrorMethodNotFound) } - - // Call the method directly in the current thread - args = append([]interface{}{context.Map()}, args...) - if scriptCtx != nil { - return scriptCtx.CallWith(ctx, method, args...) - } - return nil, nil } diff --git a/neo/context/context.go b/neo/context/context.go index 686d3949..4962c3a0 100644 --- a/neo/context/context.go +++ b/neo/context/context.go @@ -21,6 +21,8 @@ type Context struct { Namespace string `json:"namespace,omitempty"` Config map[string]interface{} `json:"config,omitempty"` Signal interface{} `json:"signal,omitempty"` + Silent bool `json:"silent,omitempty"` // Silent mode + Retry bool `json:"retry,omitempty"` // Retry mode Upload *FileUpload `json:"upload,omitempty"` Version bool `json:"version,omitempty"` // Version support RAG bool `json:"rag,omitempty"` // RAG support diff --git a/neo/message/message.go b/neo/message/message.go index 19e8fbe0..125d6367 100644 --- a/neo/message/message.go +++ b/neo/message/message.go @@ -35,6 +35,8 @@ type Message struct { Data map[string]interface{} `json:"-"` // data for the message Pending bool `json:"-"` // pending for the message Hidden bool `json:"hidden,omitempty"` // hidden for the message (not show in the UI and history) + Retry bool `json:"retry,omitempty"` // retry for the message + Silent bool `json:"silent,omitempty"` // silent for the message (not show in the UI and history) } // Mention represents a mention @@ -187,10 +189,9 @@ func NewAny(content interface{}) (*Message, error) { // NewOpenAI create a new message from OpenAI response func NewOpenAI(data []byte, isThinking bool) *Message { - // For Debug // For debug environment, print the response data if os.Getenv("YAO_AGENT_PRINT_RESPONSE_DATA") == "true" { - fmt.Printf("%s\n", string(data)) + log.Trace("[Response Data] %s", string(data)) } if data == nil || len(data) == 0 { From 979acb27a87dcdb7a28e07efb8164134ac7fd298 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Feb 2025 18:13:42 +0800 Subject: [PATCH 02/27] Add callback support for assistant chat processing - Introduce optional callback mechanism for chat messages - Modify Execute and streamChat methods to support callback functions - Add support for process and script method callbacks - Update message handling to trigger callbacks during chat streaming - Enhance flexibility of assistant chat interactions with dynamic callbacks --- neo/assistant/api.go | 42 ++++++++++++++--------- neo/assistant/hooks.go | 75 ++++++++++++++++++++++++++++++++++++++---- neo/assistant/types.go | 2 +- neo/message/message.go | 25 ++++++++++++++ 4 files changed, 122 insertions(+), 22 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 94175a8c..39d0d106 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -46,17 +46,17 @@ func GetByConnector(connector string, name string) (*Assistant, error) { } // Execute implements the execute functionality -func (ast *Assistant) Execute(c *gin.Context, ctx chatctx.Context, input string, options map[string]interface{}) error { +func (ast *Assistant) Execute(c *gin.Context, ctx chatctx.Context, input string, options map[string]interface{}, callback ...interface{}) error { contents := chatMessage.NewContents() messages, err := ast.withHistory(ctx, input) if err != nil { return err } - return ast.execute(c, ctx, messages, options, contents) + return ast.execute(c, ctx, messages, options, contents, callback...) } // Execute implements the execute functionality -func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, input []chatMessage.Message, userOptions map[string]interface{}, contents *chatMessage.Contents) error { +func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, input []chatMessage.Message, userOptions map[string]interface{}, contents *chatMessage.Contents, callback ...interface{}) error { if contents == nil { contents = chatMessage.NewContents() @@ -123,11 +123,11 @@ func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, input []chatM // Update assistant id ctx.AssistantID = res.AssistantID - return newAst.handleChatStream(c, ctx, input, options, contents) + return newAst.handleChatStream(c, ctx, input, options, contents, callback...) } // Only proceed with chat stream if no specific next action was handled - return ast.handleChatStream(c, ctx, input, options, contents) + return ast.handleChatStream(c, ctx, input, options, contents, callback...) } // Execute the next action @@ -289,13 +289,13 @@ func (ast *Assistant) Call(c *gin.Context, payload APIPayload) (interface{}, err } // handleChatStream manages the streaming chat interaction with the AI -func (ast *Assistant) handleChatStream(c *gin.Context, ctx chatctx.Context, messages []chatMessage.Message, options map[string]interface{}, contents *chatMessage.Contents) error { +func (ast *Assistant) handleChatStream(c *gin.Context, ctx chatctx.Context, messages []chatMessage.Message, options map[string]interface{}, contents *chatMessage.Contents, callback ...interface{}) error { clientBreak := make(chan bool, 1) done := make(chan bool, 1) // Chat with AI in background go func() { - err := ast.streamChat(c, ctx, messages, options, clientBreak, done, contents) + err := ast.streamChat(c, ctx, messages, options, clientBreak, done, contents, callback...) if err != nil { chatMessage.New().Error(err).Done().Write(c.Writer) } @@ -320,7 +320,14 @@ func (ast *Assistant) streamChat( options map[string]interface{}, clientBreak chan bool, done chan bool, - contents *chatMessage.Contents) error { + contents *chatMessage.Contents, + callback ...interface{}, +) error { + + var cb interface{} + if len(callback) > 0 { + cb = callback[0] + } errorRaw := "" isFirst := true @@ -363,7 +370,7 @@ func (ast *Assistant) streamChat( newMsg := chatMessage.New().Error(value).Done() newMsg.Retry = ctx.Retry newMsg.Silent = ctx.Silent - newMsg.Write(c.Writer) + newMsg.Callback(cb).Write(c.Writer) return 0 // break } @@ -380,8 +387,11 @@ func (ast *Assistant) streamChat( if isThinking && msg.Type != "think" { // add the think close tag end := chatMessage.New().Map(map[string]interface{}{"text": "\n\n", "type": "think", "delta": true}) - end.Write(c.Writer) end.ID = currentMessageID + end.Retry = ctx.Retry + end.Silent = ctx.Silent + + end.Callback(cb).Write(c.Writer) end.AppendTo(contents) contents.UpdateType("think", map[string]interface{}{"text": contents.Text()}, currentMessageID) isThinking = false @@ -405,8 +415,10 @@ func (ast *Assistant) streamChat( if msg.IsDone { end := chatMessage.New().Map(map[string]interface{}{"text": "}\n\n", "type": "tool", "delta": true}) - end.Write(c.Writer) end.ID = currentMessageID + end.Retry = ctx.Retry + end.Silent = ctx.Silent + end.Callback(cb).Write(c.Writer) end.AppendTo(contents) contents.UpdateType("tool", map[string]interface{}{"text": contents.Text()}, currentMessageID) isTool = false @@ -485,12 +497,11 @@ func (ast *Assistant) streamChat( output.Retry = ctx.Retry // Retry mode output.Silent = ctx.Silent // Silent mode - if isFirst { output.Assistant(ast.ID, ast.Name, ast.Avatar) isFirst = false } - output.Write(c.Writer) + output.Callback(cb).Write(c.Writer) } // Complete the stream @@ -510,6 +521,7 @@ func (ast *Assistant) streamChat( "retry": ctx.Retry, "silent": ctx.Silent, }). + Callback(cb). Write(c.Writer) } @@ -544,7 +556,7 @@ func (ast *Assistant) streamChat( output.Retry = ctx.Retry output.Silent = ctx.Silent } - output.Write(c.Writer) + output.Callback(cb).Write(c.Writer) done <- true return 0 // break } @@ -566,7 +578,7 @@ func (ast *Assistant) streamChat( } msg.Retry = ctx.Retry msg.Silent = ctx.Silent - msg.Done().Write(c.Writer) + msg.Done().Callback(cb).Write(c.Writer) } return nil diff --git a/neo/assistant/hooks.go b/neo/assistant/hooks.go index 754df070..f4ed1d14 100644 --- a/neo/assistant/hooks.go +++ b/neo/assistant/hooks.go @@ -7,9 +7,11 @@ import ( "strings" "time" + "github.com/fatih/color" "github.com/gin-gonic/gin" "github.com/google/uuid" jsoniter "github.com/json-iterator/go" + "github.com/yaoapp/gou/process" "github.com/yaoapp/gou/runtime/v8/bridge" "github.com/yaoapp/kun/log" chatctx "github.com/yaoapp/yao/neo/context" @@ -19,7 +21,7 @@ import ( ) // HookInit initialize the assistant -func (ast *Assistant) HookInit(c *gin.Context, context chatctx.Context, input []message.Message, options map[string]interface{}, contents *message.Contents) (*ResHookInit, error) { +func (ast *Assistant) HookInit(c *gin.Context, context chatctx.Context, input []chatMessage.Message, options map[string]interface{}, contents *chatMessage.Contents) (*ResHookInit, error) { // Create timeout context ctx := ast.createBackgroundContext() v, err := ast.call(ctx, "Init", c, contents, context, input, options) @@ -310,7 +312,7 @@ func (ast *Assistant) call(ctx context.Context, method string, c *gin.Context, c // Add sendMessage function to the script context scriptCtx.WithFunction("SendMessage", sendMessage(c, contents)) - scriptCtx.WithFunction("Run", run(c, context)) + scriptCtx.WithFunction("Run", ast.run(c, context)) // Check if the method exists if !scriptCtx.Global().Has(method) { @@ -326,7 +328,7 @@ func (ast *Assistant) call(ctx context.Context, method string, c *gin.Context, c } // Execute the assistant -func run(c *gin.Context, context chatctx.Context) func(info *v8go.FunctionCallbackInfo) *v8go.Value { +func (ast *Assistant) run(c *gin.Context, context chatctx.Context) func(info *v8go.FunctionCallbackInfo) *v8go.Value { return func(info *v8go.FunctionCallbackInfo) *v8go.Value { // Get the args @@ -345,11 +347,72 @@ func run(c *gin.Context, context chatctx.Context) func(info *v8go.FunctionCallba } // input []chatMessage.Message + var cb func(msg *chatMessage.Message) input := args[1].String() + if len(args) > 2 { + + goValue, err := bridge.GoValue(args[2], info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + name := "" + userArgs := []interface{}{} + switch v := goValue.(type) { + case string: + name = v + case map[string]interface{}: + if fname, ok := v["name"].(string); ok { + name = fname + } + if args, ok := v["args"].([]interface{}); ok { + userArgs = args + } + } + + if strings.Contains(name, ".") { + cb = func(msg *chatMessage.Message) { + cbArgs := []interface{}{} + cbArgs = append(cbArgs, msg) + cbArgs = append(cbArgs, userArgs...) + p, err := process.Of(name, cbArgs...) + if err != nil { + log.Error("Failed to get the process: %s", err.Error()) + color.Red("Failed to get the process: %s", err.Error()) + return + } + err = p.Execute() + if err != nil { + log.Error("Failed to execute the process: %s", err.Error()) + color.Red("Failed to execute the process: %s", err.Error()) + return + } + defer p.Release() + } + } + + // Call self method + cb = func(msg *chatMessage.Message) { + cbArgs := []interface{}{} + cbArgs = append(cbArgs, msg) + cbArgs = append(cbArgs, userArgs...) + ctx, err := ast.Script.NewContext(context.Sid, nil) + if err != nil { + return + } + defer ctx.Close() + _, err = ctx.CallWith(context, name, cbArgs...) + if err != nil { + log.Error("Failed to call the method: %s", err.Error()) + color.Red("Failed to call the method: %s", err.Error()) + return + } + } + } options := map[string]interface{}{} - if len(args) > 2 { - optionsRaw, err := bridge.GoValue(args[2], info.Context()) + if len(args) > 3 { + optionsRaw, err := bridge.GoValue(args[3], info.Context()) if err != nil { return bridge.JsException(info.Context(), err.Error()) } @@ -374,7 +437,7 @@ func run(c *gin.Context, context chatctx.Context) func(info *v8go.FunctionCallba context.AssistantID = assistantID context.ChatID = fmt.Sprintf("chat_%s", uuid.New().String()) // New chat id context.Silent = true // Silent mode - err = assistant.Execute(c, context, input, options) // Execute the assistant + err = assistant.Execute(c, context, input, options, cb) // Execute the assistant if err != nil { return bridge.JsException(info.Context(), err.Error()) } diff --git a/neo/assistant/types.go b/neo/assistant/types.go index cad8fd16..394b21f2 100644 --- a/neo/assistant/types.go +++ b/neo/assistant/types.go @@ -26,7 +26,7 @@ type API interface { ReadBase64(ctx context.Context, fileID string) (string, error) GetPlaceholder() *Placeholder - Execute(c *gin.Context, ctx chatctx.Context, input string, options map[string]interface{}) error + Execute(c *gin.Context, ctx chatctx.Context, input string, options map[string]interface{}, callback ...interface{}) error Call(c *gin.Context, payload APIPayload) (interface{}, error) } diff --git a/neo/message/message.go b/neo/message/message.go index 125d6367..0236b547 100644 --- a/neo/message/message.go +++ b/neo/message/message.go @@ -693,6 +693,26 @@ func (m *Message) Bind(data map[string]interface{}) *Message { return m } +// Callback callback the message +func (m *Message) Callback(fn interface{}) *Message { + if fn != nil { + switch v := fn.(type) { + case func(msg *Message): + v(m) + break + + case func(): + v() + break + + default: + fmt.Println("no match callback") + break + } + } + return m +} + // Write writes the message to response writer func (m *Message) Write(w gin.ResponseWriter) bool { defer func() { @@ -702,6 +722,11 @@ func (m *Message) Write(w gin.ResponseWriter) bool { } }() + // Ignore silent messages + if m.Silent { + return true + } + data, err := jsoniter.Marshal(m) if err != nil { log.Error("%s", err.Error()) From 9a9d1e38a885cb39e724fb141caae17b6c4dbc48 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 19 Feb 2025 09:39:16 +0800 Subject: [PATCH 03/27] Update run method to support SendMessage in nested script contexts - Modify run method signature to include contents parameter - Add SendMessage function to nested script context during execution - Ensure consistent message sending capabilities across script levels --- neo/assistant/hooks.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/neo/assistant/hooks.go b/neo/assistant/hooks.go index f4ed1d14..36001824 100644 --- a/neo/assistant/hooks.go +++ b/neo/assistant/hooks.go @@ -312,7 +312,7 @@ func (ast *Assistant) call(ctx context.Context, method string, c *gin.Context, c // Add sendMessage function to the script context scriptCtx.WithFunction("SendMessage", sendMessage(c, contents)) - scriptCtx.WithFunction("Run", ast.run(c, context)) + scriptCtx.WithFunction("Run", ast.run(c, context, contents)) // Check if the method exists if !scriptCtx.Global().Has(method) { @@ -328,7 +328,7 @@ func (ast *Assistant) call(ctx context.Context, method string, c *gin.Context, c } // Execute the assistant -func (ast *Assistant) run(c *gin.Context, context chatctx.Context) func(info *v8go.FunctionCallbackInfo) *v8go.Value { +func (ast *Assistant) run(c *gin.Context, context chatctx.Context, contents *chatMessage.Contents) func(info *v8go.FunctionCallbackInfo) *v8go.Value { return func(info *v8go.FunctionCallbackInfo) *v8go.Value { // Get the args @@ -401,6 +401,7 @@ func (ast *Assistant) run(c *gin.Context, context chatctx.Context) func(info *v8 return } defer ctx.Close() + ctx.WithFunction("SendMessage", sendMessage(c, contents)) _, err = ctx.CallWith(context, name, cbArgs...) if err != nil { log.Error("Failed to call the method: %s", err.Error()) From 9c419c32093c59b9157b084b16301c0758b6b84a Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 21 Feb 2025 15:35:11 +0800 Subject: [PATCH 04/27] Add assistant script loading mechanism - Implement loading of assistant TypeScript index files - Filter and load only src/index.ts files from assistants directory - Generate unique IDs for assistant scripts using file path - Extend application walk method to support assistant script loading --- script/script.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/script/script.go b/script/script.go index f983e843..15d25c81 100644 --- a/script/script.go +++ b/script/script.go @@ -2,6 +2,7 @@ package script import ( "fmt" + "strings" "github.com/yaoapp/gou/application" v8 "github.com/yaoapp/gou/runtime/v8" @@ -25,6 +26,27 @@ func Load(cfg config.Config) error { return err } + // Load assistants + err = application.App.Walk("assistants", func(root, file string, isdir bool) error { + if isdir { + return nil + } + + // Keep the src.index only + if !strings.HasSuffix(file, "src/index.ts") { + return nil + } + + id := fmt.Sprintf("assistants.%s", share.ID(root, file)) + id = strings.TrimSuffix(id, ".src.index") + _, err := v8.Load(file, id) + return err + }, exts...) + + if err != nil { + return err + } + return application.App.Walk("services", func(root, file string, isdir bool) error { if isdir { return nil From f3e009c3a7e75d917fe8fc635264393641ac7229 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Feb 2025 13:53:56 +0800 Subject: [PATCH 05/27] Refactor assistant initialization and script context methods - Rename HookInit to HookCreate for clarity - Modify call method to simplify script context initialization - Remove explicit SendMessage and Run function additions - Add InitObject method to handle script context setup - Streamline assistant script method invocation --- neo/assistant/api.go | 2 +- neo/assistant/hooks.go | 185 +------------------------ neo/assistant/object.go | 289 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 181 deletions(-) create mode 100644 neo/assistant/object.go diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 39d0d106..79508e36 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -68,7 +68,7 @@ func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, input []chatM ctx.Version = ast.vision // Run init hook - res, err := ast.HookInit(c, ctx, input, options, contents) + res, err := ast.HookCreate(c, ctx, input, options, contents) if err != nil { chatMessage.New(). Assistant(ast.ID, ast.Name, ast.Avatar). diff --git a/neo/assistant/hooks.go b/neo/assistant/hooks.go index 36001824..05812e4b 100644 --- a/neo/assistant/hooks.go +++ b/neo/assistant/hooks.go @@ -7,24 +7,19 @@ import ( "strings" "time" - "github.com/fatih/color" "github.com/gin-gonic/gin" - "github.com/google/uuid" jsoniter "github.com/json-iterator/go" - "github.com/yaoapp/gou/process" - "github.com/yaoapp/gou/runtime/v8/bridge" "github.com/yaoapp/kun/log" chatctx "github.com/yaoapp/yao/neo/context" "github.com/yaoapp/yao/neo/message" chatMessage "github.com/yaoapp/yao/neo/message" - "rogchap.com/v8go" ) -// HookInit initialize the assistant -func (ast *Assistant) HookInit(c *gin.Context, context chatctx.Context, input []chatMessage.Message, options map[string]interface{}, contents *chatMessage.Contents) (*ResHookInit, error) { +// HookCreate create a new assistant +func (ast *Assistant) HookCreate(c *gin.Context, context chatctx.Context, input []chatMessage.Message, options map[string]interface{}, contents *chatMessage.Contents) (*ResHookInit, error) { // Create timeout context ctx := ast.createBackgroundContext() - v, err := ast.call(ctx, "Init", c, contents, context, input, options) + v, err := ast.call(ctx, "Create", c, contents, context, input, options) if err != nil { if err.Error() == HookErrorMethodNotFound { return nil, nil @@ -310,9 +305,8 @@ func (ast *Assistant) call(ctx context.Context, method string, c *gin.Context, c } defer scriptCtx.Close() - // Add sendMessage function to the script context - scriptCtx.WithFunction("SendMessage", sendMessage(c, contents)) - scriptCtx.WithFunction("Run", ast.run(c, context, contents)) + // Initialize the object, add the global variables, methods to the script context + ast.InitObject(scriptCtx, c, context, contents) // Check if the method exists if !scriptCtx.Global().Has(method) { @@ -320,177 +314,8 @@ func (ast *Assistant) call(ctx context.Context, method string, c *gin.Context, c } // Call the method directly in the current thread - args = append([]interface{}{context.Map()}, args...) if scriptCtx != nil { return scriptCtx.CallWith(ctx, method, args...) } return nil, nil } - -// Execute the assistant -func (ast *Assistant) run(c *gin.Context, context chatctx.Context, contents *chatMessage.Contents) func(info *v8go.FunctionCallbackInfo) *v8go.Value { - return func(info *v8go.FunctionCallbackInfo) *v8go.Value { - - // Get the args - args := info.Args() - if len(args) < 2 { - return bridge.JsException(info.Context(), "Run requires at least two arguments") - } - - // Get the assistant id - assistantID := args[0].String() - - // Get the assistant - assistant, err := Get(assistantID) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - - // input []chatMessage.Message - var cb func(msg *chatMessage.Message) - input := args[1].String() - if len(args) > 2 { - - goValue, err := bridge.GoValue(args[2], info.Context()) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - - name := "" - userArgs := []interface{}{} - switch v := goValue.(type) { - case string: - name = v - case map[string]interface{}: - if fname, ok := v["name"].(string); ok { - name = fname - } - if args, ok := v["args"].([]interface{}); ok { - userArgs = args - } - } - - if strings.Contains(name, ".") { - cb = func(msg *chatMessage.Message) { - cbArgs := []interface{}{} - cbArgs = append(cbArgs, msg) - cbArgs = append(cbArgs, userArgs...) - p, err := process.Of(name, cbArgs...) - if err != nil { - log.Error("Failed to get the process: %s", err.Error()) - color.Red("Failed to get the process: %s", err.Error()) - return - } - err = p.Execute() - if err != nil { - log.Error("Failed to execute the process: %s", err.Error()) - color.Red("Failed to execute the process: %s", err.Error()) - return - } - defer p.Release() - } - } - - // Call self method - cb = func(msg *chatMessage.Message) { - cbArgs := []interface{}{} - cbArgs = append(cbArgs, msg) - cbArgs = append(cbArgs, userArgs...) - ctx, err := ast.Script.NewContext(context.Sid, nil) - if err != nil { - return - } - defer ctx.Close() - ctx.WithFunction("SendMessage", sendMessage(c, contents)) - _, err = ctx.CallWith(context, name, cbArgs...) - if err != nil { - log.Error("Failed to call the method: %s", err.Error()) - color.Red("Failed to call the method: %s", err.Error()) - return - } - } - } - - options := map[string]interface{}{} - if len(args) > 3 { - optionsRaw, err := bridge.GoValue(args[3], info.Context()) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - - // Parse the options - if optionsRaw != nil { - switch v := optionsRaw.(type) { - case string: - err := jsoniter.UnmarshalFromString(v, &options) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - case map[string]interface{}: - options = v - default: - return bridge.JsException(info.Context(), "Invalid options") - } - } - } - - // Execute the assistant - context.AssistantID = assistantID - context.ChatID = fmt.Sprintf("chat_%s", uuid.New().String()) // New chat id - context.Silent = true // Silent mode - err = assistant.Execute(c, context, input, options, cb) // Execute the assistant - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - return nil - } -} - -func sendMessage(c *gin.Context, contents *chatMessage.Contents) func(info *v8go.FunctionCallbackInfo) *v8go.Value { - return func(info *v8go.FunctionCallbackInfo) *v8go.Value { - - // Get the message - args := info.Args() - if len(args) < 1 { - return bridge.JsException(info.Context(), "SendMessage requires at least one argument") - } - - input, err := bridge.GoValue(args[0], info.Context()) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - - // Save history by default - saveHistory := true - if len(args) > 1 && args[1].IsBoolean() { - saveHistory = args[1].Boolean() - } - - switch v := input.(type) { - case string: - // Check if the message is json - msg, err := message.NewString(v) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - - // Append the message to the contents - if saveHistory { - msg.AppendTo(contents) - } - msg.Write(c.Writer) - return nil - - case map[string]interface{}: - msg := message.New().Map(v) - if saveHistory { - msg.AppendTo(contents) - } - msg.Write(c.Writer) - return nil - - default: - return bridge.JsException(info.Context(), "SendMessage requires a string or a map") - } - } -} diff --git a/neo/assistant/object.go b/neo/assistant/object.go new file mode 100644 index 00000000..a4bc17ca --- /dev/null +++ b/neo/assistant/object.go @@ -0,0 +1,289 @@ +package assistant + +import ( + "context" + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + jsoniter "github.com/json-iterator/go" + "github.com/yaoapp/gou/process" + v8 "github.com/yaoapp/gou/runtime/v8" + "github.com/yaoapp/gou/runtime/v8/bridge" + "github.com/yaoapp/kun/log" + chatctx "github.com/yaoapp/yao/neo/context" + "github.com/yaoapp/yao/neo/message" + chatMessage "github.com/yaoapp/yao/neo/message" + "rogchap.com/v8go" +) + +// GlobalVariables is the global variables for the assistant +type GlobalVariables struct { + Assistant *Assistant + Contents *chatMessage.Contents + Context *gin.Context + ChatContext chatctx.Context +} + +// JsValue return the javascript value of the global variables +func (global *GlobalVariables) JsValue(ctx *v8go.Context) (*v8go.Value, error) { + return v8go.NewExternal(ctx.Isolate(), global) +} + +// InitObject add the global variables and methods to the script context +func (ast *Assistant) InitObject(v8ctx *v8.Context, c *gin.Context, context chatctx.Context, contents *chatMessage.Contents) { + + // Add global variables to the script context + v8ctx.WithGlobal("__yao_agent_global", &GlobalVariables{ + Assistant: ast, + Contents: contents, + Context: c, + ChatContext: context, + }) + + // Add assistant to the script context + v8ctx.WithGlobal("assistant", ast.Map()) + v8ctx.WithGlobal("context", context.Map()) + + // Add methods to the script contexts + v8ctx.WithFunction("Send", jsSend) + v8ctx.WithFunction("Call", jsCall) +} + +// jsSend function, send a message to the http stream connection +func jsSend(info *v8go.FunctionCallbackInfo) *v8go.Value { + + // Get the message + args := info.Args() + if len(args) < 1 { + return bridge.JsException(info.Context(), "SendMessage requires at least one argument") + } + + input, err := bridge.GoValue(args[0], info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + global, err := global(info) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Save history by default + saveHistory := true + if len(args) > 1 && args[1].IsBoolean() { + saveHistory = args[1].Boolean() + } + + switch v := input.(type) { + case string: + // Check if the message is json + msg, err := message.NewString(v) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Append the message to the contents + if saveHistory { + msg.AppendTo(global.Contents) + } + msg.Write(global.Context.Writer) + return nil + + case map[string]interface{}: + msg := message.New().Map(v) + if saveHistory { + msg.AppendTo(global.Contents) + } + msg.Write(global.Context.Writer) + return nil + + default: + return bridge.JsException(info.Context(), "Send requires a string or a map") + } +} + +func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { + + // Get the args + args := info.Args() + if len(args) < 2 { + return bridge.JsException(info.Context(), "Run requires at least two arguments") + } + + // Get the assistant id + assistantID := args[0].String() + + // Get the assistant + newAst, err := Get(assistantID) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Get the input + input := args[1].String() + + // Get the global variables + global, err := global(info) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Update Context + chatContext := global.ChatContext + chatContext.AssistantID = assistantID + chatContext.ChatID = fmt.Sprintf("chat_%s", uuid.New().String()) // New chat id + chatContext.Silent = true // Silent mode + + var cb func(msg *chatMessage.Message) + if len(args) > 2 { + + // Parse the callback + funcType := "method" + name := "" + userArgs := []interface{}{} + if args[2].IsFunction() { + funcType = "anonymous" + } else { + goValue, err := bridge.GoValue(args[2], info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + switch v := goValue.(type) { + case string: + name = v + case map[string]interface{}: + if fname, ok := v["name"].(string); ok { + name = fname + } + if args, ok := v["args"].([]interface{}); ok { + userArgs = args + } + } + + if strings.Contains(name, ".") { + funcType = "process" + } + } + + switch funcType { + case "anonymous": + source := args[2].String() + cb = func(msg *chatMessage.Message) { + cbArgs := []interface{}{msg} + ctx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) + if err != nil { + fmt.Println("Failed to create context", err.Error()) + return + } + defer ctx.Close() + + global.Assistant.InitObject(ctx, global.Context, chatContext, global.Contents) + _, err = ctx.CallAnonymousWith(context.Background(), source, cbArgs...) + if err != nil { + log.Error("Failed to call the method: %s", err.Error()) + color.Red("Failed to call the method: %s", err.Error()) + return + } + } + break + + case "process": + + cb = func(msg *chatMessage.Message) { + cbArgs := []interface{}{} + cbArgs = append(cbArgs, msg) + cbArgs = append(cbArgs, userArgs...) + p, err := process.Of(name, cbArgs...) + if err != nil { + log.Error("Failed to get the process: %s", err.Error()) + color.Red("Failed to get the process: %s", err.Error()) + return + } + err = p.Execute() + if err != nil { + log.Error("Failed to execute the process: %s", err.Error()) + color.Red("Failed to execute the process: %s", err.Error()) + return + } + defer p.Release() + } + + case "method": + + cb = func(msg *chatMessage.Message) { + cbArgs := []interface{}{} + cbArgs = append(cbArgs, msg) + cbArgs = append(cbArgs, userArgs...) + ctx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) + if err != nil { + return + } + defer ctx.Close() + + global.Assistant.InitObject(ctx, global.Context, global.ChatContext, global.Contents) + _, err = ctx.CallWith(context.Background(), name, cbArgs...) + if err != nil { + log.Error("Failed to call the method: %s", err.Error()) + color.Red("Failed to call the method: %s", err.Error()) + return + } + } + } + + } + + // Parse the options + options := map[string]interface{}{} + if len(args) > 3 { + optionsRaw, err := bridge.GoValue(args[3], info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Parse the options + if optionsRaw != nil { + switch v := optionsRaw.(type) { + case string: + err := jsoniter.UnmarshalFromString(v, &options) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + case map[string]interface{}: + options = v + default: + return bridge.JsException(info.Context(), "Invalid options") + } + } + } + + err = newAst.Execute(global.Context, chatContext, input, options, cb) // Execute the assistant + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + return nil +} + +// global get the global variables +func global(info *v8go.FunctionCallbackInfo) (global *GlobalVariables, err error) { + + jsGlobal, err := info.This().Get("__yao_agent_global") + if err != nil { + return nil, err + } + + // Convert to go interface + goGlobal, err := bridge.GoValue(jsGlobal, info.Context()) + if err != nil { + return nil, err + } + + global, ok := goGlobal.(*GlobalVariables) + if !ok { + return nil, fmt.Errorf("global is not a valid GlobalVariables") + } + + return global, nil +} From 05af5d15141d90fa493b94acbd36ba04c0a976c6 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Feb 2025 14:07:11 +0800 Subject: [PATCH 06/27] Rename Context to GinContext in GlobalVariables struct - Update GlobalVariables struct to use more descriptive GinContext field - Modify all references to Context to use GinContext in assistant methods - Improve clarity of Gin context usage in JavaScript bridge functions --- neo/assistant/object.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/neo/assistant/object.go b/neo/assistant/object.go index a4bc17ca..d043a31c 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -23,7 +23,7 @@ import ( type GlobalVariables struct { Assistant *Assistant Contents *chatMessage.Contents - Context *gin.Context + GinContext *gin.Context ChatContext chatctx.Context } @@ -39,7 +39,7 @@ func (ast *Assistant) InitObject(v8ctx *v8.Context, c *gin.Context, context chat v8ctx.WithGlobal("__yao_agent_global", &GlobalVariables{ Assistant: ast, Contents: contents, - Context: c, + GinContext: c, ChatContext: context, }) @@ -89,7 +89,7 @@ func jsSend(info *v8go.FunctionCallbackInfo) *v8go.Value { if saveHistory { msg.AppendTo(global.Contents) } - msg.Write(global.Context.Writer) + msg.Write(global.GinContext.Writer) return nil case map[string]interface{}: @@ -97,7 +97,7 @@ func jsSend(info *v8go.FunctionCallbackInfo) *v8go.Value { if saveHistory { msg.AppendTo(global.Contents) } - msg.Write(global.Context.Writer) + msg.Write(global.GinContext.Writer) return nil default: @@ -180,7 +180,7 @@ func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { } defer ctx.Close() - global.Assistant.InitObject(ctx, global.Context, chatContext, global.Contents) + global.Assistant.InitObject(ctx, global.GinContext, chatContext, global.Contents) _, err = ctx.CallAnonymousWith(context.Background(), source, cbArgs...) if err != nil { log.Error("Failed to call the method: %s", err.Error()) @@ -223,7 +223,7 @@ func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { } defer ctx.Close() - global.Assistant.InitObject(ctx, global.Context, global.ChatContext, global.Contents) + global.Assistant.InitObject(ctx, global.GinContext, global.ChatContext, global.Contents) _, err = ctx.CallWith(context.Background(), name, cbArgs...) if err != nil { log.Error("Failed to call the method: %s", err.Error()) @@ -259,7 +259,7 @@ func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { } } - err = newAst.Execute(global.Context, chatContext, input, options, cb) // Execute the assistant + err = newAst.Execute(global.GinContext, chatContext, input, options, cb) // Execute the assistant if err != nil { return bridge.JsException(info.Context(), err.Error()) } From df99b9a2286948ae5f3375d3aea5f5d7532834ff Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Feb 2025 18:37:35 +0800 Subject: [PATCH 07/27] Add Plan function to JavaScript bridge in InitObject method - Introduce new jsPlan function to script context initialization - Extend InitObject method with Plan method for script interactions - Enhance JavaScript bridge capabilities in assistant script contexts --- neo/assistant/object.go | 1 + neo/assistant/plan.go | 142 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 neo/assistant/plan.go diff --git a/neo/assistant/object.go b/neo/assistant/object.go index d043a31c..ae0c8021 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -48,6 +48,7 @@ func (ast *Assistant) InitObject(v8ctx *v8.Context, c *gin.Context, context chat v8ctx.WithGlobal("context", context.Map()) // Add methods to the script contexts + v8ctx.WithFunction("Plan", jsPlan) // Create a new plan object v8ctx.WithFunction("Send", jsSend) v8ctx.WithFunction("Call", jsCall) } diff --git a/neo/assistant/plan.go b/neo/assistant/plan.go new file mode 100644 index 00000000..5d0b08bb --- /dev/null +++ b/neo/assistant/plan.go @@ -0,0 +1,142 @@ +package assistant + +import ( + "context" + "fmt" + + "github.com/fatih/color" + "github.com/yaoapp/gou/runtime/v8/bridge" + v8plan "github.com/yaoapp/gou/runtime/v8/objects/plan" + "rogchap.com/v8go" +) + +// TaskFn is the task function +func TaskFn(plan_id string, task_id string, source bool, method string, args ...interface{}) (interface{}, error) { + + if !source { + return v8plan.DefaultTaskFn(plan_id, task_id, source, method, args...) + } + + // Data + plan, err := v8plan.GetPlan(plan_id) + if err != nil { + return nil, err + } + + global, ok := plan.Data().(*GlobalVariables) + if !ok { + return nil, fmt.Errorf("plan data is not a GlobalVariables") + } + + if global.Assistant == nil { + return nil, fmt.Errorf("assistant is not set") + } + + if global.Assistant.Script == nil { + return nil, fmt.Errorf("script is not set") + } + + scriptCtx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) + if err != nil { + return nil, err + } + defer scriptCtx.Close() + + // Initialize the object + global.Assistant.InitObject(scriptCtx, global.GinContext, global.ChatContext, global.Contents) + + fnargs := []interface{}{plan_id, task_id} + fnargs = append(fnargs, args...) + + // Execute the anonymous function + return scriptCtx.CallAnonymousWith(context.Background(), method, fnargs...) + +} + +// SubscribeFn is the default subscribe function +func SubscribeFn(plan_id string, key string, value interface{}, source bool, method string, args ...interface{}) { + + if !source { + v8plan.DefaultSubscribeFn(plan_id, key, value, source, method, args...) + return + } + + // Data + plan, err := v8plan.GetPlan(plan_id) + if err != nil { + color.Red("Failed to get the plan: %s", err.Error()) + return + } + + global, ok := plan.Data().(*GlobalVariables) + if !ok { + color.Red("plan data is not a GlobalVariables") + return + } + + if global.Assistant == nil { + color.Red("assistant is not set") + return + } + + if global.Assistant.Script == nil { + color.Red("script is not set") + return + } + + scriptCtx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) + if err != nil { + color.Red("Failed to create the script context: %s", err.Error()) + return + } + defer scriptCtx.Close() + + fnargs := []interface{}{plan_id, key, value} + fnargs = append(fnargs, args...) + + // Initialize the object + global.Assistant.InitObject(scriptCtx, global.GinContext, global.ChatContext, global.Contents) + _, err = scriptCtx.CallAnonymousWith(context.Background(), method, fnargs...) + if err != nil { + return + } +} + +// jsNewPlan create a plan object and return it +func jsPlan(info *v8go.FunctionCallbackInfo) *v8go.Value { + + global, err := global(info) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + obj := newPlanObject() + + // Export the object + objectTmpl := obj.ExportObject(info.Context().Isolate()) + + args := info.Args() + if len(args) < 1 { + return bridge.JsException(info.Context(), "the first parameter should be a string") + } + + if !args[0].IsString() { + return bridge.JsException(info.Context(), "the first parameter should be a string") + } + + id := args[0].String() + plan, err := objectTmpl.NewInstance(info.Context()) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("failed to create plan object %s", err.Error())) + } + + return obj.NewInstance(id, plan, global) +} + +func newPlanObject() *v8plan.Object { + obj := v8plan.New(v8plan.Options{ + TaskFn: TaskFn, + SubscribeFn: SubscribeFn, + }) + return obj +} From 9c9f137a8ada4a92c5a1a3a93b853279dd4b22da Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 24 Feb 2025 10:44:15 +0800 Subject: [PATCH 08/27] Improve error handling and logging in assistant methods - Add nil checks for callbacks in message and object methods - Enhance error logging in plan subscription with more descriptive messages - Improve write method error handling with additional context logging - Add safeguards against potential nil function calls --- neo/assistant/object.go | 2 +- neo/assistant/plan.go | 10 +++++----- neo/message/message.go | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/neo/assistant/object.go b/neo/assistant/object.go index ae0c8021..2ab2095a 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -138,7 +138,7 @@ func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { chatContext.ChatID = fmt.Sprintf("chat_%s", uuid.New().String()) // New chat id chatContext.Silent = true // Silent mode - var cb func(msg *chatMessage.Message) + var cb func(msg *chatMessage.Message) = nil if len(args) > 2 { // Parse the callback diff --git a/neo/assistant/plan.go b/neo/assistant/plan.go index 5d0b08bb..ee470970 100644 --- a/neo/assistant/plan.go +++ b/neo/assistant/plan.go @@ -64,29 +64,29 @@ func SubscribeFn(plan_id string, key string, value interface{}, source bool, met // Data plan, err := v8plan.GetPlan(plan_id) if err != nil { - color.Red("Failed to get the plan: %s", err.Error()) + color.Red("Subscribe Failed to get the plan: %s", err.Error()) return } global, ok := plan.Data().(*GlobalVariables) if !ok { - color.Red("plan data is not a GlobalVariables") + color.Red("Subscribe Failed: plan data is not a GlobalVariables") return } if global.Assistant == nil { - color.Red("assistant is not set") + color.Red("Subscribe Failed: assistant is not set") return } if global.Assistant.Script == nil { - color.Red("script is not set") + color.Red("Subscribe Failed: script is not set") return } scriptCtx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) if err != nil { - color.Red("Failed to create the script context: %s", err.Error()) + color.Red("Subscribe Failed: Failed to create the script context: %s", err.Error()) return } defer scriptCtx.Close() diff --git a/neo/message/message.go b/neo/message/message.go index 0236b547..02341acd 100644 --- a/neo/message/message.go +++ b/neo/message/message.go @@ -698,10 +698,16 @@ func (m *Message) Callback(fn interface{}) *Message { if fn != nil { switch v := fn.(type) { case func(msg *Message): + if v == nil { + break + } v(m) break case func(): + if v == nil { + break + } v() break @@ -717,8 +723,18 @@ func (m *Message) Callback(fn interface{}) *Message { func (m *Message) Write(w gin.ResponseWriter) bool { defer func() { if r := recover(); r != nil { + + // Ignore if done is true + if m.IsDone { + return + } + message := "Write Response Exception: (if client close the connection, it's normal) \n %s\n\n" color.Red(message, r) + + // Print the message + raw, _ := jsoniter.MarshalToString(m) + color.White("Message:\n %s", raw) } }() From 3c51f16d959f2636a1b8eb471631f46efaad1213 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 24 Feb 2025 14:33:04 +0800 Subject: [PATCH 09/27] Refactor assistant script loading and improve error diagnostics - Comment out assistant script loading in script package - Enhance global variable error message with detailed type information - Prepare for moving assistant script loading to a dedicated package --- neo/assistant/object.go | 2 +- script/script.go | 35 +++++++++++++++++------------------ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/neo/assistant/object.go b/neo/assistant/object.go index 2ab2095a..ef18de3e 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -283,7 +283,7 @@ func global(info *v8go.FunctionCallbackInfo) (global *GlobalVariables, err error global, ok := goGlobal.(*GlobalVariables) if !ok { - return nil, fmt.Errorf("global is not a valid GlobalVariables") + return nil, fmt.Errorf("global is not a valid GlobalVariables. %#v", goGlobal) } return global, nil diff --git a/script/script.go b/script/script.go index 15d25c81..18b5fe48 100644 --- a/script/script.go +++ b/script/script.go @@ -2,7 +2,6 @@ package script import ( "fmt" - "strings" "github.com/yaoapp/gou/application" v8 "github.com/yaoapp/gou/runtime/v8" @@ -26,26 +25,26 @@ func Load(cfg config.Config) error { return err } - // Load assistants - err = application.App.Walk("assistants", func(root, file string, isdir bool) error { - if isdir { - return nil - } + // Load assistants - Move to the neo assistant package + // err = application.App.Walk("assistants", func(root, file string, isdir bool) error { + // if isdir { + // return nil + // } - // Keep the src.index only - if !strings.HasSuffix(file, "src/index.ts") { - return nil - } + // // Keep the src.index only + // if !strings.HasSuffix(file, "src/index.ts") { + // return nil + // } - id := fmt.Sprintf("assistants.%s", share.ID(root, file)) - id = strings.TrimSuffix(id, ".src.index") - _, err := v8.Load(file, id) - return err - }, exts...) + // id := fmt.Sprintf("assistants.%s", share.ID(root, file)) + // id = strings.TrimSuffix(id, ".src.index") + // _, err := v8.Load(file, id) + // return err + // }, exts...) - if err != nil { - return err - } + // if err != nil { + // return err + // } return application.App.Walk("services", func(root, file string, isdir bool) error { if isdir { From 101927e343b545f5282e9e8e1fdc73a3b59341ff Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 24 Feb 2025 17:24:34 +0800 Subject: [PATCH 10/27] Add thread-safe write method with mutex synchronization - Introduce sync.Mutex to protect concurrent writes to response writer - Add locking mechanism in Write method to prevent race conditions - Ensure safe and synchronized message writing in multi-threaded contexts --- neo/message/message.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/neo/message/message.go b/neo/message/message.go index 02341acd..09832edc 100644 --- a/neo/message/message.go +++ b/neo/message/message.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "sync" "github.com/fatih/color" "github.com/gin-gonic/gin" @@ -15,6 +16,8 @@ import ( "github.com/yaoapp/yao/openai" ) +var locker = sync.Mutex{} + // Message the message type Message struct { ID string `json:"id,omitempty"` // id for the message @@ -721,6 +724,11 @@ func (m *Message) Callback(fn interface{}) *Message { // Write writes the message to response writer func (m *Message) Write(w gin.ResponseWriter) bool { + + // Sync write to response writer + locker.Lock() + defer locker.Unlock() + defer func() { if r := recover(); r != nil { From 358f327dee8af80088b26fe520f90fc47896f549 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 24 Feb 2025 19:29:57 +0800 Subject: [PATCH 11/27] Improve native tool calls handling in assistant streaming - Refactor tool calls processing to handle multiple tool calls more robustly - Add new message flags (IsBeginTool, IsEndTool) to track tool call stages - Modify streaming logic to correctly wrap and manage tool call messages - Ensure only the first tool call is fully processed and displayed --- neo/assistant/api.go | 49 +++++++++++++++++++++--------------------- neo/message/message.go | 28 ++++++++++++++++-------- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 79508e36..6222f1ba 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -334,8 +334,7 @@ func (ast *Assistant) streamChat( isFirstThink := true isThinking := false - isFirstTool := true - isTool := false + toolsCount := 0 currentMessageID := "" err := ast.Chat(c.Request.Context(), messages, options, func(data []byte) int { select { @@ -401,32 +400,34 @@ func (ast *Assistant) streamChat( contents.ClearToken() } - // for native tool_calls response + // for native tool_calls response, keep the first tool_calls_native message if msg.Type == "tool_calls_native" { - if isFirstTool { - msg.Text = "\n\n" + msg.Text // add the tool_calls begin tag - isFirstTool = false - isTool = true - } - } - // for tool response - if isTool && msg.Type != "tool_calls_native" { - - if msg.IsDone { - end := chatMessage.New().Map(map[string]interface{}{"text": "}\n\n", "type": "tool", "delta": true}) - end.ID = currentMessageID - end.Retry = ctx.Retry - end.Silent = ctx.Silent - end.Callback(cb).Write(c.Writer) - end.AppendTo(contents) - contents.UpdateType("tool", map[string]interface{}{"text": contents.Text()}, currentMessageID) - isTool = false - } else { - msg.Text = "\n\n" + msg.Text // add the tool_calls close tag + if toolsCount > 1 { + msg.Text = "" // clear the text + msg.Type = "text" + msg.IsNew = false + return 1 // continue } - isTool = false + if msg.IsBeginTool { + + if toolsCount == 1 { + msg.IsNew = false + msg.Text = "\n\n" // add the tool_calls close tag + } + + if toolsCount == 0 { + msg.Text = "\n\n" + msg.Text // add the tool_calls begin tag + } + + toolsCount++ + + } + + if msg.IsEndTool { + msg.Text = msg.Text + "\n\n" // add the tool_calls close tag + } } delta := msg.String() diff --git a/neo/message/message.go b/neo/message/message.go index 09832edc..92c7df2c 100644 --- a/neo/message/message.go +++ b/neo/message/message.go @@ -40,6 +40,9 @@ type Message struct { Hidden bool `json:"hidden,omitempty"` // hidden for the message (not show in the UI and history) Retry bool `json:"retry,omitempty"` // retry for the message Silent bool `json:"silent,omitempty"` // silent for the message (not show in the UI and history) + IsTool bool `json:"-"` // is tool for the message for native tool_calls + IsBeginTool bool `json:"-"` // is new tool for the message for native tool_calls + IsEndTool bool `json:"-"` // is end tool for the message for native tool_calls } // Mention represents a mention @@ -224,19 +227,26 @@ func NewOpenAI(data []byte, isThinking bool) *Message { } // Tool calls - if len(chunk.Choices[0].Delta.ToolCalls) > 0 { + if len(chunk.Choices[0].Delta.ToolCalls) > 0 || chunk.Choices[0].FinishReason == "tool_calls" { msg.Type = "tool_calls_native" - id := chunk.Choices[0].Delta.ToolCalls[0].ID - function := chunk.Choices[0].Delta.ToolCalls[0].Function.Name - arguments := chunk.Choices[0].Delta.ToolCalls[0].Function.Arguments - text := arguments - if id != "" { - text = fmt.Sprintf(`{"id": "%s", "function": "%s", "arguments": %s`, id, function, arguments) - msg.IsNew = true // mark as a new message + text := "" + if len(chunk.Choices[0].Delta.ToolCalls) > 0 { + id := chunk.Choices[0].Delta.ToolCalls[0].ID + function := chunk.Choices[0].Delta.ToolCalls[0].Function.Name + arguments := chunk.Choices[0].Delta.ToolCalls[0].Function.Arguments + text = arguments + if id != "" { + msg.IsBeginTool = true + msg.IsNew = true // mark as a new message + text = fmt.Sprintf(`{"id": "%s", "function": "%s", "arguments": %s`, id, function, arguments) + } + } + + if chunk.Choices[0].FinishReason == "tool_calls" { + msg.IsEndTool = true } msg.Text = text - msg.IsDone = chunk.Choices[0].FinishReason == "tool_calls" // is done when tool calls are finished return msg } From 5475e0ab39f3dcdaddc7818cba3c7e37228cfa13 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 25 Feb 2025 11:27:04 +0800 Subject: [PATCH 12/27] Add Assets method to assistant for retrieving and processing asset files - Implement Assets method in Assistant struct to read asset files - Add jsAssets function to JavaScript bridge for accessing assets - Support optional data replacement in asset file content - Enable dynamic asset retrieval with optional template data processing --- neo/assistant/api.go | 18 ++++++++------- neo/assistant/assistant.go | 25 +++++++++++++++++++++ neo/assistant/object.go | 45 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 6222f1ba..2e183f44 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -242,14 +242,16 @@ func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *c // Create a new Text // Send loading message and mark as new - msg := chatMessage.New().Map(map[string]interface{}{ - "new": true, - "role": "assistant", - "type": "loading", - "props": map[string]interface{}{"placeholder": "Calling " + assistant.Name}, - }) - msg.Assistant(assistant.ID, assistant.Name, assistant.Avatar) - msg.Write(c.Writer) + if !ctx.Silent { + msg := chatMessage.New().Map(map[string]interface{}{ + "new": true, + "role": "assistant", + "type": "loading", + "props": map[string]interface{}{"placeholder": "Calling " + assistant.Name}, + }) + msg.Assistant(assistant.ID, assistant.Name, assistant.Avatar) + msg.Write(c.Writer) + } newContents := chatMessage.NewContents() // Update the context id diff --git a/neo/assistant/assistant.go b/neo/assistant/assistant.go index e320f91e..7af06d0b 100644 --- a/neo/assistant/assistant.go +++ b/neo/assistant/assistant.go @@ -3,12 +3,15 @@ package assistant import ( "context" "fmt" + "path" "time" "github.com/fatih/color" jsoniter "github.com/json-iterator/go" + "github.com/yaoapp/gou/fs" "github.com/yaoapp/gou/rag/driver" "github.com/yaoapp/kun/log" + sui "github.com/yaoapp/yao/sui/core" ) // Save save the assistant @@ -156,6 +159,28 @@ func (ast *Assistant) Validate() error { return nil } +// Assets get the assets content +func (ast *Assistant) Assets(name string, data sui.Data) (string, error) { + + app, err := fs.Get("app") + if err != nil { + return "", err + } + + root := path.Join(ast.Path, "assets", name) + raw, err := app.ReadFile(root) + if err != nil { + return "", err + } + + if data != nil { + content, _ := data.Replace(string(raw)) + return content, nil + } + + return string(raw), nil +} + // Clone creates a deep copy of the assistant func (ast *Assistant) Clone() *Assistant { if ast == nil { diff --git a/neo/assistant/object.go b/neo/assistant/object.go index ef18de3e..af1eb9fc 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -51,6 +51,51 @@ func (ast *Assistant) InitObject(v8ctx *v8.Context, c *gin.Context, context chat v8ctx.WithFunction("Plan", jsPlan) // Create a new plan object v8ctx.WithFunction("Send", jsSend) v8ctx.WithFunction("Call", jsCall) + v8ctx.WithFunction("Assets", jsAssets) +} + +// jsAssets function, get the assets content +func jsAssets(info *v8go.FunctionCallbackInfo) *v8go.Value { + + global, err := global(info) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Get the message + args := info.Args() + if len(args) < 1 { + return bridge.JsException(info.Context(), "Assets requires at least one argument") + } + + // Get the name + name := args[0].String() + + data := map[string]interface{}{} + if len(args) > 1 { + raw, err := bridge.GoValue(args[1], info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + v, ok := raw.(map[string]interface{}) + if !ok { + return bridge.JsException(info.Context(), "Assets requires a map") + } + data = v + } + + content, err := global.Assistant.Assets(name, data) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + jsContent, err := bridge.JsValue(info.Context(), content) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + return jsContent } // jsSend function, send a message to the http stream connection From 5fe43f81034ab069fc5d67e2905017011ad6ab6a Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 25 Feb 2025 16:11:53 +0800 Subject: [PATCH 13/27] Enhance JavaScript bridge argument handling in assistant method calls - Add support for passing additional arguments to callback functions - Implement flexible argument parsing for JavaScript method calls - Handle both array and single argument scenarios - Update method signature to accommodate optional arguments --- neo/assistant/object.go | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/neo/assistant/object.go b/neo/assistant/object.go index af1eb9fc..9a0e36d9 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -186,6 +186,31 @@ func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { var cb func(msg *chatMessage.Message) = nil if len(args) > 2 { + // Rest args + var jsArgs *v8go.Value + if len(args) > 3 { + jsArgs = args[3] + } + + goArgs := []interface{}{} + if jsArgs.IsArray() { + v, err := bridge.GoValue(jsArgs, info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + arr, ok := v.([]interface{}) + if !ok { + return bridge.JsException(info.Context(), "Invalid arguments") + } + goArgs = arr + } else { + v, err := bridge.GoValue(jsArgs, info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + goArgs = []interface{}{v} + } + // Parse the callback funcType := "method" name := "" @@ -219,6 +244,7 @@ func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { source := args[2].String() cb = func(msg *chatMessage.Message) { cbArgs := []interface{}{msg} + cbArgs = append(cbArgs, goArgs...) ctx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) if err != nil { fmt.Println("Failed to create context", err.Error()) @@ -283,8 +309,8 @@ func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { // Parse the options options := map[string]interface{}{} - if len(args) > 3 { - optionsRaw, err := bridge.GoValue(args[3], info.Context()) + if len(args) > 4 { + optionsRaw, err := bridge.GoValue(args[4], info.Context()) if err != nil { return bridge.JsException(info.Context(), err.Error()) } From f1e6c2c62faebc6d767f7b878a93713e1a2e2496 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 25 Feb 2025 17:32:58 +0800 Subject: [PATCH 14/27] Add result handling in assistant streaming and hook responses - Introduce Result field in ResHookDone struct to support additional data - Update NextAction.Execute method to pass optional callback arguments - Modify streamChat method to propagate result data to output messages - Add Result field to Message struct for flexible result storage --- neo/assistant/api.go | 17 ++++++++++++----- neo/assistant/hooks.go | 5 +++++ neo/assistant/types.go | 1 + neo/message/message.go | 1 + 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 2e183f44..660b848a 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -131,7 +131,7 @@ func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, input []chatM } // Execute the next action -func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *chatMessage.Contents) error { +func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *chatMessage.Contents, callback ...interface{}) error { switch next.Action { // It's not used, because the process could be executed in the hook script @@ -256,7 +256,7 @@ func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *c // Update the context id ctx.AssistantID = assistant.ID - return assistant.execute(c, ctx, messages, options, newContents) + return assistant.execute(c, ctx, messages, options, newContents, callback...) case "exit": return nil @@ -534,7 +534,8 @@ func (ast *Assistant) streamChat( // Some error occurred in the hook, return the error if hookErr != nil { - chatMessage.New().Error(hookErr.Error()).Done().Write(c.Writer) + chatMessage.New().Error(hookErr.Error()).Done().Callback(cb).Write(c.Writer) + done <- true return 0 // break } @@ -544,9 +545,9 @@ func (ast *Assistant) streamChat( // If the hook is successful, execute the next action if res != nil && res.Next != nil { - err := res.Next.Execute(c, ctx, contents) + err := res.Next.Execute(c, ctx, contents, cb) if err != nil { - chatMessage.New().Error(err.Error()).Done().Write(c.Writer) + chatMessage.New().Error(err.Error()).Done().Callback(cb).Write(c.Writer) } done <- true return 0 // break @@ -559,6 +560,12 @@ func (ast *Assistant) streamChat( output.Retry = ctx.Retry output.Silent = ctx.Silent } + + // has result + if res != nil && res.Result != nil && cb != nil { + output.Result = res.Result // Add the result to the output message + } + output.Callback(cb).Write(c.Writer) done <- true return 0 // break diff --git a/neo/assistant/hooks.go b/neo/assistant/hooks.go index 05812e4b..e099ee10 100644 --- a/neo/assistant/hooks.go +++ b/neo/assistant/hooks.go @@ -217,6 +217,11 @@ func (ast *Assistant) HookDone(c *gin.Context, context chatctx.Context, input [] response.Output = vv } + // has result + if res, has := v["result"]; has { + response.Result = res + } + if res, ok := v["next"].(map[string]interface{}); ok { response.Next = &NextAction{} if name, ok := res["action"].(string); ok { diff --git a/neo/assistant/types.go b/neo/assistant/types.go index 394b21f2..b7d8d211 100644 --- a/neo/assistant/types.go +++ b/neo/assistant/types.go @@ -58,6 +58,7 @@ type ResHookDone struct { Next *NextAction `json:"next,omitempty"` Input []message.Message `json:"input,omitempty"` Output []message.Data `json:"output,omitempty"` + Result any `json:"result,omitempty"` } // ResHookFail the response of the fail hook diff --git a/neo/message/message.go b/neo/message/message.go index 92c7df2c..b57147a2 100644 --- a/neo/message/message.go +++ b/neo/message/message.go @@ -43,6 +43,7 @@ type Message struct { IsTool bool `json:"-"` // is tool for the message for native tool_calls IsBeginTool bool `json:"-"` // is new tool for the message for native tool_calls IsEndTool bool `json:"-"` // is end tool for the message for native tool_calls + Result any `json:"result,omitempty"` // result for the message } // Mention represents a mention From 6b15a1763eb52c12a1bb29f6201fb5cbf95eead1 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 26 Feb 2025 08:27:16 +0800 Subject: [PATCH 15/27] Enhance message processing in assistant chat method - Implement comprehensive message formatting function - Add filtering for duplicate messages - Reorder messages to ensure proper system and user message placement - Merge consecutive assistant messages - Improve message preprocessing for API requests --- neo/assistant/api.go | 142 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 138 insertions(+), 4 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 660b848a..4dd2111c 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -801,13 +801,144 @@ func (ast *Assistant) Chat(ctx context.Context, messages []chatMessage.Message, return nil } -func (ast *Assistant) requestMessages(ctx context.Context, messages []chatMessage.Message) ([]map[string]interface{}, error) { +// formatMessages processes messages to ensure they meet the required standards: +// 1. Filters out duplicate messages with identical content, role, and name +// 2. Moves system messages to the beginning while preserving the order of other messages +// 3. Ensures the first non-system message is a user message (removes leading assistant messages) +// 4. Ensures the last message is a user message (removes trailing assistant messages) +// 5. Merges consecutive assistant messages from the same assistant +func formatMessages(messages []map[string]interface{}) []map[string]interface{} { + // Filter out duplicate messages with identical content, role, and name + filteredMessages := []map[string]interface{}{} + seen := make(map[string]bool) + for _, msg := range messages { + // Create a unique key for each message based on role, content, and name + role := msg["role"].(string) + content := fmt.Sprintf("%v", msg["content"]) // Convert to string regardless of type + + // Get name if it exists + name := "" + if nameVal, exists := msg["name"]; exists { + name = fmt.Sprintf("%v", nameVal) + } + + // Create a unique key for this message + key := fmt.Sprintf("%s:%s:%s", role, content, name) + + // If we haven't seen this message before, add it to filtered messages + if !seen[key] { + filteredMessages = append(filteredMessages, msg) + seen[key] = true + } + } + + // Separate system messages while preserving the order of other messages + systemMessages := []map[string]interface{}{} + otherMessages := []map[string]interface{}{} + + for _, msg := range filteredMessages { + if msg["role"].(string) == "system" { + systemMessages = append(systemMessages, msg) + } else { + otherMessages = append(otherMessages, msg) + } + } + + // Ensure the first non-system message is a user message + // If there are no user messages or the first message is not a user message, remove leading assistant messages + validOtherMessages := []map[string]interface{}{} + foundUserMessage := false + + for _, msg := range otherMessages { + if msg["role"].(string) == "user" { + foundUserMessage = true + validOtherMessages = append(validOtherMessages, msg) + } else if foundUserMessage { + // Only keep assistant messages that come after a user message + validOtherMessages = append(validOtherMessages, msg) + } + // Skip assistant messages that come before any user message + } + + // If no valid messages remain, return just the system messages + if len(validOtherMessages) == 0 { + return systemMessages + } + + // Ensure the last message is a user message + // Remove any trailing assistant messages + lastUserIndex := -1 + for i := len(validOtherMessages) - 1; i >= 0; i-- { + if validOtherMessages[i]["role"].(string) == "user" { + lastUserIndex = i + break + } + } + + // If we found a user message, trim any assistant messages after it + if lastUserIndex >= 0 && lastUserIndex < len(validOtherMessages)-1 { + validOtherMessages = validOtherMessages[:lastUserIndex+1] + } + + // If there are no user messages left after filtering, return just the system messages + if len(validOtherMessages) == 0 { + return systemMessages + } + + // Combine system messages first, followed by other valid messages in their original order + orderedMessages := append(systemMessages, validOtherMessages...) + + // Merge consecutive assistant messages + mergedMessages := []map[string]interface{}{} + var lastMessage map[string]interface{} + + for _, msg := range orderedMessages { + // If this is the first message, just add it + if lastMessage == nil { + mergedMessages = append(mergedMessages, msg) + lastMessage = msg + continue + } + + // If both current and last messages are from assistant, check if they can be merged + if msg["role"].(string) == "assistant" && lastMessage["role"].(string) == "assistant" { + // Get name information + nameVal, hasName := msg["name"] + + // Prepare name prefix for the content + namePrefix := "" + if hasName { + namePrefix = fmt.Sprintf("[%v]: ", nameVal) + } + + // Merge the content, including name information if available + lastContent := fmt.Sprintf("%v", lastMessage["content"]) + content := fmt.Sprintf("%v", msg["content"]) + + // Add the name prefix to the content + if namePrefix != "" { + content = namePrefix + content + } + + // Merge the messages + lastMessage["content"] = lastContent + "\n" + content + continue + } + + // If we can't merge, add as a new message + mergedMessages = append(mergedMessages, msg) + lastMessage = msg + } + + return mergedMessages +} + +func (ast *Assistant) requestMessages(ctx context.Context, messages []chatMessage.Message) ([]map[string]interface{}, error) { newMessages := []map[string]interface{}{} length := len(messages) for index, message := range messages { - // Ignore the tool, think, error if message.Type == "tool" || message.Type == "think" || message.Type == "error" { continue @@ -870,15 +1001,18 @@ func (ast *Assistant) requestMessages(ctx context.Context, messages []chatMessag newMessages = append(newMessages, newMessage) } + // Process messages to standardize format, filter duplicates, and merge consecutive assistant messages + processedMessages := formatMessages(newMessages) + // For debug environment, print the request messages if os.Getenv("YAO_AGENT_PRINT_REQUEST_MESSAGES") == "true" { - for _, message := range newMessages { + for _, message := range processedMessages { raw, _ := jsoniter.MarshalToString(message) log.Trace("[Request Message] %s", raw) } } - return newMessages, nil + return processedMessages, nil } func (ast *Assistant) withAttachments(ctx context.Context, msg *chatMessage.Message) ([]map[string]interface{}, error) { From ae599d9b83d447fbbc3299048403b0c880f8b09d Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 27 Feb 2025 15:15:27 +0800 Subject: [PATCH 16/27] Improve JavaScript argument handling in jsCall method - Add null check for jsArgs to prevent potential nil pointer dereferences - Refactor argument parsing logic to handle optional arguments more robustly - Ensure safe conversion of JavaScript arguments to Go slice - Enhance error handling for argument type conversion --- neo/assistant/object.go | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/neo/assistant/object.go b/neo/assistant/object.go index 9a0e36d9..3ca75ec9 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -188,27 +188,28 @@ func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { // Rest args var jsArgs *v8go.Value + goArgs := []interface{}{} if len(args) > 3 { jsArgs = args[3] - } - - goArgs := []interface{}{} - if jsArgs.IsArray() { - v, err := bridge.GoValue(jsArgs, info.Context()) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) + if jsArgs != nil { + if jsArgs.IsArray() { + v, err := bridge.GoValue(jsArgs, info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + arr, ok := v.([]interface{}) + if !ok { + return bridge.JsException(info.Context(), "Invalid arguments") + } + goArgs = arr + } else { + v, err := bridge.GoValue(jsArgs, info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + goArgs = []interface{}{v} + } } - arr, ok := v.([]interface{}) - if !ok { - return bridge.JsException(info.Context(), "Invalid arguments") - } - goArgs = arr - } else { - v, err := bridge.GoValue(jsArgs, info.Context()) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - goArgs = []interface{}{v} } // Parse the callback From ca6d075a2271f61ec8eb89a287f20d8dda89f661 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 27 Feb 2025 16:32:25 +0800 Subject: [PATCH 17/27] Add shared space methods to JavaScript bridge - Implement Set, Get, Del, and Clear methods for shared space interactions - Add error handling and validation for shared space method calls - Enhance context management with Release method to clear shared space - Update context initialization to create a default memory shared space --- neo/api.go | 1 + neo/assistant/object.go | 139 ++++++++++++++++++++++++++++++++++++++++ neo/context/context.go | 17 ++++- 3 files changed, 154 insertions(+), 3 deletions(-) diff --git a/neo/api.go b/neo/api.go index d4d34913..71e866da 100644 --- a/neo/api.go +++ b/neo/api.go @@ -230,6 +230,7 @@ func (neo *DSL) handleChat(c *gin.Context) { // Set the context with validated chat_id ctx, cancel := chatctx.NewWithCancel(sid, chatID, c.Query("context")) defer cancel() + defer ctx.Release() // Release the context after the request is done neo.Answer(ctx, content, c) } diff --git a/neo/assistant/object.go b/neo/assistant/object.go index 3ca75ec9..42971e91 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -52,6 +52,145 @@ func (ast *Assistant) InitObject(v8ctx *v8.Context, c *gin.Context, context chat v8ctx.WithFunction("Send", jsSend) v8ctx.WithFunction("Call", jsCall) v8ctx.WithFunction("Assets", jsAssets) + + // Shared space methods + v8ctx.WithFunction("Set", jsSet) + v8ctx.WithFunction("Get", jsGet) + v8ctx.WithFunction("Del", jsDel) + v8ctx.WithFunction("Clear", jsClear) +} + +// jsSet function, set a value to the shared space +func jsSet(info *v8go.FunctionCallbackInfo) *v8go.Value { + global, err := global(info) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + if global.ChatContext.SharedSpace == nil { + return bridge.JsException(info.Context(), "Shared space is not set") + } + + args := info.Args() + if len(args) < 2 { + return bridge.JsException(info.Context(), "Set requires at least two arguments") + } + + if !args[0].IsString() { + return bridge.JsException(info.Context(), "Set requires a valid key") + } + + // Validate the key + key := args[0].String() + if key == "" { + return bridge.JsException(info.Context(), "Set requires a valid key") + } + + // Validate the value + value, err := bridge.GoValue(args[1], info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Set the value + err = global.ChatContext.SharedSpace.Set(key, value) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + return nil +} + +// jsGet function, get a value from the shared space +func jsGet(info *v8go.FunctionCallbackInfo) *v8go.Value { + global, err := global(info) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + if global.ChatContext.SharedSpace == nil { + return bridge.JsException(info.Context(), "Shared space is not set") + } + + args := info.Args() + if len(args) < 1 { + return bridge.JsException(info.Context(), "Get requires at least one argument") + } + + if !args[0].IsString() { + return bridge.JsException(info.Context(), "Get requires a valid key") + } + + // Get the key + key := args[0].String() + if key == "" { + return bridge.JsException(info.Context(), "Get requires a valid key") + } + + // Get the value + value, err := global.ChatContext.SharedSpace.Get(key) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + jsValue, err := bridge.JsValue(info.Context(), value) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + return jsValue +} + +// jsDel function, delete a value from the shared space +func jsDel(info *v8go.FunctionCallbackInfo) *v8go.Value { + global, err := global(info) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + if global.ChatContext.SharedSpace == nil { + return bridge.JsException(info.Context(), "Shared space is not set") + } + + args := info.Args() + if len(args) < 1 { + return bridge.JsException(info.Context(), "Get requires at least one argument") + } + + if !args[0].IsString() { + return bridge.JsException(info.Context(), "Get requires a valid key") + } + + // Get the key + key := args[0].String() + if key == "" { + return bridge.JsException(info.Context(), "Get requires a valid key") + } + + err = global.ChatContext.SharedSpace.Delete(key) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + return nil +} + +func jsClear(info *v8go.FunctionCallbackInfo) *v8go.Value { + global, err := global(info) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + if global.ChatContext.SharedSpace == nil { + return bridge.JsException(info.Context(), "Shared space is not set") + } + + err = global.ChatContext.SharedSpace.Clear() + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + return nil } // jsAssets function, get the assets content diff --git a/neo/context/context.go b/neo/context/context.go index 4962c3a0..26cc5450 100644 --- a/neo/context/context.go +++ b/neo/context/context.go @@ -5,6 +5,7 @@ import ( "time" jsoniter "github.com/json-iterator/go" + "github.com/yaoapp/gou/plan" "github.com/yaoapp/kun/log" ) @@ -14,8 +15,8 @@ type Context struct { Sid string `json:"sid" yaml:"-"` // Session ID ChatID string `json:"chat_id,omitempty"` // Chat ID, use to select chat AssistantID string `json:"assistant_id,omitempty"` // Assistant ID, use to select assistant - Stack string `json:"stack,omitempty"` - Path string `json:"pathname,omitempty"` + Stack string `json:"stack,omitempty"` // will be removed in the future + Path string `json:"pathname,omitempty"` // wiil be rename to path FormData map[string]interface{} `json:"formdata,omitempty"` Field *Field `json:"field,omitempty"` Namespace string `json:"namespace,omitempty"` @@ -26,6 +27,7 @@ type Context struct { Upload *FileUpload `json:"upload,omitempty"` Version bool `json:"version,omitempty"` // Version support RAG bool `json:"rag,omitempty"` // RAG support + SharedSpace plan.Space `json:"-"` // Shared space } // Field the context field @@ -47,7 +49,8 @@ type FileUpload struct { // New create a new context func New(sid, cid, payload string) Context { - ctx := Context{Context: context.Background(), Sid: sid, ChatID: cid} + + ctx := Context{Context: context.Background(), Sid: sid, ChatID: cid, SharedSpace: plan.NewMemorySharedSpace()} if payload == "" { return ctx } @@ -56,6 +59,7 @@ func New(sid, cid, payload string) Context { if err != nil { log.Error("%s", err.Error()) } + return ctx } @@ -85,6 +89,13 @@ func WithTimeout(parent Context, timeout time.Duration) (Context, context.Cancel return parent, cancel } +// Release the context +func (ctx *Context) Release() { + ctx.SharedSpace.Clear() + ctx.SharedSpace = nil + ctx = nil +} + // Map the context to a map func (ctx *Context) Map() map[string]interface{} { data := map[string]interface{}{ From 86dd04728df2a095e2b8f84bd94bf8bf0124ae0b Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 28 Feb 2025 20:13:59 +0800 Subject: [PATCH 18/27] Enhance input handling and message processing in assistant methods - Modify Execute and withHistory methods to support multiple input types - Add flexible type conversion for input messages using jsoniter - Implement robust handling of string, map, and slice input formats - Update method signatures to accept interface{} for more dynamic input processing - Improve error handling during input marshaling and unmarshaling --- neo/assistant/api.go | 58 ++++- neo/assistant/call.go | 502 ++++++++++++++++++++++++++++++++++++++++ neo/assistant/object.go | 224 ++---------------- neo/assistant/plan.go | 4 +- neo/assistant/types.go | 2 +- 5 files changed, 580 insertions(+), 210 deletions(-) create mode 100644 neo/assistant/call.go diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 4dd2111c..e9543929 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -46,7 +46,7 @@ func GetByConnector(connector string, name string) (*Assistant, error) { } // Execute implements the execute functionality -func (ast *Assistant) Execute(c *gin.Context, ctx chatctx.Context, input string, options map[string]interface{}, callback ...interface{}) error { +func (ast *Assistant) Execute(c *gin.Context, ctx chatctx.Context, input interface{}, options map[string]interface{}, callback ...interface{}) error { contents := chatMessage.NewContents() messages, err := ast.withHistory(ctx, input) if err != nil { @@ -56,7 +56,27 @@ func (ast *Assistant) Execute(c *gin.Context, ctx chatctx.Context, input string, } // Execute implements the execute functionality -func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, input []chatMessage.Message, userOptions map[string]interface{}, contents *chatMessage.Contents, callback ...interface{}) error { +func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, userInput interface{}, userOptions map[string]interface{}, contents *chatMessage.Contents, callback ...interface{}) error { + + var input []chatMessage.Message + + switch v := userInput.(type) { + case string: + input = []chatMessage.Message{{Role: "user", Text: v}} + + case []interface{}: + raw, err := jsoniter.Marshal(v) + if err != nil { + return fmt.Errorf("marshal input error: %s", err.Error()) + } + err = jsoniter.Unmarshal(raw, &input) + if err != nil { + return fmt.Errorf("unmarshal input error: %s", err.Error()) + } + + case []chatMessage.Message: + input = v + } if contents == nil { contents = chatMessage.NewContents() @@ -742,12 +762,25 @@ func (ast *Assistant) withPrompts(messages []chatMessage.Message) []chatMessage. func (ast *Assistant) withHistory(ctx chatctx.Context, input interface{}) ([]chatMessage.Message, error) { - var userMessage *chatMessage.Message = chatMessage.New() + var userMessage *chatMessage.Message + var inputMessages []*chatMessage.Message switch v := input.(type) { case string: - userMessage.Map(map[string]interface{}{"role": "user", "content": v}) + userMessage = chatMessage.New().Map(map[string]interface{}{"role": "user", "content": v}) + case map[string]interface{}: - userMessage.Map(v) + userMessage = chatMessage.New().Map(v) + + case []interface{}: + raw, err := jsoniter.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshal input error: %s", err.Error()) + } + err = jsoniter.Unmarshal(raw, &inputMessages) + if err != nil { + return nil, fmt.Errorf("unmarshal input error: %s", err.Error()) + } + case chatMessage.Message: userMessage = &v case *chatMessage.Message: @@ -757,7 +790,6 @@ func (ast *Assistant) withHistory(ctx chatctx.Context, input interface{}) ([]cha } messages := []chatMessage.Message{} - if storage != nil { history, err := storage.GetHistory(ctx.Sid, ctx.ChatID) if err != nil { @@ -778,7 +810,19 @@ func (ast *Assistant) withHistory(ctx chatctx.Context, input interface{}) ([]cha messages = ast.withPrompts(messages) // Add user message - messages = append(messages, *userMessage) + if userMessage != nil { + messages = append(messages, *userMessage) + } + + // Add input messages + if len(inputMessages) > 0 { + for _, msg := range inputMessages { + if msg == nil || msg.Role == "" { + continue + } + messages = append(messages, *msg) + } + } return messages, nil } diff --git a/neo/assistant/call.go b/neo/assistant/call.go new file mode 100644 index 00000000..4694c675 --- /dev/null +++ b/neo/assistant/call.go @@ -0,0 +1,502 @@ +package assistant + +import ( + "context" + "fmt" + + "github.com/fatih/color" + "github.com/google/uuid" + "github.com/yaoapp/gou/runtime/v8/bridge" + "github.com/yaoapp/kun/log" + chatctx "github.com/yaoapp/yao/neo/context" + chatMessage "github.com/yaoapp/yao/neo/message" + "rogchap.com/v8go" +) + +// objectCall is the object for the call function +type objectCall struct{} + +// OptionsCall is the options for the call function +type OptionsCall struct { + Retry OptionsCallRetry `json:"retry,omitempty"` + Options map[string]interface{} `json:"options,omitempty"` +} + +// OptionsCallRetry is the retry options for the call function +type OptionsCallRetry struct { + Times int `json:"times,omitempty"` + Delay int `json:"delay,omitempty"` + DelayMax int `json:"delay_max,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +// allowedEvents is the allowed events for the call function +var allowedEvents = map[string]bool{ + "done": true, + "retry": true, + "error": true, + "message": true, +} + +var callProps = []string{ + "assistant_id", + "input", + "options", +} + +// jsNewPlan create a plan object and return it +func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { + + args := info.Args() + if len(args) < 2 { + return bridge.JsException(info.Context(), "Run requires at least two arguments") + } + + options := v8go.Undefined(info.Context().Isolate()) + if len(args) > 2 { + options = args[2] + } + + // Export the object + obj := &objectCall{} + objectTmpl := obj.ExportObject(info) + this, err := objectTmpl.NewInstance(info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Copy global properties + global := info.This() + for _, prop := range objectProperties { + if !global.Has(prop) { + continue + } + value, err := global.Get(prop) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get property %s: %s", prop, err.Error())) + } + this.Set(prop, value) + } + + this.Set("assistant_id", args[0]) + this.Set("input", args[1]) + this.Set("options", options) + return this.Value +} + +// ExportObject Export as a FS Object +func (obj *objectCall) ExportObject(info *v8go.FunctionCallbackInfo) *v8go.ObjectTemplate { + tmpl := v8go.NewObjectTemplate(info.Context().Isolate()) + tmpl.Set("On", v8go.NewFunctionTemplate(info.Context().Isolate(), obj.on)) // On the call + tmpl.Set("Run", v8go.NewFunctionTemplate(info.Context().Isolate(), obj.run)) // Run the call + return tmpl +} + +// on bind the callback to the call object +func (obj *objectCall) on(info *v8go.FunctionCallbackInfo) *v8go.Value { + + args := info.Args() + if len(args) < 2 { + return bridge.JsException(info.Context(), "On requires at least one argument") + } + + if !args[0].IsString() { + return bridge.JsException(info.Context(), "The first argument should be a string") + } + + name := args[0].String() + if !allowedEvents[name] { + return bridge.JsException(info.Context(), fmt.Sprintf("Invalid event %s", name)) + } + + cb := args[1] + if !cb.IsFunction() { + return bridge.JsException(info.Context(), fmt.Sprintf("The second argument should be a function for event %s", name)) + } + + this := info.This() + this.Set(fmt.Sprintf("on_%s", name), cb) + return this.Value +} + +// run run the call +func (obj *objectCall) run(info *v8go.FunctionCallbackInfo) *v8go.Value { + + this := info.This() + args := info.Args() + + global, err := getGlobal(info.Context(), this) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + goArgs := []interface{}{} + jsArgs := []v8go.Valuer{} + if len(args) > 0 { + for _, arg := range args { + v, err := bridge.GoValue(arg, info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + goArgs = append(goArgs, v) + jsArgs = append(jsArgs, arg) + } + } + + // Get the assistant id + jsAssistantID, err := this.Get("assistant_id") + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + assistantID := jsAssistantID.String() + + // Get the input + jsInput, err := this.Get("input") + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the input: %s", err.Error())) + } + + input, err := bridge.GoValue(jsInput, info.Context()) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to unmarshal the input: %s", err.Error())) + } + + // Options + options := OptionsCall{ + Retry: OptionsCallRetry{ + Times: 3, + Delay: 200, + DelayMax: 5000, + Prompt: "Please fix the error. \n {{ error }}", + }, + Options: map[string]interface{}{}, + } + + // Get the options + if this.Has("options") { + jsOptions, err := this.Get("options") + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the options: %s", err.Error())) + } + + err = bridge.Unmarshal(jsOptions, &options) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to unmarshal the options: %s", err.Error())) + } + } + + // Get the assistant + newAst, err := Get(assistantID) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the assistant: %s", err.Error())) + } + + // Get the message event ( it will be used for the message event ) + eventMessage := "" + goCallProps := map[string]interface{}{} + if this.Has("on_message") { + jsEventMessage, err := this.Get("on_message") + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the message: %s", err.Error())) + } + eventMessage = jsEventMessage.String() + + for _, prop := range callProps { + if this.Has(prop) { + value, err := this.Get(prop) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the %s property: %s", prop, err.Error())) + } + goValue, err := bridge.GoValue(value, info.Context()) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the %s property: %s", prop, err.Error())) + } + goCallProps[prop] = goValue + } + } + } + + // Update the chat context + var chatCtx chatctx.Context = global.ChatContext + chatCtx.AssistantID = assistantID + chatCtx.ChatID = fmt.Sprintf("chat_%s", uuid.New().String()) // New chat id + chatCtx.Silent = true + + // Define the callback function + var cb func(msg *chatMessage.Message) = nil + var output = []chatMessage.Message{} + cb = func(msg *chatMessage.Message) { + output = append(output, *msg) + if eventMessage != "" { + err := obj.triggerAnonymous(chatCtx, global, goCallProps, eventMessage, goArgs, msg) + if err != nil { + color.Red("Failed to trigger the message event: %s", err.Error()) + log.Error("Failed to trigger the message event: %s", err.Error()) + return + } + } + } + + // Execute the assistant + err = newAst.Execute(global.GinContext, chatCtx, input, options.Options, cb) // Execute the assistant + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + // Copy props + for name, value := range goCallProps { + info.Context().Global().Set(name, value) + } + + // Trigger the done event + exception := obj.trigger(info, "done", jsArgs...) + if exception != nil { + return exception + } + + return nil +} + +func (obj *objectCall) triggerAnonymous(chatCtx chatctx.Context, global *GlobalVariables, goCallProps map[string]interface{}, source string, bindArgs []interface{}, fnArgs ...interface{}) error { + + ctx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) + if err != nil { + return err + } + defer ctx.Close() + + // Update Context + global.Assistant.InitObject(ctx, global.GinContext, chatCtx, global.Contents) + + // Copy props + for k, v := range goCallProps { + ctx.WithGlobal(k, v) + } + + // Add the args + ctx.WithGlobal("args", bindArgs) + _, err = ctx.CallAnonymousWith(context.Background(), source, fnArgs...) + if err != nil { + return err + } + return nil + +} + +// trigger trigger the callback +func (obj *objectCall) trigger(info *v8go.FunctionCallbackInfo, name string, fnArgs ...v8go.Valuer) *v8go.Value { + // Try to get the callback + this := info.This() + if this.Has(fmt.Sprintf("on_%s", name)) { + event, err := this.Get(fmt.Sprintf("on_%s", name)) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the %s callback: %s ", name, err.Error())) + } + + if event.IsFunction() { + + cb, err := event.AsFunction() + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the %s callback: %s", name, err.Error())) + } + + result, err := cb.Call(this, fnArgs...) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to trigger the %s callback: %s", name, err.Error())) + } + return result + } + } + + return nil +} + +// jsCallBackup is the backup function for the call function +// func jsCallBackup(info *v8go.FunctionCallbackInfo) *v8go.Value { + +// // Get the args +// args := info.Args() +// if len(args) < 2 { +// return bridge.JsException(info.Context(), "Run requires at least two arguments") +// } + +// // Get the assistant id +// assistantID := args[0].String() + +// // Get the assistant +// newAst, err := Get(assistantID) +// if err != nil { +// return bridge.JsException(info.Context(), err.Error()) +// } + +// // Get the input +// input := args[1].String() + +// // Get the global variables +// global, err := global(info) +// if err != nil { +// return bridge.JsException(info.Context(), err.Error()) +// } + +// // Update Context +// chatContext := global.ChatContext +// chatContext.AssistantID = assistantID +// chatContext.ChatID = fmt.Sprintf("chat_%s", uuid.New().String()) // New chat id +// chatContext.Silent = true // Silent mode + +// var cb func(msg *chatMessage.Message) = nil +// if len(args) > 2 { + +// // Rest args +// var jsArgs *v8go.Value +// goArgs := []interface{}{} +// if len(args) > 3 { +// jsArgs = args[3] +// if jsArgs != nil { +// if jsArgs.IsArray() { +// v, err := bridge.GoValue(jsArgs, info.Context()) +// if err != nil { +// return bridge.JsException(info.Context(), err.Error()) +// } +// arr, ok := v.([]interface{}) +// if !ok { +// return bridge.JsException(info.Context(), "Invalid arguments") +// } +// goArgs = arr +// } else { +// v, err := bridge.GoValue(jsArgs, info.Context()) +// if err != nil { +// return bridge.JsException(info.Context(), err.Error()) +// } +// goArgs = []interface{}{v} +// } +// } +// } + +// // Parse the callback +// funcType := "method" +// name := "" +// userArgs := []interface{}{} +// if args[2].IsFunction() { +// funcType = "anonymous" +// } else { +// goValue, err := bridge.GoValue(args[2], info.Context()) +// if err != nil { +// return bridge.JsException(info.Context(), err.Error()) +// } +// switch v := goValue.(type) { +// case string: +// name = v +// case map[string]interface{}: +// if fname, ok := v["name"].(string); ok { +// name = fname +// } +// if args, ok := v["args"].([]interface{}); ok { +// userArgs = args +// } +// } + +// if strings.Contains(name, ".") { +// funcType = "process" +// } +// } + +// switch funcType { +// case "anonymous": +// source := args[2].String() +// cb = func(msg *chatMessage.Message) { +// cbArgs := []interface{}{msg} +// cbArgs = append(cbArgs, goArgs...) +// ctx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) +// if err != nil { +// fmt.Println("Failed to create context", err.Error()) +// return +// } +// defer ctx.Close() + +// global.Assistant.InitObject(ctx, global.GinContext, chatContext, global.Contents) +// _, err = ctx.CallAnonymousWith(context.Background(), source, cbArgs...) +// if err != nil { +// log.Error("Failed to call the method: %s", err.Error()) +// color.Red("Failed to call the method: %s", err.Error()) +// return +// } +// } +// break + +// case "process": + +// cb = func(msg *chatMessage.Message) { +// cbArgs := []interface{}{} +// cbArgs = append(cbArgs, msg) +// cbArgs = append(cbArgs, userArgs...) +// p, err := process.Of(name, cbArgs...) +// if err != nil { +// log.Error("Failed to get the process: %s", err.Error()) +// color.Red("Failed to get the process: %s", err.Error()) +// return +// } +// err = p.Execute() +// if err != nil { +// log.Error("Failed to execute the process: %s", err.Error()) +// color.Red("Failed to execute the process: %s", err.Error()) +// return +// } +// defer p.Release() +// } + +// case "method": + +// cb = func(msg *chatMessage.Message) { +// cbArgs := []interface{}{} +// cbArgs = append(cbArgs, msg) +// cbArgs = append(cbArgs, userArgs...) +// ctx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) +// if err != nil { +// return +// } +// defer ctx.Close() + +// global.Assistant.InitObject(ctx, global.GinContext, global.ChatContext, global.Contents) +// _, err = ctx.CallWith(context.Background(), name, cbArgs...) +// if err != nil { +// log.Error("Failed to call the method: %s", err.Error()) +// color.Red("Failed to call the method: %s", err.Error()) +// return +// } +// } +// } + +// } + +// // Parse the options +// options := map[string]interface{}{} +// if len(args) > 4 { +// optionsRaw, err := bridge.GoValue(args[4], info.Context()) +// if err != nil { +// return bridge.JsException(info.Context(), err.Error()) +// } + +// // Parse the options +// if optionsRaw != nil { +// switch v := optionsRaw.(type) { +// case string: +// err := jsoniter.UnmarshalFromString(v, &options) +// if err != nil { +// return bridge.JsException(info.Context(), err.Error()) +// } +// case map[string]interface{}: +// options = v +// default: +// return bridge.JsException(info.Context(), "Invalid options") +// } +// } +// } + +// err = newAst.Execute(global.GinContext, chatContext, input, options, cb) // Execute the assistant +// if err != nil { +// return bridge.JsException(info.Context(), err.Error()) +// } +// return nil +// } diff --git a/neo/assistant/object.go b/neo/assistant/object.go index 42971e91..3cd45491 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -1,24 +1,32 @@ package assistant import ( - "context" "fmt" - "strings" - "github.com/fatih/color" "github.com/gin-gonic/gin" - "github.com/google/uuid" - jsoniter "github.com/json-iterator/go" - "github.com/yaoapp/gou/process" v8 "github.com/yaoapp/gou/runtime/v8" "github.com/yaoapp/gou/runtime/v8/bridge" - "github.com/yaoapp/kun/log" chatctx "github.com/yaoapp/yao/neo/context" "github.com/yaoapp/yao/neo/message" chatMessage "github.com/yaoapp/yao/neo/message" "rogchap.com/v8go" ) +// objectProperties is the properties of the assistant object +var objectProperties = []string{ + "__yao_agent_global", + "assistant", + "context", + "Plan", + "Send", + "Call", + "Assets", + "Set", + "Get", + "Del", + "Clear", +} + // GlobalVariables is the global variables for the assistant type GlobalVariables struct { Assistant *Assistant @@ -36,12 +44,15 @@ func (global *GlobalVariables) JsValue(ctx *v8go.Context) (*v8go.Value, error) { func (ast *Assistant) InitObject(v8ctx *v8.Context, c *gin.Context, context chatctx.Context, contents *chatMessage.Contents) { // Add global variables to the script context - v8ctx.WithGlobal("__yao_agent_global", &GlobalVariables{ + global := &GlobalVariables{ Assistant: ast, Contents: contents, GinContext: c, ChatContext: context, - }) + } + + // Add global variables to the script context + v8ctx.WithGlobal("__yao_agent_global", global) // Add assistant to the script context v8ctx.WithGlobal("assistant", ast.Map()) @@ -290,204 +301,19 @@ func jsSend(info *v8go.FunctionCallbackInfo) *v8go.Value { } } -func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { - - // Get the args - args := info.Args() - if len(args) < 2 { - return bridge.JsException(info.Context(), "Run requires at least two arguments") - } - - // Get the assistant id - assistantID := args[0].String() - - // Get the assistant - newAst, err := Get(assistantID) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - - // Get the input - input := args[1].String() - - // Get the global variables - global, err := global(info) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - - // Update Context - chatContext := global.ChatContext - chatContext.AssistantID = assistantID - chatContext.ChatID = fmt.Sprintf("chat_%s", uuid.New().String()) // New chat id - chatContext.Silent = true // Silent mode - - var cb func(msg *chatMessage.Message) = nil - if len(args) > 2 { - - // Rest args - var jsArgs *v8go.Value - goArgs := []interface{}{} - if len(args) > 3 { - jsArgs = args[3] - if jsArgs != nil { - if jsArgs.IsArray() { - v, err := bridge.GoValue(jsArgs, info.Context()) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - arr, ok := v.([]interface{}) - if !ok { - return bridge.JsException(info.Context(), "Invalid arguments") - } - goArgs = arr - } else { - v, err := bridge.GoValue(jsArgs, info.Context()) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - goArgs = []interface{}{v} - } - } - } - - // Parse the callback - funcType := "method" - name := "" - userArgs := []interface{}{} - if args[2].IsFunction() { - funcType = "anonymous" - } else { - goValue, err := bridge.GoValue(args[2], info.Context()) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - switch v := goValue.(type) { - case string: - name = v - case map[string]interface{}: - if fname, ok := v["name"].(string); ok { - name = fname - } - if args, ok := v["args"].([]interface{}); ok { - userArgs = args - } - } - - if strings.Contains(name, ".") { - funcType = "process" - } - } - - switch funcType { - case "anonymous": - source := args[2].String() - cb = func(msg *chatMessage.Message) { - cbArgs := []interface{}{msg} - cbArgs = append(cbArgs, goArgs...) - ctx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) - if err != nil { - fmt.Println("Failed to create context", err.Error()) - return - } - defer ctx.Close() - - global.Assistant.InitObject(ctx, global.GinContext, chatContext, global.Contents) - _, err = ctx.CallAnonymousWith(context.Background(), source, cbArgs...) - if err != nil { - log.Error("Failed to call the method: %s", err.Error()) - color.Red("Failed to call the method: %s", err.Error()) - return - } - } - break - - case "process": - - cb = func(msg *chatMessage.Message) { - cbArgs := []interface{}{} - cbArgs = append(cbArgs, msg) - cbArgs = append(cbArgs, userArgs...) - p, err := process.Of(name, cbArgs...) - if err != nil { - log.Error("Failed to get the process: %s", err.Error()) - color.Red("Failed to get the process: %s", err.Error()) - return - } - err = p.Execute() - if err != nil { - log.Error("Failed to execute the process: %s", err.Error()) - color.Red("Failed to execute the process: %s", err.Error()) - return - } - defer p.Release() - } - - case "method": - - cb = func(msg *chatMessage.Message) { - cbArgs := []interface{}{} - cbArgs = append(cbArgs, msg) - cbArgs = append(cbArgs, userArgs...) - ctx, err := global.Assistant.Script.NewContext(global.ChatContext.Sid, nil) - if err != nil { - return - } - defer ctx.Close() - - global.Assistant.InitObject(ctx, global.GinContext, global.ChatContext, global.Contents) - _, err = ctx.CallWith(context.Background(), name, cbArgs...) - if err != nil { - log.Error("Failed to call the method: %s", err.Error()) - color.Red("Failed to call the method: %s", err.Error()) - return - } - } - } - - } - - // Parse the options - options := map[string]interface{}{} - if len(args) > 4 { - optionsRaw, err := bridge.GoValue(args[4], info.Context()) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - - // Parse the options - if optionsRaw != nil { - switch v := optionsRaw.(type) { - case string: - err := jsoniter.UnmarshalFromString(v, &options) - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - case map[string]interface{}: - options = v - default: - return bridge.JsException(info.Context(), "Invalid options") - } - } - } - - err = newAst.Execute(global.GinContext, chatContext, input, options, cb) // Execute the assistant - if err != nil { - return bridge.JsException(info.Context(), err.Error()) - } - return nil -} - // global get the global variables func global(info *v8go.FunctionCallbackInfo) (global *GlobalVariables, err error) { + return getGlobal(info.Context(), info.This()) +} - jsGlobal, err := info.This().Get("__yao_agent_global") +func getGlobal(ctx *v8go.Context, obj *v8go.Object) (global *GlobalVariables, err error) { + jsGlobal, err := obj.Get("__yao_agent_global") if err != nil { return nil, err } // Convert to go interface - goGlobal, err := bridge.GoValue(jsGlobal, info.Context()) + goGlobal, err := bridge.GoValue(jsGlobal, ctx) if err != nil { return nil, err } diff --git a/neo/assistant/plan.go b/neo/assistant/plan.go index ee470970..8f5794f4 100644 --- a/neo/assistant/plan.go +++ b/neo/assistant/plan.go @@ -112,9 +112,6 @@ func jsPlan(info *v8go.FunctionCallbackInfo) *v8go.Value { obj := newPlanObject() - // Export the object - objectTmpl := obj.ExportObject(info.Context().Isolate()) - args := info.Args() if len(args) < 1 { return bridge.JsException(info.Context(), "the first parameter should be a string") @@ -125,6 +122,7 @@ func jsPlan(info *v8go.FunctionCallbackInfo) *v8go.Value { } id := args[0].String() + objectTmpl := obj.ExportObject(info.Context().Isolate()) plan, err := objectTmpl.NewInstance(info.Context()) if err != nil { return bridge.JsException(info.Context(), fmt.Sprintf("failed to create plan object %s", err.Error())) diff --git a/neo/assistant/types.go b/neo/assistant/types.go index b7d8d211..dbf6c68e 100644 --- a/neo/assistant/types.go +++ b/neo/assistant/types.go @@ -26,7 +26,7 @@ type API interface { ReadBase64(ctx context.Context, fileID string) (string, error) GetPlaceholder() *Placeholder - Execute(c *gin.Context, ctx chatctx.Context, input string, options map[string]interface{}, callback ...interface{}) error + Execute(c *gin.Context, ctx chatctx.Context, input interface{}, options map[string]interface{}, callback ...interface{}) error Call(c *gin.Context, payload APIPayload) (interface{}, error) } From dcc126ef95a970becb4615e4466eac7175e70f47 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 1 Mar 2025 12:20:02 +0800 Subject: [PATCH 19/27] Implement retry mechanism for assistant method calls - Add comprehensive retry handling in objectCall.run method - Support configurable retry times, delay, and custom retry prompts - Enhance error handling and input processing during retry attempts - Modify method signatures to return interface{} for more flexible result handling - Implement retry event hook for custom retry logic in JavaScript --- neo/assistant/api.go | 46 ++++----- neo/assistant/call.go | 214 ++++++++++++++++++++++++++++++++++++++--- neo/assistant/types.go | 2 +- neo/neo.go | 3 +- 4 files changed, 228 insertions(+), 37 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index e9543929..f333ab69 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -46,17 +46,17 @@ func GetByConnector(connector string, name string) (*Assistant, error) { } // Execute implements the execute functionality -func (ast *Assistant) Execute(c *gin.Context, ctx chatctx.Context, input interface{}, options map[string]interface{}, callback ...interface{}) error { +func (ast *Assistant) Execute(c *gin.Context, ctx chatctx.Context, input interface{}, options map[string]interface{}, callback ...interface{}) (interface{}, error) { contents := chatMessage.NewContents() messages, err := ast.withHistory(ctx, input) if err != nil { - return err + return nil, err } return ast.execute(c, ctx, messages, options, contents, callback...) } // Execute implements the execute functionality -func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, userInput interface{}, userOptions map[string]interface{}, contents *chatMessage.Contents, callback ...interface{}) error { +func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, userInput interface{}, userOptions map[string]interface{}, contents *chatMessage.Contents, callback ...interface{}) (interface{}, error) { var input []chatMessage.Message @@ -67,11 +67,11 @@ func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, userInput int case []interface{}: raw, err := jsoniter.Marshal(v) if err != nil { - return fmt.Errorf("marshal input error: %s", err.Error()) + return nil, fmt.Errorf("marshal input error: %s", err.Error()) } err = jsoniter.Unmarshal(raw, &input) if err != nil { - return fmt.Errorf("unmarshal input error: %s", err.Error()) + return nil, fmt.Errorf("unmarshal input error: %s", err.Error()) } case []chatMessage.Message: @@ -95,7 +95,7 @@ func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, userInput int Error(err). Done(). Write(c.Writer) - return err + return nil, err } // Update options if provided @@ -123,14 +123,14 @@ func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, userInput int Error(err). Done(). Write(c.Writer) - return err + return nil, err } // Reset Message Contents last := input[len(input)-1] input, err = newAst.withHistory(ctx, last) if err != nil { - return err + return nil, err } // Reset options @@ -151,7 +151,7 @@ func (ast *Assistant) execute(c *gin.Context, ctx chatctx.Context, userInput int } // Execute the next action -func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *chatMessage.Contents, callback ...interface{}) error { +func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *chatMessage.Contents, callback ...interface{}) (interface{}, error) { switch next.Action { // It's not used, because the process could be executed in the hook script @@ -188,26 +188,26 @@ func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *c case "assistant": if next.Payload == nil { - return fmt.Errorf("payload is required") + return nil, fmt.Errorf("payload is required") } // Get assistant id id, ok := next.Payload["assistant_id"].(string) if !ok { - return fmt.Errorf("assistant id should be string") + return nil, fmt.Errorf("assistant id should be string") } // Get assistant assistant, err := Get(id) if err != nil { - return fmt.Errorf("get assistant error: %s", err.Error()) + return nil, fmt.Errorf("get assistant error: %s", err.Error()) } // Input input := chatMessage.Message{} _, has := next.Payload["input"] if !has { - return fmt.Errorf("input is required") + return nil, fmt.Errorf("input is required") } // Retry mode @@ -223,14 +223,14 @@ func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *c messages := chatMessage.Message{} err := jsoniter.UnmarshalFromString(v, &messages) if err != nil { - return fmt.Errorf("unmarshal input error: %s", err.Error()) + return nil, fmt.Errorf("unmarshal input error: %s", err.Error()) } input = messages case map[string]interface{}: msg, err := chatMessage.NewMap(v) if err != nil { - return fmt.Errorf("unmarshal input error: %s", err.Error()) + return nil, fmt.Errorf("unmarshal input error: %s", err.Error()) } input = *msg @@ -241,7 +241,7 @@ func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *c input = v default: - return fmt.Errorf("input should be string or []chatMessage.Message") + return nil, fmt.Errorf("input should be string or []chatMessage.Message") } // Options @@ -257,7 +257,7 @@ func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *c messages, err := assistant.withHistory(ctx, input) if err != nil { - return fmt.Errorf("with history error: %s", err.Error()) + return nil, fmt.Errorf("with history error: %s", err.Error()) } // Create a new Text @@ -279,10 +279,10 @@ func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *c return assistant.execute(c, ctx, messages, options, newContents, callback...) case "exit": - return nil + return nil, nil default: - return fmt.Errorf("unknown action: %s", next.Action) + return nil, fmt.Errorf("unknown action: %s", next.Action) } } @@ -311,7 +311,7 @@ func (ast *Assistant) Call(c *gin.Context, payload APIPayload) (interface{}, err } // handleChatStream manages the streaming chat interaction with the AI -func (ast *Assistant) handleChatStream(c *gin.Context, ctx chatctx.Context, messages []chatMessage.Message, options map[string]interface{}, contents *chatMessage.Contents, callback ...interface{}) error { +func (ast *Assistant) handleChatStream(c *gin.Context, ctx chatctx.Context, messages []chatMessage.Message, options map[string]interface{}, contents *chatMessage.Contents, callback ...interface{}) (interface{}, error) { clientBreak := make(chan bool, 1) done := make(chan bool, 1) @@ -327,10 +327,10 @@ func (ast *Assistant) handleChatStream(c *gin.Context, ctx chatctx.Context, mess // Wait for completion or client disconnect select { case <-done: - return nil + return nil, nil case <-c.Writer.CloseNotify(): clientBreak <- true - return nil + return nil, nil } } @@ -565,7 +565,7 @@ func (ast *Assistant) streamChat( // If the hook is successful, execute the next action if res != nil && res.Next != nil { - err := res.Next.Execute(c, ctx, contents, cb) + _, err := res.Next.Execute(c, ctx, contents, cb) if err != nil { chatMessage.New().Error(err.Error()).Done().Callback(cb).Write(c.Writer) } diff --git a/neo/assistant/call.go b/neo/assistant/call.go index 4694c675..9a285f33 100644 --- a/neo/assistant/call.go +++ b/neo/assistant/call.go @@ -3,13 +3,18 @@ package assistant import ( "context" "fmt" + "strings" + "time" "github.com/fatih/color" "github.com/google/uuid" + jsoniter "github.com/json-iterator/go" "github.com/yaoapp/gou/runtime/v8/bridge" + "github.com/yaoapp/kun/exception" "github.com/yaoapp/kun/log" chatctx "github.com/yaoapp/yao/neo/context" chatMessage "github.com/yaoapp/yao/neo/message" + sui "github.com/yaoapp/yao/sui/core" "rogchap.com/v8go" ) @@ -42,6 +47,7 @@ var callProps = []string{ "assistant_id", "input", "options", + "retry_times", } // jsNewPlan create a plan object and return it @@ -81,6 +87,7 @@ func jsCall(info *v8go.FunctionCallbackInfo) *v8go.Value { this.Set("assistant_id", args[0]) this.Set("input", args[1]) this.Set("options", options) + this.Set("retry_times", int32(1)) return this.Value } @@ -162,6 +169,20 @@ func (obj *objectCall) run(info *v8go.FunctionCallbackInfo) *v8go.Value { return bridge.JsException(info.Context(), fmt.Sprintf("Failed to unmarshal the input: %s", err.Error())) } + // Get the retry input + if this.Has("retry_input") { + + jsRetryInput, err := this.Get("retry_input") + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the retry input: %s", err.Error())) + } + + input, err = bridge.GoValue(jsRetryInput, info.Context()) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to unmarshal the retry input: %s", err.Error())) + } + } + // Options options := OptionsCall{ Retry: OptionsCallRetry{ @@ -239,9 +260,12 @@ func (obj *objectCall) run(info *v8go.FunctionCallbackInfo) *v8go.Value { } // Execute the assistant - err = newAst.Execute(global.GinContext, chatCtx, input, options.Options, cb) // Execute the assistant + result, err := newAst.Execute(global.GinContext, chatCtx, input, options.Options, cb) // Execute the assistant if err != nil { - return bridge.JsException(info.Context(), err.Error()) + result, err = obj.retry(jsArgs, err, input, output, info, options) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } } // Copy props @@ -250,12 +274,178 @@ func (obj *objectCall) run(info *v8go.FunctionCallbackInfo) *v8go.Value { } // Trigger the done event - exception := obj.trigger(info, "done", jsArgs...) - if exception != nil { - return exception + _, err = obj.trigger(info, "done", jsArgs...) + if err != nil { + result, err = obj.retry(jsArgs, err, input, output, info, options) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } } - return nil + // Return Value + switch v := result.(type) { + case *v8go.Value: + return v + case error: + return bridge.JsException(info.Context(), v.Error()) + } + + // Return Value + jsResult, err := bridge.JsValue(info.Context(), result) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the result: %s", err.Error())) + } + return jsResult +} + +func (obj *objectCall) retry(jsArgs []v8go.Valuer, err error, input interface{}, output []chatMessage.Message, info *v8go.FunctionCallbackInfo, options OptionsCall) (*v8go.Value, error) { + + // Retry times, if not set, return the error + if options.Retry.Times <= 0 { + return nil, err + } + + this := info.This() + errmsg := exception.Trim(err) + + // Get current retry times + jsTimes, retryErr := this.Get("retry_times") + if retryErr != nil { + return nil, fmt.Errorf("%s occurred but failed to get the retry times: %s", errmsg, retryErr.Error()) + } + + times := int(jsTimes.Int32()) + if times > options.Retry.Times { + return nil, fmt.Errorf("%s occurred, max retry times reached", errmsg) + } + + // Update the retry times + times = times + 1 + this.Set("retry_times", int32(times)) + + // Message content + content := "" + for _, msg := range output { + if msg.Type == "text" && msg.IsDelta { + content += msg.Text + } + } + + // Delay + delay := options.Retry.Delay * int(times) + if delay > options.Retry.DelayMax { + delay = options.Retry.DelayMax + } + + // Retry delay (millisecond) + if delay > 0 { + time.Sleep(time.Duration(delay) * time.Millisecond) + } + + // Format the input + var lastUserMessage *chatMessage.Message = nil + var inputMessages []*chatMessage.Message = nil + var lastUserMessageIndex int = 0 + switch v := input.(type) { + case string: + lastUserMessage = &chatMessage.Message{Type: "text", Text: v, Role: "user"} + inputMessages = []*chatMessage.Message{lastUserMessage} + + case []interface{}: + // Get the last user message + raw, parseErr := jsoniter.Marshal(v) + if parseErr != nil { + return nil, fmt.Errorf("%s occurred but failed to marshal the input: %s", errmsg, parseErr.Error()) + } + + parseErr = jsoniter.Unmarshal(raw, &inputMessages) + if parseErr != nil { + return nil, fmt.Errorf("%s occurred but failed to unmarshal the input: %s", errmsg, parseErr.Error()) + } + + // Get the last user message + for i := len(inputMessages) - 1; i >= 0; i-- { + if inputMessages[i].Type == "text" && inputMessages[i].Role == "user" { + lastUserMessage = inputMessages[i] + lastUserMessageIndex = i + break + } + } + + case *chatMessage.Message: + lastUserMessage = v + inputMessages = []*chatMessage.Message{lastUserMessage} + + case map[string]interface{}: + text, ok := v["text"].(string) + if !ok { + return nil, fmt.Errorf("%s occurred but failed to get the text", errmsg) + } + + if v["role"] != "user" { + return nil, fmt.Errorf("%s occurred but the role is not user", errmsg) + } + + lastUserMessage = &chatMessage.Message{Type: "text", Text: text, Role: "user"} + inputMessages = []*chatMessage.Message{lastUserMessage} + } + + // Get the prompt from the options + promptTmpl := options.Retry.Prompt + data := sui.Data{ + "error": errmsg, + "output": strings.TrimSpace(content), + "input": lastUserMessage.Text, + } + prompt, _ := data.Replace(promptTmpl) + + // Custom retry prompt by hooking the retry event + if this.Has("on_retry") { + info.Context().Global().Set("error", errmsg) // Set error + jsDelay, _ := bridge.JsValue(info.Context(), delay) + jsPrompt, _ := bridge.JsValue(info.Context(), prompt) + newPrompt, retryErr := obj.trigger(info, "retry", jsTimes, jsDelay, jsPrompt) + if retryErr != nil { + return nil, fmt.Errorf("%s occurred but failed to trigger the retry event: %s", errmsg, retryErr.Error()) + } + // Update the prompt + if newPrompt.IsString() { + prompt = newPrompt.String() + } + } + + // Generate the new input with the prompt + // Update the input + inputMessages[lastUserMessageIndex].Text = prompt + jsInput, inputErr := bridge.JsValue(info.Context(), inputMessages) + if inputErr != nil { + return nil, fmt.Errorf("%s occurred but failed to update the input: %s", errmsg, inputErr.Error()) + } + // Update the input + this.Set("retry_input", jsInput) + + // Call the run function + run, funcErr := this.Get("Run") + if funcErr != nil { + return nil, fmt.Errorf("%s occurred but failed to get the run function: %s", errmsg, funcErr.Error()) + } + + if !run.IsFunction() { + return nil, fmt.Errorf("%s occurred but the run function is not a function", errmsg) + } + + fn, fnErr := run.AsFunction() + if fnErr != nil { + return nil, fmt.Errorf("%s occurred but failed to get the run function: %s", errmsg, fnErr.Error()) + } + + // Call the run function + result, resErr := fn.Call(this, jsArgs...) + if resErr != nil { + return nil, fmt.Errorf("%s (%d)", exception.Trim(resErr), times-1) + } + + return result, nil } func (obj *objectCall) triggerAnonymous(chatCtx chatctx.Context, global *GlobalVariables, goCallProps map[string]interface{}, source string, bindArgs []interface{}, fnArgs ...interface{}) error { @@ -285,31 +475,31 @@ func (obj *objectCall) triggerAnonymous(chatCtx chatctx.Context, global *GlobalV } // trigger trigger the callback -func (obj *objectCall) trigger(info *v8go.FunctionCallbackInfo, name string, fnArgs ...v8go.Valuer) *v8go.Value { +func (obj *objectCall) trigger(info *v8go.FunctionCallbackInfo, name string, fnArgs ...v8go.Valuer) (*v8go.Value, error) { // Try to get the callback this := info.This() if this.Has(fmt.Sprintf("on_%s", name)) { event, err := this.Get(fmt.Sprintf("on_%s", name)) if err != nil { - return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the %s callback: %s ", name, err.Error())) + return nil, err } if event.IsFunction() { cb, err := event.AsFunction() if err != nil { - return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the %s callback: %s", name, err.Error())) + return nil, err } result, err := cb.Call(this, fnArgs...) if err != nil { - return bridge.JsException(info.Context(), fmt.Sprintf("Failed to trigger the %s callback: %s", name, err.Error())) + return nil, err } - return result + return result, nil } } - return nil + return nil, nil } // jsCallBackup is the backup function for the call function diff --git a/neo/assistant/types.go b/neo/assistant/types.go index dbf6c68e..036cf5bc 100644 --- a/neo/assistant/types.go +++ b/neo/assistant/types.go @@ -26,7 +26,7 @@ type API interface { ReadBase64(ctx context.Context, fileID string) (string, error) GetPlaceholder() *Placeholder - Execute(c *gin.Context, ctx chatctx.Context, input interface{}, options map[string]interface{}, callback ...interface{}) error + Execute(c *gin.Context, ctx chatctx.Context, input interface{}, options map[string]interface{}, callback ...interface{}) (interface{}, error) Call(c *gin.Context, payload APIPayload) (interface{}, error) } diff --git a/neo/neo.go b/neo/neo.go index 76d94bce..5bdfe438 100644 --- a/neo/neo.go +++ b/neo/neo.go @@ -22,7 +22,8 @@ func (neo *DSL) Answer(ctx chatctx.Context, question string, c *gin.Context) err return err } } - return ast.Execute(c, ctx, question, nil) + _, err = ast.Execute(c, ctx, question, nil) + return err } // Select select an assistant From 392676f2cbaf930ee9fffdb10ae6c56f51c804da Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 1 Mar 2025 16:08:56 +0800 Subject: [PATCH 20/27] Add silent mode support for chat history and filtering - Implement silent flag in chat and history storage - Add filter options to retrieve chats and messages with silent mode - Update store interfaces to support silent message filtering - Enhance GetChats, GetHistory, and related methods to handle silent messages - Add comprehensive test cases for silent mode functionality --- neo/assistant/api.go | 43 ++++-- neo/assistant/call.go | 32 +++-- neo/assistant/load_test.go | 6 + neo/assistant/object.go | 4 +- neo/context/context.go | 6 + neo/store/mongo.go | 10 ++ neo/store/redis.go | 10 ++ neo/store/types.go | 15 ++ neo/store/xun.go | 276 +++++++++++++++++++++++++++++++------ neo/store/xun_test.go | 249 +++++++++++++++++++++++++++++++++ 10 files changed, 582 insertions(+), 69 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index f333ab69..4e275572 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -314,20 +314,28 @@ func (ast *Assistant) Call(c *gin.Context, payload APIPayload) (interface{}, err func (ast *Assistant) handleChatStream(c *gin.Context, ctx chatctx.Context, messages []chatMessage.Message, options map[string]interface{}, contents *chatMessage.Contents, callback ...interface{}) (interface{}, error) { clientBreak := make(chan bool, 1) done := make(chan bool, 1) + var result interface{} = nil + var err error = nil // Chat with AI in background go func() { - err := ast.streamChat(c, ctx, messages, options, clientBreak, done, contents, callback...) + var res interface{} = nil + res, err = ast.streamChat(c, ctx, messages, options, clientBreak, contents, callback...) if err != nil { chatMessage.New().Error(err).Done().Write(c.Writer) + err = fmt.Errorf("stream chat error %s", err.Error()) } + result = res done <- true }() // Wait for completion or client disconnect select { case <-done: - return nil, nil + if err != nil { + return nil, err + } + return result, nil case <-c.Writer.CloseNotify(): clientBreak <- true return nil, nil @@ -341,10 +349,9 @@ func (ast *Assistant) streamChat( messages []chatMessage.Message, options map[string]interface{}, clientBreak chan bool, - done chan bool, contents *chatMessage.Contents, callback ...interface{}, -) error { +) (interface{}, error) { var cb interface{} if len(callback) > 0 { @@ -358,6 +365,8 @@ func (ast *Assistant) streamChat( toolsCount := 0 currentMessageID := "" + var result interface{} = nil // To save the result + var content string = "" // To save the content err := ast.Chat(c.Request.Context(), messages, options, func(data []byte) int { select { case <-clientBreak: @@ -511,6 +520,11 @@ func (ast *Assistant) streamChat( msgType = "tool" } + // Add the text content to the content + if msgType == "text" || msgType == "" { + content += msg.Text // Save the content + } + output := chatMessage.New().Map(map[string]interface{}{ "text": delta, "type": msgType, @@ -555,8 +569,6 @@ func (ast *Assistant) streamChat( // Some error occurred in the hook, return the error if hookErr != nil { chatMessage.New().Error(hookErr.Error()).Done().Callback(cb).Write(c.Writer) - - done <- true return 0 // break } @@ -569,10 +581,14 @@ func (ast *Assistant) streamChat( if err != nil { chatMessage.New().Error(err.Error()).Done().Callback(cb).Write(c.Writer) } - done <- true return 0 // break } + // if the result is not nil, save the result + if res != nil && res.Result != nil { + result = res.Result + } + // The default output output := chatMessage.New().Done() if res != nil && res.Output != nil { @@ -587,7 +603,6 @@ func (ast *Assistant) streamChat( } output.Callback(cb).Write(c.Writer) - done <- true return 0 // break } @@ -597,21 +612,27 @@ func (ast *Assistant) streamChat( // Handle error if err != nil { - return err + return nil, err } // raw error if errorRaw != "" { msg, err := chatMessage.NewStringError(errorRaw) if err != nil { - return fmt.Errorf("error: %s", err.Error()) + return nil, fmt.Errorf("stream chat error %s", err.Error()) } msg.Retry = ctx.Retry msg.Silent = ctx.Silent msg.Done().Callback(cb).Write(c.Writer) } - return nil + // If the result is not nil, return the result + if result != nil { + return result, nil + } + + // Return the content + return strings.TrimSpace(content), nil } // saveChatHistory saves the chat history if storage is available diff --git a/neo/assistant/call.go b/neo/assistant/call.go index 9a285f33..532e8a7e 100644 --- a/neo/assistant/call.go +++ b/neo/assistant/call.go @@ -23,23 +23,23 @@ type objectCall struct{} // OptionsCall is the options for the call function type OptionsCall struct { - Retry OptionsCallRetry `json:"retry,omitempty"` - Options map[string]interface{} `json:"options,omitempty"` + Retry OptionsCallRetry `json:"retry,omitempty"` // Retry options + Options map[string]interface{} `json:"options,omitempty"` // LLM API options + Silent bool `json:"silent,omitempty"` // Silent mode, default is true } // OptionsCallRetry is the retry options for the call function type OptionsCallRetry struct { - Times int `json:"times,omitempty"` - Delay int `json:"delay,omitempty"` - DelayMax int `json:"delay_max,omitempty"` - Prompt string `json:"prompt,omitempty"` + Times int `json:"times,omitempty"` // Retry times, default is 3 + Delay int `json:"delay,omitempty"` // Retry delay, default is 200 + DelayMax int `json:"delay_max,omitempty"` // Retry delay max, default is 5000 + Prompt string `json:"prompt,omitempty"` // Retry prompt, default is "Please fix the error. \n {{ error }}" } // allowedEvents is the allowed events for the call function var allowedEvents = map[string]bool{ "done": true, "retry": true, - "error": true, "message": true, } @@ -188,10 +188,11 @@ func (obj *objectCall) run(info *v8go.FunctionCallbackInfo) *v8go.Value { Retry: OptionsCallRetry{ Times: 3, Delay: 200, - DelayMax: 5000, - Prompt: "Please fix the error. \n {{ error }}", + DelayMax: 1000, + Prompt: "{{ input }}\n**Answer is not correct, please try again.**\nError:\n{{ error }} \nAssistant's last answer:\n{{ output }}", }, - Options: map[string]interface{}{}, + Silent: true, + Options: map[string]interface{}{}, // LLM API options } // Get the options @@ -241,8 +242,8 @@ func (obj *objectCall) run(info *v8go.FunctionCallbackInfo) *v8go.Value { // Update the chat context var chatCtx chatctx.Context = global.ChatContext chatCtx.AssistantID = assistantID - chatCtx.ChatID = fmt.Sprintf("chat_%s", uuid.New().String()) // New chat id - chatCtx.Silent = true + chatCtx.ChatID = fmt.Sprintf("call_%s", uuid.New().String()) // New chat id + chatCtx.Silent = options.Silent // Check the silent mode // Define the callback function var cb func(msg *chatMessage.Message) = nil @@ -274,7 +275,7 @@ func (obj *objectCall) run(info *v8go.FunctionCallbackInfo) *v8go.Value { } // Trigger the done event - _, err = obj.trigger(info, "done", jsArgs...) + doneResult, err := obj.trigger(info, "done", jsArgs...) if err != nil { result, err = obj.retry(jsArgs, err, input, output, info, options) if err != nil { @@ -282,6 +283,11 @@ func (obj *objectCall) run(info *v8go.FunctionCallbackInfo) *v8go.Value { } } + // Return the done result + if doneResult != nil && !doneResult.IsUndefined() { + return doneResult + } + // Return Value switch v := result.(type) { case *v8go.Value: diff --git a/neo/assistant/load_test.go b/neo/assistant/load_test.go index dd5088c8..1e55d31f 100644 --- a/neo/assistant/load_test.go +++ b/neo/assistant/load_test.go @@ -371,12 +371,18 @@ func (m *mockStore) GetAssistants(filter store.AssistantFilter) (*store.Assistan return nil, nil } func (m *mockStore) GetChat(id string, chatID string) (*store.ChatInfo, error) { return nil, nil } +func (m *mockStore) GetChatWithFilter(id string, chatID string, filter store.ChatFilter) (*store.ChatInfo, error) { + return nil, nil +} func (m *mockStore) GetChats(id string, filter store.ChatFilter) (*store.ChatGroupResponse, error) { return nil, nil } func (m *mockStore) GetHistory(id string, chatID string) ([]map[string]interface{}, error) { return nil, nil } +func (m *mockStore) GetHistoryWithFilter(id string, chatID string, filter store.ChatFilter) ([]map[string]interface{}, error) { + return nil, nil +} func (m *mockStore) SaveAssistant(assistant map[string]interface{}) (interface{}, error) { return nil, nil } diff --git a/neo/assistant/object.go b/neo/assistant/object.go index 3cd45491..c97041c3 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -59,10 +59,10 @@ func (ast *Assistant) InitObject(v8ctx *v8.Context, c *gin.Context, context chat v8ctx.WithGlobal("context", context.Map()) // Add methods to the script contexts - v8ctx.WithFunction("Plan", jsPlan) // Create a new plan object v8ctx.WithFunction("Send", jsSend) - v8ctx.WithFunction("Call", jsCall) v8ctx.WithFunction("Assets", jsAssets) + v8ctx.WithFunction("MakeCall", jsCall) // Create a new call object + v8ctx.WithFunction("MakePlan", jsPlan) // Create a new plan object // Shared space methods v8ctx.WithFunction("Set", jsSet) diff --git a/neo/context/context.go b/neo/context/context.go index 26cc5450..8d0d9442 100644 --- a/neo/context/context.go +++ b/neo/context/context.go @@ -113,6 +113,12 @@ func (ctx *Context) Map() map[string]interface{} { if ctx.Stack != "" { data["stack"] = ctx.Stack } + + // Silent mode + if ctx.Silent { + data["silent"] = ctx.Silent + } + if ctx.Path != "" { data["pathname"] = ctx.Path } diff --git a/neo/store/mongo.go b/neo/store/mongo.go index e94b4278..b6fc9146 100644 --- a/neo/store/mongo.go +++ b/neo/store/mongo.go @@ -18,11 +18,21 @@ func (m *Mongo) GetChat(sid string, cid string) (*ChatInfo, error) { return &ChatInfo{}, nil } +// GetChatWithFilter retrieves a single chat's information with filter options +func (m *Mongo) GetChatWithFilter(sid string, cid string, filter ChatFilter) (*ChatInfo, error) { + return &ChatInfo{}, nil +} + // GetHistory retrieves chat history func (m *Mongo) GetHistory(sid string, cid string) ([]map[string]interface{}, error) { return []map[string]interface{}{}, nil } +// GetHistoryWithFilter retrieves chat history with filter options +func (m *Mongo) GetHistoryWithFilter(sid string, cid string, filter ChatFilter) ([]map[string]interface{}, error) { + return []map[string]interface{}{}, nil +} + // SaveHistory saves chat history func (m *Mongo) SaveHistory(sid string, messages []map[string]interface{}, cid string, context map[string]interface{}) error { return nil diff --git a/neo/store/redis.go b/neo/store/redis.go index 50206e1a..a5176b27 100644 --- a/neo/store/redis.go +++ b/neo/store/redis.go @@ -18,11 +18,21 @@ func (r *Redis) GetChat(sid string, cid string) (*ChatInfo, error) { return &ChatInfo{}, nil } +// GetChatWithFilter retrieves a single chat's information with filter options +func (r *Redis) GetChatWithFilter(sid string, cid string, filter ChatFilter) (*ChatInfo, error) { + return &ChatInfo{}, nil +} + // GetHistory retrieves chat history func (r *Redis) GetHistory(sid string, cid string) ([]map[string]interface{}, error) { return []map[string]interface{}{}, nil } +// GetHistoryWithFilter retrieves chat history with filter options +func (r *Redis) GetHistoryWithFilter(sid string, cid string, filter ChatFilter) ([]map[string]interface{}, error) { + return []map[string]interface{}{}, nil +} + // SaveHistory saves chat history func (r *Redis) SaveHistory(sid string, messages []map[string]interface{}, cid string, context map[string]interface{}) error { return nil diff --git a/neo/store/types.go b/neo/store/types.go index d4dcdba3..22cab473 100644 --- a/neo/store/types.go +++ b/neo/store/types.go @@ -24,6 +24,7 @@ type ChatFilter struct { Page int `json:"page,omitempty"` // Page number, starting from 1 PageSize int `json:"pagesize,omitempty"` // Number of items per page Order string `json:"order,omitempty"` // Sort order: desc/asc + Silent *bool `json:"silent,omitempty"` // Include silent messages (default: false) } // ChatGroup represents the chat group structure @@ -86,12 +87,26 @@ type Store interface { // Returns: Chat information and potential error GetChat(sid string, cid string) (*ChatInfo, error) + // GetChatWithFilter retrieves a single chat's information with filter options + // sid: Session ID + // cid: Chat ID + // filter: Filter conditions + // Returns: Chat information and potential error + GetChatWithFilter(sid string, cid string, filter ChatFilter) (*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) + // GetHistoryWithFilter retrieves chat history with filter options + // sid: Session ID + // cid: Chat ID + // filter: Filter conditions + // Returns: History record list and potential error + GetHistoryWithFilter(sid string, cid string, filter ChatFilter) ([]map[string]interface{}, error) + // SaveHistory saves chat history // sid: Session ID // messages: Message list diff --git a/neo/store/xun.go b/neo/store/xun.go index 6f3b4c97..ceae2f0a 100644 --- a/neo/store/xun.go +++ b/neo/store/xun.go @@ -3,7 +3,6 @@ package store import ( "fmt" "math" - "strings" "time" "github.com/google/uuid" @@ -145,6 +144,7 @@ func (conv *Xun) initHistoryTable() error { table.String("assistant_name", 200).Null() table.String("assistant_avatar", 200).Null() table.JSON("mentions").Null() + table.Boolean("silent").SetDefault(false).Index() table.TimestampTz("created_at").SetDefaultRaw("NOW()").Index() table.TimestampTz("updated_at").Null().Index() table.TimestampTz("expired_at").Null().Index() @@ -162,7 +162,7 @@ func (conv *Xun) initHistoryTable() error { return err } - fields := []string{"id", "sid", "cid", "uid", "role", "name", "content", "context", "assistant_id", "assistant_name", "assistant_avatar", "mentions", "created_at", "updated_at", "expired_at"} + fields := []string{"id", "sid", "cid", "uid", "role", "name", "content", "context", "assistant_id", "assistant_name", "assistant_avatar", "mentions", "silent", "created_at", "updated_at", "expired_at"} for _, field := range fields { if !tab.HasColumn(field) { return fmt.Errorf("%s is required", field) @@ -187,6 +187,7 @@ func (conv *Xun) initChatTable() error { table.String("title", 200).Null() table.String("assistant_id", 200).Null().Index() table.String("sid", 255).Index() + table.Boolean("silent").SetDefault(false).Index() table.TimestampTz("created_at").SetDefaultRaw("NOW()").Index() table.TimestampTz("updated_at").Null().Index() }) @@ -203,7 +204,7 @@ func (conv *Xun) initChatTable() error { return err } - fields := []string{"id", "chat_id", "title", "assistant_id", "sid", "created_at", "updated_at"} + fields := []string{"id", "chat_id", "title", "assistant_id", "sid", "silent", "created_at", "updated_at"} for _, field := range fields { if !tab.HasColumn(field) { return fmt.Errorf("%s is required", field) @@ -319,53 +320,90 @@ func (conv *Xun) UpdateChatTitle(sid string, cid string, title string) error { // GetChats get the chat list with grouping by date func (conv *Xun) GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, error) { + // Default behavior: exclude silent chats + if filter.Silent == nil { + silentFalse := false + filter.Silent = &silentFalse + } + + return conv.getChatsWithFilter(sid, filter) +} + +// getChatsWithFilter get the chats with filter options +func (conv *Xun) getChatsWithFilter(sid string, filter ChatFilter) (*ChatGroupResponse, error) { userID, err := conv.getUserID(sid) if err != nil { return nil, err } - // Set defaults - if filter.PageSize <= 0 { - filter.PageSize = 100 - } + // Set default values if filter.Page <= 0 { filter.Page = 1 } + if filter.PageSize <= 0 { + filter.PageSize = 20 + } if filter.Order == "" { filter.Order = "desc" } - // Build base query - qb := conv.newQueryChat(). - Select("chat_id", "title", "assistant_id", "created_at", "updated_at"). - Where("sid", userID). - Where("chat_id", "!=", "") + // Get total count + qbCount := conv.newQueryChat(). + Where("sid", userID) - // Add keyword filter - if filter.Keywords != "" { - keyword := strings.TrimSpace(filter.Keywords) - if keyword != "" { - qb.Where("title", "like", "%"+keyword+"%") + // Apply silent filter if provided + if filter.Silent != nil { + if *filter.Silent { + // Include all chats (both silent and non-silent) + } else { + // Only include non-silent chats + qbCount.Where("silent", false) } } - // Get total count - total, err := qb.Clone().Count() + // Apply keyword filter if provided + if filter.Keywords != "" { + qbCount.Where("title", "like", fmt.Sprintf("%%%s%%", filter.Keywords)) + } + + total, err := qbCount.Count() if err != nil { return nil, err } - // Calculate pagination - offset := (filter.Page - 1) * filter.PageSize + // Calculate last page lastPage := int(math.Ceil(float64(total) / float64(filter.PageSize))) + if lastPage < 1 { + lastPage = 1 + } - // Get paginated results - rows, err := qb. - OrderBy("updated_at", filter.Order). - OrderBy("created_at", filter.Order). + // Get chats with pagination + qb := conv.newQueryChat(). + Select("chat_id", "title", "assistant_id", "silent", "created_at", "updated_at"). + Where("sid", userID) + + // Apply silent filter if provided + if filter.Silent != nil { + if *filter.Silent { + // Include all chats (both silent and non-silent) + } else { + // Only include non-silent chats + qb.Where("silent", false) + } + } + + // Apply keyword filter if provided + if filter.Keywords != "" { + qb.Where("title", "like", fmt.Sprintf("%%%s%%", filter.Keywords)) + } + + // Apply pagination + offset := (filter.Page - 1) * filter.PageSize + qb.OrderBy("updated_at", filter.Order). Offset(offset). - Limit(filter.PageSize). - Get() + Limit(filter.PageSize) + + rows, err := qb.Get() if err != nil { return nil, err } @@ -385,16 +423,16 @@ func (conv *Xun) GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, er "Even Earlier": {}, } - // Get assistant details for all chats + // Collect assistant IDs to fetch their details assistantIDs := []interface{}{} - assistantMap := make(map[string]map[string]interface{}) - for _, row := range rows { if assistantID := row.Get("assistant_id"); assistantID != nil && assistantID != "" { assistantIDs = append(assistantIDs, assistantID) } } + // Fetch assistant details + assistantMap := map[string]map[string]interface{}{} if len(assistantIDs) > 0 { assistants, err := conv.query.New(). Table(conv.getAssistantTable()). @@ -425,6 +463,7 @@ func (conv *Xun) GetChats(sid string, filter ChatFilter) (*ChatGroupResponse, er "chat_id": chatID, "title": row.Get("title"), "assistant_id": row.Get("assistant_id"), + "silent": row.Get("silent"), } // Add assistant details if available @@ -502,11 +541,14 @@ func (conv *Xun) GetHistory(sid string, cid string) ([]map[string]interface{}, e } qb := conv.newQuery(). - Select("role", "name", "content", "context", "assistant_id", "assistant_name", "assistant_avatar", "mentions", "uid", "created_at", "updated_at"). + Select("role", "name", "content", "context", "assistant_id", "assistant_name", "assistant_avatar", "mentions", "uid", "silent", "created_at", "updated_at"). Where("sid", userID). Where("cid", cid). OrderBy("id", "desc") + // By default, exclude silent messages + qb.Where("silent", false) + if conv.setting.TTL > 0 { qb.Where("expired_at", ">", time.Now()) } @@ -533,6 +575,7 @@ func (conv *Xun) GetHistory(sid string, cid string) ([]map[string]interface{}, e "assistant_avatar": row.Get("assistant_avatar"), "mentions": row.Get("mentions"), "uid": row.Get("uid"), + "silent": row.Get("silent"), "created_at": row.Get("created_at"), "updated_at": row.Get("updated_at"), } @@ -562,6 +605,23 @@ func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid } } + // Get silent flag from context + var silent bool = false + if context != nil { + if silentVal, ok := context["silent"]; ok { + switch v := silentVal.(type) { + case bool: + silent = v + case string: + silent = v == "true" || v == "1" || v == "yes" + case int: + silent = v != 0 + case float64: + silent = v != 0 + } + } + } + // First ensure chat record exists exists, err := conv.newQueryChat(). Where("chat_id", cid). @@ -579,6 +639,7 @@ func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid "chat_id": cid, "sid": userID, "assistant_id": assistantID, + "silent": silent, "created_at": time.Now(), }) @@ -586,17 +647,16 @@ func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid return err } } else { - // Update assistant_id if it exists - if assistantID != nil { - _, err = conv.newQueryChat(). - Where("chat_id", cid). - Where("sid", userID). - Update(map[string]interface{}{ - "assistant_id": assistantID, - }) - if err != nil { - return err - } + // Update assistant_id and silent if needed + _, err = conv.newQueryChat(). + Where("chat_id", cid). + Where("sid", userID). + Update(map[string]interface{}{ + "assistant_id": assistantID, + "silent": silent, + }) + if err != nil { + return err } } @@ -650,6 +710,7 @@ func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid "assistant_id": nil, "assistant_name": nil, "assistant_avatar": nil, + "silent": silent, "created_at": now, "updated_at": nil, "expired_at": expiredAt, @@ -736,7 +797,7 @@ func (conv *Xun) GetChat(sid string, cid string) (*ChatInfo, error) { } } - // Get chat history + // Get chat history with default filter (silent=false) history, err := conv.GetHistory(sid, cid) if err != nil { return nil, err @@ -748,6 +809,64 @@ func (conv *Xun) GetChat(sid string, cid string) (*ChatInfo, error) { }, nil } +// GetChatWithFilter get the chat info and its history with filter options +func (conv *Xun) GetChatWithFilter(sid string, cid string, filter ChatFilter) (*ChatInfo, error) { + userID, err := conv.getUserID(sid) + if err != nil { + return nil, err + } + + // Get chat info + qb := conv.newQueryChat(). + Select("chat_id", "title", "assistant_id"). + Where("sid", userID). + Where("chat_id", cid) + + row, err := qb.First() + if err != nil { + return nil, err + } + + // Return nil if chat_id is nil (means no chat found) + if row.Get("chat_id") == nil { + return nil, nil + } + + chat := map[string]interface{}{ + "chat_id": row.Get("chat_id"), + "title": row.Get("title"), + "assistant_id": row.Get("assistant_id"), + } + + // Get assistant details if assistant_id exists + if assistantID := row.Get("assistant_id"); assistantID != nil && assistantID != "" { + assistant, err := conv.query.New(). + Table(conv.getAssistantTable()). + Select("name", "avatar"). + Where("assistant_id", assistantID). + First() + if err != nil { + return nil, err + } + + if assistant != nil { + chat["assistant_name"] = assistant.Get("name") + chat["assistant_avatar"] = assistant.Get("avatar") + } + } + + // Get chat history with filter + history, err := conv.GetHistoryWithFilter(sid, cid, filter) + if err != nil { + return nil, err + } + + return &ChatInfo{ + Chat: chat, + History: history, + }, nil +} + // DeleteChat deletes a specific chat and its history func (conv *Xun) DeleteChat(sid string, cid string) error { userID, err := conv.getUserID(sid) @@ -1164,3 +1283,74 @@ func (conv *Xun) GetAssistantTags() ([]string, error) { } return tags, nil } + +// GetHistoryWithFilter get the history with filter options +func (conv *Xun) GetHistoryWithFilter(sid string, cid string, filter ChatFilter) ([]map[string]interface{}, error) { + userID, err := conv.getUserID(sid) + if err != nil { + return nil, err + } + + qb := conv.newQuery(). + Select("role", "name", "content", "context", "assistant_id", "assistant_name", "assistant_avatar", "mentions", "uid", "silent", "created_at", "updated_at"). + Where("sid", userID). + Where("cid", cid). + OrderBy("id", "desc") + + // Apply silent filter if provided, otherwise exclude silent messages by default + if filter.Silent != nil { + if *filter.Silent { + // Include all messages (both silent and non-silent) + } else { + // Only include non-silent messages + qb.Where("silent", false) + } + } else { + // Default behavior: exclude silent messages + qb.Where("silent", false) + } + + if conv.setting.TTL > 0 { + qb.Where("expired_at", ">", time.Now()) + } + + limit := 20 + if conv.setting.MaxSize > 0 { + limit = conv.setting.MaxSize + } + if filter.PageSize > 0 { + limit = filter.PageSize + } + + // Apply pagination if provided + if filter.Page > 0 { + offset := (filter.Page - 1) * limit + qb.Offset(offset) + } + + rows, err := qb.Limit(limit).Get() + if err != nil { + return nil, err + } + + res := []map[string]interface{}{} + for _, row := range rows { + message := map[string]interface{}{ + "role": row.Get("role"), + "name": row.Get("name"), + "content": row.Get("content"), + "context": row.Get("context"), + "assistant_id": row.Get("assistant_id"), + "assistant_name": row.Get("assistant_name"), + "assistant_avatar": row.Get("assistant_avatar"), + "mentions": row.Get("mentions"), + "uid": row.Get("uid"), + "silent": row.Get("silent"), + "created_at": row.Get("created_at"), + "updated_at": row.Get("updated_at"), + } + res = append([]map[string]interface{}{message}, res...) + } + + return res, nil +} diff --git a/neo/store/xun_test.go b/neo/store/xun_test.go index e1a4d7d7..e1d1f1bd 100644 --- a/neo/store/xun_test.go +++ b/neo/store/xun_test.go @@ -996,3 +996,252 @@ func TestGetAssistantTags(t *testing.T) { } } } + +func TestXunSaveAndGetHistoryWithSilent(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") + + err := capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + if err != nil { + t.Fatal(err) + } + + err = capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") + if err != nil { + t.Fatal(err) + } + + store, err := NewXun(Setting{ + Connector: "default", + Prefix: "__unit_test_conversation_", + TTL: 3600, + }) + + // save the history with silent messages + sid := "123456" + cid := "silent_test" + + // First save regular messages + messages := []map[string]interface{}{ + {"role": "user", "name": "user1", "content": "hello"}, + {"role": "assistant", "name": "assistant1", "content": "Hi! How can I help you?"}, + } + context := map[string]interface{}{ + "assistant_id": "test-assistant-1", + } + err = store.SaveHistory(sid, messages, cid, context) + assert.Nil(t, err) + + // Then save silent messages + silentMessages := []map[string]interface{}{ + {"role": "user", "name": "user1", "content": "silent message"}, + {"role": "assistant", "name": "assistant1", "content": "This is a silent response"}, + } + silentContext := map[string]interface{}{ + "assistant_id": "test-assistant-1", + "silent": true, + } + err = store.SaveHistory(sid, silentMessages, cid, silentContext) + assert.Nil(t, err) + + // Get history without filter (should only return non-silent messages) + data, err := store.GetHistory(sid, cid) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, 2, len(data)) + for _, msg := range data { + // Check if silent is false, handling different types + isSilent := false + switch v := msg["silent"].(type) { + case bool: + isSilent = v + case int: + isSilent = v != 0 + case int64: + isSilent = v != 0 + case float64: + isSilent = v != 0 + } + assert.False(t, isSilent, "message should not be silent") + } + + // Get history with silent=true filter (should return all messages) + silentTrue := true + filter := ChatFilter{ + Silent: &silentTrue, + } + allData, err := store.GetHistoryWithFilter(sid, cid, filter) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, 4, len(allData)) + + // Count silent messages + silentCount := 0 + for _, msg := range allData { + // Check if silent is true, handling different types + isSilent := false + switch v := msg["silent"].(type) { + case bool: + isSilent = v + case int: + isSilent = v != 0 + case int64: + isSilent = v != 0 + case float64: + isSilent = v != 0 + } + if isSilent { + silentCount++ + } + } + assert.Equal(t, 2, silentCount) + + // Get chat with filter (should include silent messages) + chat, err := store.GetChatWithFilter(sid, cid, filter) + assert.Nil(t, err) + assert.Equal(t, 4, len(chat.History)) + + // Get chat without filter (should exclude silent messages) + chatNoSilent, err := store.GetChat(sid, cid) + assert.Nil(t, err) + assert.Equal(t, 2, len(chatNoSilent.History)) +} + +func TestXunGetChatsWithSilent(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 tables before test + err := capsule.Schema().DropTableIfExists("__unit_test_conversation_history") + if err != nil { + t.Fatal(err) + } + err = capsule.Schema().DropTableIfExists("__unit_test_conversation_chat") + if err != nil { + t.Fatal(err) + } + err = capsule.Schema().DropTableIfExists("__unit_test_conversation_assistant") + if err != nil { + t.Fatal(err) + } + + store, err := NewXun(Setting{ + Connector: "default", + Prefix: "__unit_test_conversation_", + }) + if err != nil { + t.Fatal(err) + } + + // Create test assistant + assistant := map[string]interface{}{ + "assistant_id": "test-assistant-1", + "name": "Test Assistant 1", + "avatar": "avatar1.png", + "type": "assistant", + "connector": "test", + } + _, err = store.SaveAssistant(assistant) + assert.Nil(t, err) + + // Save some test chats + sid := "test_user" + messages := []map[string]interface{}{ + {"role": "user", "content": "test message"}, + } + + // Create regular chats + for i := 0; i < 3; i++ { + chatID := fmt.Sprintf("regular_chat_%d", i) + title := fmt.Sprintf("Regular Chat %d", i) + context := map[string]interface{}{ + "assistant_id": "test-assistant-1", + "silent": false, + } + + // Save history to create the chat + err = store.SaveHistory(sid, messages, chatID, context) + assert.Nil(t, err) + + // Update the chat title + err = store.UpdateChatTitle(sid, chatID, title) + assert.Nil(t, err) + } + + // Create silent chats + for i := 0; i < 2; i++ { + chatID := fmt.Sprintf("silent_chat_%d", i) + title := fmt.Sprintf("Silent Chat %d", i) + context := map[string]interface{}{ + "assistant_id": "test-assistant-1", + "silent": true, + } + + // Save history to create the chat + err = store.SaveHistory(sid, messages, chatID, context) + assert.Nil(t, err) + + // Update the chat title + err = store.UpdateChatTitle(sid, chatID, title) + assert.Nil(t, err) + } + + // Test GetChats with default filter (should exclude silent chats) + defaultFilter := ChatFilter{ + PageSize: 10, + Order: "desc", + } + defaultGroups, err := store.GetChats(sid, defaultFilter) + assert.Nil(t, err) + assert.NotNil(t, defaultGroups) + + // Count total chats in all groups + totalDefaultChats := 0 + for _, group := range defaultGroups.Groups { + totalDefaultChats += len(group.Chats) + } + assert.Equal(t, 3, totalDefaultChats, "Default filter should only return non-silent chats") + + // Test GetChats with silent=true filter (should include all chats) + silentTrue := true + silentFilter := ChatFilter{ + PageSize: 10, + Order: "desc", + Silent: &silentTrue, + } + silentGroups, err := store.GetChats(sid, silentFilter) + assert.Nil(t, err) + assert.NotNil(t, silentGroups) + + // Count total chats in all groups + totalSilentChats := 0 + for _, group := range silentGroups.Groups { + totalSilentChats += len(group.Chats) + } + assert.Equal(t, 5, totalSilentChats, "Silent filter should return all chats") + + // Test GetChats with silent=false filter (should only include non-silent chats) + silentFalse := false + nonSilentFilter := ChatFilter{ + PageSize: 10, + Order: "desc", + Silent: &silentFalse, + } + nonSilentGroups, err := store.GetChats(sid, nonSilentFilter) + assert.Nil(t, err) + assert.NotNil(t, nonSilentGroups) + + // Count total chats in all groups + totalNonSilentChats := 0 + for _, group := range nonSilentGroups.Groups { + totalNonSilentChats += len(group.Chats) + } + assert.Equal(t, 3, totalNonSilentChats, "Non-silent filter should only return non-silent chats") +} From c74d32390cf2e1fc0a073cbe0f57c8ae2d5abe46 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 3 Mar 2025 14:49:31 +0800 Subject: [PATCH 21/27] Add retry mechanism for chat streaming with error handling - Implement HookRetry method to handle retry scenarios in chat streaming - Add retry tracking with RetryTimes and Retry flag in context - Create retryMessages method to modify messages for retry attempts - Enhance error handling and logging for retry scenarios - Add color-coded error output for retry failures --- neo/assistant/api.go | 59 ++++++++++++++++++++++++++++++++++++++++- neo/assistant/hooks.go | 32 ++++++++++++++++++++++ neo/assistant/object.go | 38 ++++++++++++++++++++++++++ neo/context/context.go | 13 +++++++-- 4 files changed, 139 insertions(+), 3 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 4e275572..4bd611bc 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -7,9 +7,11 @@ import ( "os" "strings" + "github.com/fatih/color" "github.com/gin-gonic/gin" jsoniter "github.com/json-iterator/go" "github.com/yaoapp/gou/fs" + "github.com/yaoapp/kun/exception" "github.com/yaoapp/kun/log" chatctx "github.com/yaoapp/yao/neo/context" chatMessage "github.com/yaoapp/yao/neo/message" @@ -365,9 +367,11 @@ func (ast *Assistant) streamChat( toolsCount := 0 currentMessageID := "" + var retry error = nil var result interface{} = nil // To save the result var content string = "" // To save the content err := ast.Chat(c.Request.Context(), messages, options, func(data []byte) int { + select { case <-clientBreak: return 0 // break @@ -568,7 +572,7 @@ func (ast *Assistant) streamChat( // Some error occurred in the hook, return the error if hookErr != nil { - chatMessage.New().Error(hookErr.Error()).Done().Callback(cb).Write(c.Writer) + retry = hookErr return 0 // break } @@ -610,6 +614,38 @@ func (ast *Assistant) streamChat( } }) + // retry + if retry != nil { + + // Update the retry times + ctx.RetryTimes = ctx.RetryTimes + 1 // Increment the retry times + ctx.Retry = true // Set the retry mode + + // Hook retry + prompt, retryErr := ast.HookRetry(c, ctx, messages, contents, exception.Trim(retry)) + if retryErr != nil { + color.Red("%s, try to fix the error %d times, but failed with %s", exception.Trim(retry), ctx.RetryTimes, exception.Trim(retryErr)) + chatMessage.New().Error(retry.Error()).Done().Callback(cb).Write(c.Writer) + return nil, retry + } + + // if the prompt is empty, return the error + if prompt == "" { + return nil, retry + } + + // Add the prompt to the messages + retryMessages, retryErr := ast.retryMessages(messages, prompt) + if retryErr != nil { + color.Red("%s, try to fix the error %d times, but failed with %s", exception.Trim(retry), ctx.RetryTimes, exception.Trim(retryErr)) + chatMessage.New().Error(retry.Error()).Done().Callback(cb).Write(c.Writer) + return nil, retry + } + + // Retry the chat + return ast.streamChat(c, ctx, retryMessages, options, clientBreak, contents, cb) + } + // Handle error if err != nil { return nil, err @@ -635,6 +671,27 @@ func (ast *Assistant) streamChat( return strings.TrimSpace(content), nil } +func (ast *Assistant) retryMessages(messages []chatMessage.Message, prompt string) ([]chatMessage.Message, error) { + + // Get the last user message + var lastIndex int + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + messages[i].Text = prompt + lastIndex = i + break + } + } + + if lastIndex == 0 { + return nil, fmt.Errorf("no user message found") + } + + // Remove the messages after the last user message + messages = messages[:lastIndex+1] + return messages, nil +} + // saveChatHistory saves the chat history if storage is available func (ast *Assistant) saveChatHistory(ctx chatctx.Context, messages []chatMessage.Message, contents *chatMessage.Contents) { if len(contents.Data) > 0 && ctx.Sid != "" && len(messages) > 0 { diff --git a/neo/assistant/hooks.go b/neo/assistant/hooks.go index e099ee10..32adc7ce 100644 --- a/neo/assistant/hooks.go +++ b/neo/assistant/hooks.go @@ -134,6 +134,38 @@ func (ast *Assistant) HookStream(c *gin.Context, context chatctx.Context, input return response, nil } +// HookRetry Handle retry of assistant response +func (ast *Assistant) HookRetry(c *gin.Context, context chatctx.Context, input []message.Message, contents *chatMessage.Contents, errmsg string) (string, error) { + ctx := ast.createBackgroundContext() + output := []message.Data{} + if len(input) < 1 { + return "", fmt.Errorf("no input") + } + + var lastInput message.Message = input[len(input)-1] + for _, data := range contents.Data { + if data.Type == "think" { + continue + } + output = append(output, data) + } + + v, err := ast.call(ctx, "Retry", c, contents, context, lastInput.String(), output, errmsg) + if err != nil { + if err.Error() == HookErrorMethodNotFound { + return "", nil + } + return "", err + } + + res, ok := v.(string) + if !ok { + return "", fmt.Errorf("invalid return type: %T", v) + } + + return res, nil +} + // HookDone Handle completion of assistant response func (ast *Assistant) HookDone(c *gin.Context, context chatctx.Context, input []message.Message, contents *chatMessage.Contents) (*ResHookDone, error) { // Create timeout context diff --git a/neo/assistant/object.go b/neo/assistant/object.go index c97041c3..5d672d2f 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -9,6 +9,7 @@ import ( chatctx "github.com/yaoapp/yao/neo/context" "github.com/yaoapp/yao/neo/message" chatMessage "github.com/yaoapp/yao/neo/message" + sui "github.com/yaoapp/yao/sui/core" "rogchap.com/v8go" ) @@ -69,6 +70,9 @@ func (ast *Assistant) InitObject(v8ctx *v8.Context, c *gin.Context, context chat v8ctx.WithFunction("Get", jsGet) v8ctx.WithFunction("Del", jsDel) v8ctx.WithFunction("Clear", jsClear) + + // Template methods + v8ctx.WithFunction("Replace", jsReplace) } // jsSet function, set a value to the shared space @@ -301,6 +305,40 @@ func jsSend(info *v8go.FunctionCallbackInfo) *v8go.Value { } } +func jsReplace(info *v8go.FunctionCallbackInfo) *v8go.Value { + args := info.Args() + if len(args) < 2 { + return bridge.JsException(info.Context(), "Replace requires at least two arguments") + } + + if !args[0].IsString() { + return bridge.JsException(info.Context(), "the first argument must be a string") + } + tmpl := args[0].String() + + raw, err := bridge.GoValue(args[1], info.Context()) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + data, ok := raw.(map[string]interface{}) + if !ok { + return bridge.JsException(info.Context(), "the second argument must be a map") + } + + replaced, _ := sui.Data(data).Replace(tmpl) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + jsReplaced, err := bridge.JsValue(info.Context(), replaced) + if err != nil { + return bridge.JsException(info.Context(), err.Error()) + } + + return jsReplaced +} + // global get the global variables func global(info *v8go.FunctionCallbackInfo) (global *GlobalVariables, err error) { return getGlobal(info.Context(), info.This()) diff --git a/neo/context/context.go b/neo/context/context.go index 8d0d9442..77d1c43f 100644 --- a/neo/context/context.go +++ b/neo/context/context.go @@ -22,8 +22,9 @@ type Context struct { Namespace string `json:"namespace,omitempty"` Config map[string]interface{} `json:"config,omitempty"` Signal interface{} `json:"signal,omitempty"` - Silent bool `json:"silent,omitempty"` // Silent mode - Retry bool `json:"retry,omitempty"` // Retry mode + Silent bool `json:"silent,omitempty"` // Silent mode + Retry bool `json:"retry,omitempty"` // Retry mode + RetryTimes uint8 `json:"retry_times,omitempty"` // Retry times Upload *FileUpload `json:"upload,omitempty"` Version bool `json:"version,omitempty"` // Version support RAG bool `json:"rag,omitempty"` // RAG support @@ -119,6 +120,14 @@ func (ctx *Context) Map() map[string]interface{} { data["silent"] = ctx.Silent } + // Retry mode + if ctx.Retry { + data["retry"] = ctx.Retry + } + + // Retry times + data["retry_times"] = ctx.RetryTimes + if ctx.Path != "" { data["pathname"] = ctx.Path } From 48d65c0e3929bc4d0188ea78ea48bad6f67a8137 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 3 Mar 2025 15:26:24 +0800 Subject: [PATCH 22/27] Handle empty prompt in chat streaming with error callback - Add error handling for empty prompts in streamChat method - Ensure proper error message generation and callback invocation - Improve error propagation in chat streaming scenarios --- neo/assistant/api.go | 1 + 1 file changed, 1 insertion(+) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 4bd611bc..f8b0bef8 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -631,6 +631,7 @@ func (ast *Assistant) streamChat( // if the prompt is empty, return the error if prompt == "" { + chatMessage.New().Error(retry.Error()).Done().Callback(cb).Write(c.Writer) return nil, retry } From 709c7ec738ba9d694b8ae2478d895d5fc2e03ccc Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 3 Mar 2025 18:13:10 +0800 Subject: [PATCH 23/27] Add optional options handling in objectCall.run method - Modify options unmarshaling to handle undefined JavaScript options - Add null check to prevent unmarshaling errors when no options are provided - Improve robustness of method call argument processing --- neo/assistant/call.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/neo/assistant/call.go b/neo/assistant/call.go index 532e8a7e..cc17194e 100644 --- a/neo/assistant/call.go +++ b/neo/assistant/call.go @@ -202,9 +202,12 @@ func (obj *objectCall) run(info *v8go.FunctionCallbackInfo) *v8go.Value { return bridge.JsException(info.Context(), fmt.Sprintf("Failed to get the options: %s", err.Error())) } - err = bridge.Unmarshal(jsOptions, &options) - if err != nil { - return bridge.JsException(info.Context(), fmt.Sprintf("Failed to unmarshal the options: %s", err.Error())) + // Check if the options is undefined + if !jsOptions.IsUndefined() { + err = bridge.Unmarshal(jsOptions, &options) + if err != nil { + return bridge.JsException(info.Context(), fmt.Sprintf("Failed to unmarshal the options: %s", err.Error())) + } } } From f469568d85e95c8db6964e05013c341198693473 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 4 Mar 2025 12:03:19 +0800 Subject: [PATCH 24/27] Improve request context handling in chat streaming - Use request context for client connection tracking - Replace CloseNotify with context cancellation - Enhance streaming method to handle request context termination --- neo/assistant/api.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index f8b0bef8..69bda80c 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -319,7 +319,7 @@ func (ast *Assistant) handleChatStream(c *gin.Context, ctx chatctx.Context, mess var result interface{} = nil var err error = nil - // Chat with AI in background + requestCtx := c.Request.Context() go func() { var res interface{} = nil res, err = ast.streamChat(c, ctx, messages, options, clientBreak, contents, callback...) @@ -338,7 +338,8 @@ func (ast *Assistant) handleChatStream(c *gin.Context, ctx chatctx.Context, mess return nil, err } return result, nil - case <-c.Writer.CloseNotify(): + + case <-requestCtx.Done(): clientBreak <- true return nil, nil } From dafd6b029b0c9327b92197866e7c2d7cc319c129 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 4 Mar 2025 15:27:40 +0800 Subject: [PATCH 25/27] Enhance retry mechanism with flexible action handling - Modify HookRetry to support returning NextAction - Update streamChat to handle different retry response types - Add type switching for retry hook return values - Implement execution of NextAction in retry flow - Improve error handling and response processing during retry --- neo/assistant/api.go | 22 ++++++++++++++++++---- neo/assistant/hooks.go | 18 +++++++++++++----- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 69bda80c..64ffc10a 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -623,19 +623,32 @@ func (ast *Assistant) streamChat( ctx.Retry = true // Set the retry mode // Hook retry - prompt, retryErr := ast.HookRetry(c, ctx, messages, contents, exception.Trim(retry)) + promptAny, retryErr := ast.HookRetry(c, ctx, messages, contents, exception.Trim(retry)) if retryErr != nil { color.Red("%s, try to fix the error %d times, but failed with %s", exception.Trim(retry), ctx.RetryTimes, exception.Trim(retryErr)) chatMessage.New().Error(retry.Error()).Done().Callback(cb).Write(c.Writer) return nil, retry } - // if the prompt is empty, return the error - if prompt == "" { + if promptAny == nil { chatMessage.New().Error(retry.Error()).Done().Callback(cb).Write(c.Writer) return nil, retry } + var prompt string = "" + switch v := promptAny.(type) { + case NextAction: + result, err := v.Execute(c, ctx, contents, cb) + if err != nil { + chatMessage.New().Error(err.Error()).Done().Callback(cb).Write(c.Writer) + return nil, retry + } + return result, nil + + case string: + prompt = v + } + // Add the prompt to the messages retryMessages, retryErr := ast.retryMessages(messages, prompt) if retryErr != nil { @@ -645,7 +658,8 @@ func (ast *Assistant) streamChat( } // Retry the chat - return ast.streamChat(c, ctx, retryMessages, options, clientBreak, contents, cb) + retryContents := chatMessage.NewContents() + return ast.execute(c, ctx, retryMessages, options, retryContents, cb) } // Handle error diff --git a/neo/assistant/hooks.go b/neo/assistant/hooks.go index 32adc7ce..31aaec2c 100644 --- a/neo/assistant/hooks.go +++ b/neo/assistant/hooks.go @@ -135,7 +135,7 @@ func (ast *Assistant) HookStream(c *gin.Context, context chatctx.Context, input } // HookRetry Handle retry of assistant response -func (ast *Assistant) HookRetry(c *gin.Context, context chatctx.Context, input []message.Message, contents *chatMessage.Contents, errmsg string) (string, error) { +func (ast *Assistant) HookRetry(c *gin.Context, context chatctx.Context, input []message.Message, contents *chatMessage.Contents, errmsg string) (interface{}, error) { ctx := ast.createBackgroundContext() output := []message.Data{} if len(input) < 1 { @@ -158,12 +158,20 @@ func (ast *Assistant) HookRetry(c *gin.Context, context chatctx.Context, input [ return "", err } - res, ok := v.(string) - if !ok { - return "", fmt.Errorf("invalid return type: %T", v) + switch v := v.(type) { + case string: + return v, nil + case map[string]interface{}: + var next NextAction + raw, _ := jsoniter.MarshalToString(v) + err := jsoniter.UnmarshalFromString(raw, &next) + if err != nil { + return "", err + } + return next, nil } - return res, nil + return "", nil } // HookDone Handle completion of assistant response From 47da72bbeecec69a2e8892ffbea4bd518976a63b Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 4 Mar 2025 17:31:07 +0800 Subject: [PATCH 26/27] Handle null return for non-existent shared space keys - Modify jsGet method to return null when key is not found - Add string-based error checking to differentiate between missing keys and other errors - Improve shared space key retrieval error handling in JavaScript bridge --- neo/assistant/object.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/neo/assistant/object.go b/neo/assistant/object.go index 5d672d2f..ea6e1c9b 100644 --- a/neo/assistant/object.go +++ b/neo/assistant/object.go @@ -2,6 +2,7 @@ package assistant import ( "fmt" + "strings" "github.com/gin-gonic/gin" v8 "github.com/yaoapp/gou/runtime/v8" @@ -145,6 +146,10 @@ func jsGet(info *v8go.FunctionCallbackInfo) *v8go.Value { // Get the value value, err := global.ChatContext.SharedSpace.Get(key) if err != nil { + // If the key is not found, return null + if strings.Contains(err.Error(), "not found") { + return v8go.Null(info.Context().Isolate()) + } return bridge.JsException(info.Context(), err.Error()) } From e59dacefa2b1770455e74b2e102fe1c935dbc477 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 6 Mar 2025 10:48:27 +0800 Subject: [PATCH 27/27] Improve error handling in request value execution - Modify request value execution to log errors when method calls fail - Prevent propagation of errors during value resolution - Update error message in data merging to be more descriptive --- sui/api/request.go | 2 +- sui/core/request.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/sui/api/request.go b/sui/api/request.go index 8ca48a40..6b45d5ef 100644 --- a/sui/api/request.go +++ b/sui/api/request.go @@ -141,7 +141,7 @@ func (r *Request) Render() (string, int, error) { if c.Data != "" { err = r.Request.ExecStringMerge(data, c.Data) if err != nil { - return "", 500, fmt.Errorf("data error, please re-complie the page. %s", err.Error()) + return "", 500, fmt.Errorf("data merge error, please re-complie the page. %s", err.Error()) } } diff --git a/sui/core/request.go b/sui/core/request.go index 43d8f71b..3382d447 100644 --- a/sui/core/request.go +++ b/sui/core/request.go @@ -218,7 +218,12 @@ func (r *Request) execValue(value interface{}) (interface{}, error) { } if strings.HasPrefix(v, "$") { - return r.call(strings.TrimLeft(v, "$")) + res, err := r.call(strings.TrimLeft(v, "$")) + if err != nil { + log.Error("[Request] Exec value:%s, %s", v, err.Error()) + return nil, nil + } + return res, nil } return v, nil