Remove deprecated studio package and refactor agent integration - Deleted the studio package, which is no longer in use, to streamline the codebase. - Updated references in the agent and chat modules to utilize the new agent package instead of the deprecated neo package. - Ensured that all related middleware and routing functionalities are now aligned with the agent architecture, enhancing overall system coherence.
1277 lines
35 KiB
Go
1277 lines
35 KiB
Go
package assistant
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/agent/context"
|
|
"github.com/yaoapp/yao/agent/i18n"
|
|
"github.com/yaoapp/yao/agent/message"
|
|
chatMessage "github.com/yaoapp/yao/agent/message"
|
|
)
|
|
|
|
// Get get the assistant by id
|
|
func Get(id string) (*Assistant, error) {
|
|
return LoadStore(id)
|
|
}
|
|
|
|
// GetByConnector get the assistant by connector
|
|
func GetByConnector(connector string, name string) (*Assistant, error) {
|
|
id := "connector:" + connector
|
|
|
|
assistant, exists := loaded.Get(id)
|
|
if exists {
|
|
return assistant, nil
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"assistant_id": id,
|
|
"connector": connector,
|
|
"description": "Default assistant for " + connector,
|
|
"name": name,
|
|
"type": "assistant",
|
|
}
|
|
|
|
assistant, err := loadMap(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
loaded.Put(assistant)
|
|
return assistant, nil
|
|
}
|
|
|
|
// Execute implements the execute functionality
|
|
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 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{}) (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 nil, fmt.Errorf("marshal input error: %s", err.Error())
|
|
}
|
|
err = jsoniter.Unmarshal(raw, &input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unmarshal input error: %s", err.Error())
|
|
}
|
|
|
|
case []chatMessage.Message:
|
|
input = v
|
|
}
|
|
|
|
if contents == nil {
|
|
contents = chatMessage.NewContents()
|
|
}
|
|
options := ast.withOptions(userOptions)
|
|
|
|
// Add RAG、Vision and Search support
|
|
// ctx.RAG = rag != nil
|
|
ctx.Knowledge = false
|
|
ctx.Vision = ast.vision
|
|
ctx.Search = ast.search && search != nil
|
|
|
|
// Run init hook
|
|
res, err := ast.HookCreate(c, ctx, input, options, contents)
|
|
if err != nil {
|
|
chatMessage.New().
|
|
// Assistant(ast.ID, ast.Name, ast.Avatar).
|
|
Error(err).
|
|
Done().
|
|
Write(c.Writer)
|
|
return nil, err
|
|
}
|
|
|
|
// Update options if provided
|
|
if res != nil && res.Options != nil {
|
|
options = res.Options
|
|
}
|
|
|
|
// messages
|
|
if res != nil && res.Input != nil {
|
|
input = res.Input
|
|
}
|
|
|
|
// Has result return directly
|
|
if res != nil && res.Result != nil {
|
|
output := chatMessage.New().
|
|
// Assistant(ast.ID, ast.Name, ast.Avatar).
|
|
SetResult(res.Result).
|
|
Done()
|
|
|
|
// Has callback function
|
|
if len(callback) > 0 {
|
|
output.Callback(callback[0]).Write(c.Writer)
|
|
return res.Result, nil
|
|
}
|
|
output.Write(c.Writer)
|
|
return res.Result, nil
|
|
}
|
|
|
|
// Switch to the new assistant if necessary
|
|
if res != nil && res.AssistantID != "" && res.AssistantID != ctx.AssistantID {
|
|
newAst, err := Get(res.AssistantID)
|
|
if err != nil {
|
|
chatMessage.New().
|
|
// Assistant(ast.ID, ast.Name, ast.Avatar).
|
|
Error(err).
|
|
Done().
|
|
Write(c.Writer)
|
|
return nil, err
|
|
}
|
|
|
|
// Reset Message Contents
|
|
last := input[len(input)-1]
|
|
input, err = newAst.withHistory(ctx, last)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reset options
|
|
options = newAst.withOptions(userOptions)
|
|
|
|
// Update options if provided
|
|
if res.Options != nil {
|
|
options = res.Options
|
|
}
|
|
|
|
// Update assistant id
|
|
ctx.AssistantID = res.AssistantID
|
|
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, callback...)
|
|
}
|
|
|
|
// Execute the next action
|
|
func (next *NextAction) Execute(c *gin.Context, ctx chatctx.Context, contents *chatMessage.Contents, callback ...interface{}) (interface{}, error) {
|
|
switch next.Action {
|
|
|
|
case "assistant":
|
|
if next.Payload == nil {
|
|
return nil, fmt.Errorf("payload is required")
|
|
}
|
|
|
|
// Get assistant id
|
|
id, ok := next.Payload["assistant_id"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("assistant id should be string")
|
|
}
|
|
|
|
// Get assistant
|
|
assistant, err := Get(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get assistant error: %s", err.Error())
|
|
}
|
|
|
|
// Input
|
|
input := chatMessage.Message{}
|
|
_, has := next.Payload["input"]
|
|
if !has {
|
|
return nil, 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{}
|
|
err := jsoniter.UnmarshalFromString(v, &messages)
|
|
if err != nil {
|
|
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 nil, fmt.Errorf("unmarshal input error: %s", err.Error())
|
|
}
|
|
input = *msg
|
|
|
|
case *chatMessage.Message:
|
|
input = *v
|
|
|
|
case chatMessage.Message:
|
|
input = v
|
|
|
|
default:
|
|
return nil, fmt.Errorf("input should be string or []chatMessage.Message")
|
|
}
|
|
|
|
// Options
|
|
options := map[string]interface{}{}
|
|
if v, ok := next.Payload["options"].(map[string]interface{}); ok {
|
|
options = v
|
|
}
|
|
|
|
input.Hidden = true // not show in the history
|
|
if input.Name == "" && ctx.Sid != "" { // add user id to the input
|
|
input.Name = ctx.Sid
|
|
}
|
|
|
|
messages, err := assistant.withHistory(ctx, input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("with history error: %s", err.Error())
|
|
}
|
|
newContents := chatMessage.NewContents()
|
|
|
|
// Update the context id
|
|
ctx.AssistantID = assistant.ID
|
|
return assistant.execute(c, ctx, messages, options, newContents, callback...)
|
|
|
|
case "exit":
|
|
return nil, nil
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unknown action: %s", next.Action)
|
|
}
|
|
}
|
|
|
|
// GetPlaceholder returns the placeholder of the assistant
|
|
func (ast *Assistant) GetPlaceholder(locale string) *Placeholder {
|
|
|
|
prompts := []string{}
|
|
if ast.Placeholder.Prompts != nil {
|
|
prompts = i18n.Translate(ast.ID, locale, ast.Placeholder.Prompts).([]string)
|
|
}
|
|
title := i18n.Translate(ast.ID, locale, ast.Placeholder.Title).(string)
|
|
description := i18n.Translate(ast.ID, locale, ast.Placeholder.Description).(string)
|
|
return &Placeholder{
|
|
Title: title,
|
|
Description: description,
|
|
Prompts: prompts,
|
|
}
|
|
}
|
|
|
|
// GetName returns the name of the assistant
|
|
func (ast *Assistant) GetName(locale string) string {
|
|
return i18n.Translate(ast.ID, locale, ast.Name).(string)
|
|
}
|
|
|
|
// GetDescription returns the description of the assistant
|
|
func (ast *Assistant) GetDescription(locale string) string {
|
|
return i18n.Translate(ast.ID, locale, ast.Description).(string)
|
|
}
|
|
|
|
// Call implements the call functionality
|
|
func (ast *Assistant) Call(c *gin.Context, payload APIPayload) (interface{}, error) {
|
|
scriptCtx, err := ast.Script.NewContext(payload.Sid, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer scriptCtx.Close()
|
|
ctx := c.Request.Context()
|
|
|
|
method := fmt.Sprintf("%sAPI", payload.Name)
|
|
|
|
// Check if the method exists
|
|
if !scriptCtx.Global().Has(method) {
|
|
color.Red("Assistant Call: %s Method %s not found", ast.ID, method)
|
|
return nil, fmt.Errorf(HookErrorMethodNotFound)
|
|
}
|
|
|
|
if payload.Args == nil || len(payload.Args) == 0 {
|
|
return scriptCtx.CallWith(ctx, method)
|
|
}
|
|
|
|
return scriptCtx.CallWith(ctx, method, payload.Args...)
|
|
}
|
|
|
|
// 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{}) (interface{}, error) {
|
|
clientBreak := make(chan bool, 1)
|
|
done := make(chan bool, 1)
|
|
var result interface{} = nil
|
|
var err error = nil
|
|
|
|
requestCtx := c.Request.Context()
|
|
go func() {
|
|
var res interface{} = nil
|
|
res, err = ast.streamChat(c, ctx, messages, options, clientBreak, contents, callback...)
|
|
result = res
|
|
done <- true
|
|
}()
|
|
|
|
// Wait for completion or client disconnect
|
|
select {
|
|
case <-done:
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
|
|
case <-requestCtx.Done():
|
|
clientBreak <- true
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
// streamChat handles the streaming chat interaction
|
|
func (ast *Assistant) streamChat(
|
|
c *gin.Context,
|
|
ctx chatctx.Context,
|
|
messages []chatMessage.Message,
|
|
options map[string]interface{},
|
|
clientBreak chan bool,
|
|
contents *chatMessage.Contents,
|
|
callback ...interface{},
|
|
) (interface{}, error) {
|
|
|
|
var cb interface{}
|
|
if len(callback) > 0 {
|
|
cb = callback[0]
|
|
}
|
|
|
|
errorRaw := ""
|
|
isFirst := true
|
|
isFirstThink := true
|
|
isThinking := false
|
|
|
|
toolsCount := 0
|
|
currentMessageID := ""
|
|
tokenID := ""
|
|
beganAt := int64(0)
|
|
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
|
|
|
|
default:
|
|
msg := chatMessage.NewOpenAI(data, isThinking)
|
|
if msg == nil {
|
|
return 1 // continue
|
|
}
|
|
|
|
if msg.Pending {
|
|
errorRaw += msg.Text
|
|
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()
|
|
res, hookErr := ast.HookFail(c, ctx, messages, fmt.Errorf("%s", value), contents)
|
|
if hookErr == nil && res != nil && (res.Output != "" || res.Error != "") {
|
|
value = res.Output
|
|
if res.Error != "" {
|
|
value = res.Error
|
|
}
|
|
}
|
|
newMsg := chatMessage.New().Error(value).Done()
|
|
newMsg.Retry = ctx.Retry
|
|
newMsg.Silent = ctx.Silent
|
|
newMsg.Callback(cb).Write(c.Writer)
|
|
return 0 // break
|
|
}
|
|
|
|
// for api reasoning_content response
|
|
if msg.Type == "think" {
|
|
if isFirstThink {
|
|
msg.Begin = time.Now().UnixNano()
|
|
msg.Text = "<think>\n" + msg.Text // add the think begin tag
|
|
isFirstThink = false
|
|
isThinking = true
|
|
}
|
|
}
|
|
|
|
// for api reasoning_content response
|
|
if isThinking && msg.Type != "think" {
|
|
// add the think close tag
|
|
end := chatMessage.New().Map(map[string]interface{}{"text": "\n</think>\n", "type": "think", "delta": true})
|
|
end.ID = currentMessageID
|
|
end.Retry = ctx.Retry
|
|
end.Silent = ctx.Silent
|
|
end.End = time.Now().UnixNano()
|
|
end.Begin = beganAt
|
|
end.ToolID = tokenID
|
|
|
|
end.Callback(cb).Write(c.Writer)
|
|
end.AppendTo(contents)
|
|
contents.UpdateType("think", map[string]interface{}{"text": contents.Text()}, chatMessage.Extra{ID: currentMessageID, End: time.Now().UnixNano()})
|
|
isThinking = false
|
|
|
|
// Clear the token and make a new line
|
|
contents.NewText([]byte{}, chatMessage.Extra{ID: currentMessageID})
|
|
|
|
// Clear the token
|
|
contents.ClearToken(tokenID)
|
|
beganAt = 0
|
|
tokenID = ""
|
|
}
|
|
|
|
// for native tool_calls response, keep the first tool_calls_native message
|
|
if msg.Type == "tool_calls_native" {
|
|
|
|
if toolsCount > 1 {
|
|
msg.Text = "" // clear the text
|
|
msg.Type = "text"
|
|
msg.IsNew = false
|
|
return 1 // continue
|
|
}
|
|
|
|
if msg.IsBeginTool {
|
|
|
|
if toolsCount == 1 {
|
|
msg.IsNew = false
|
|
msg.Text = "\n</tool>\n" // add the tool_calls close tag
|
|
}
|
|
|
|
if toolsCount == 0 {
|
|
msg.Text = "\n<tool>\n" + msg.Text // add the tool_calls begin tag
|
|
}
|
|
|
|
toolsCount++
|
|
msg.Begin = time.Now().UnixNano()
|
|
}
|
|
|
|
if msg.IsEndTool {
|
|
msg.Text = msg.Text + "\n</tool>\n" // add the tool_calls close tag
|
|
msg.End = time.Now().UnixNano()
|
|
}
|
|
}
|
|
|
|
delta := msg.String()
|
|
|
|
// Chunk the delta
|
|
if delta != "" {
|
|
|
|
msg.AppendTo(contents) // Append content
|
|
|
|
// Scan the tokens
|
|
contents.ScanTokens(currentMessageID, tokenID, beganAt, func(params message.ScanCallbackParams) {
|
|
currentMessageID = params.MessageID
|
|
msg.ID = params.MessageID
|
|
msg.Type = params.Token
|
|
msg.Text = "" // clear the text
|
|
msg.Props = map[string]interface{}{"text": params.Text, "id": params.TokenID} // Update props
|
|
msg.Begin = params.BeganAt
|
|
msg.End = params.EndAt
|
|
msg.ToolID = params.TokenID
|
|
|
|
// End of the token clear the text
|
|
if params.Begin {
|
|
tokenID = params.TokenID
|
|
beganAt = params.BeganAt
|
|
return
|
|
}
|
|
|
|
if params.End {
|
|
tokenID = ""
|
|
beganAt = 0
|
|
return
|
|
}
|
|
|
|
// New message with the tails
|
|
if params.Tails != "" {
|
|
newMsg, err := chatMessage.NewString(params.Tails, params.MessageID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
messages = append(messages, *newMsg)
|
|
}
|
|
})
|
|
|
|
// Write the message to the stream
|
|
msgType := msg.Type
|
|
if msgType == "tool_calls_native" {
|
|
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,
|
|
"done": msg.IsDone,
|
|
"delta": true,
|
|
})
|
|
|
|
output.Retry = ctx.Retry // Retry mode
|
|
output.Silent = ctx.Silent // Silent mode
|
|
if isFirst {
|
|
output.Assistant(ast.ID, ast.GetName(ctx.Locale), ast.Avatar)
|
|
isFirst = false
|
|
}
|
|
|
|
if msg.Type == "think" || msg.Type == "tool" {
|
|
output.Begin = msg.Begin
|
|
output.End = msg.End
|
|
output.ToolID = msg.ToolID
|
|
}
|
|
|
|
output.Callback(cb).Write(c.Writer)
|
|
}
|
|
|
|
// Complete the stream
|
|
if msg.IsDone {
|
|
|
|
// Send the last message to the client
|
|
if delta != "" {
|
|
chatMessage.New().
|
|
Map(map[string]interface{}{
|
|
"assistant_id": ast.ID,
|
|
"assistant_name": ast.GetName(ctx.Locale),
|
|
"assistant_avatar": ast.Avatar,
|
|
"text": delta,
|
|
"type": "text",
|
|
"delta": true,
|
|
"done": true,
|
|
"retry": ctx.Retry,
|
|
"silent": ctx.Silent,
|
|
}).
|
|
Callback(cb).
|
|
Write(c.Writer)
|
|
}
|
|
|
|
// Remove the last empty data
|
|
contents.RemoveLastEmpty()
|
|
res, hookErr := ast.HookDone(c, ctx, messages, contents)
|
|
|
|
// Some error occurred in the hook, return the error
|
|
if hookErr != nil {
|
|
retry = hookErr
|
|
return 0 // break
|
|
}
|
|
|
|
// Save the chat history
|
|
ast.saveChatHistory(ctx, messages, contents)
|
|
|
|
// If the hook is successful, execute the next action
|
|
if res != nil && res.Next != nil {
|
|
_, err := res.Next.Execute(c, ctx, contents, cb)
|
|
if err != nil {
|
|
chatMessage.New().Error(err.Error()).Done().Callback(cb).Write(c.Writer)
|
|
}
|
|
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 {
|
|
output = chatMessage.New().Map(map[string]interface{}{"text": res.Output, "done": true})
|
|
output.Retry = ctx.Retry
|
|
output.Silent = ctx.Silent
|
|
}
|
|
|
|
// has result
|
|
if res != nil && res.Result != nil {
|
|
output.SetResult(res.Result)
|
|
if cb != nil {
|
|
output.Callback(cb).Write(c.Writer)
|
|
return 0 // break
|
|
}
|
|
}
|
|
|
|
// Send the result to the client
|
|
output.Write(c.Writer)
|
|
return 0 // break
|
|
}
|
|
|
|
return 1 // continue
|
|
}
|
|
})
|
|
|
|
// retry
|
|
if retry != nil {
|
|
|
|
// Update the retry times
|
|
ctx.RetryTimes = ctx.RetryTimes + 1 // Increment the retry times
|
|
ctx.Retry = true // Set the retry mode
|
|
|
|
// The maximum retry times is 9
|
|
if ctx.RetryTimes > 9 {
|
|
color.Red("Maximum retry times is 9, please check the error and fix it")
|
|
// chatMessage.New().Error(retry.Error()).Done().Callback(cb).Write(c.Writer)
|
|
return nil, retry
|
|
}
|
|
|
|
// Hook 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 promptAny == nil {
|
|
return nil, retry
|
|
}
|
|
|
|
// Default prompt
|
|
var prompt string = fmt.Sprintf("Try to fix the error following the error message. error:\n %s", exception.Trim(retry))
|
|
switch v := promptAny.(type) {
|
|
case bool: // Ignore the error, and return the specific result
|
|
if v == false {
|
|
return nil, retry
|
|
}
|
|
|
|
case map[string]interface{}: // Ignore the error, and return the specific result
|
|
return v, nil
|
|
|
|
case NextAction: // Execute the next action
|
|
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: // Add the prompt to the messages
|
|
prompt = v
|
|
}
|
|
|
|
// 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
|
|
retryContents := chatMessage.NewContents()
|
|
return ast.execute(c, ctx, retryMessages, options, retryContents, cb)
|
|
}
|
|
|
|
// Handle error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// raw error
|
|
if errorRaw != "" {
|
|
msg, err := chatMessage.NewStringError(errorRaw)
|
|
if err != nil {
|
|
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)
|
|
}
|
|
|
|
// If the result is not nil, return the result
|
|
if result != nil {
|
|
return result, nil
|
|
}
|
|
|
|
// Return the content
|
|
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 = -1
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
if messages[i].Role == "user" {
|
|
messages[i].Text = prompt
|
|
lastIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if lastIndex == -1 {
|
|
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 {
|
|
userMessage := messages[len(messages)-1]
|
|
data := []map[string]interface{}{
|
|
{
|
|
"role": "user",
|
|
"content": userMessage.Content(),
|
|
"name": ctx.Sid,
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": contents.JSON(),
|
|
"name": ast.ID,
|
|
"assistant_id": ast.ID,
|
|
"assistant_name": ast.GetName(ctx.Locale),
|
|
"assistant_avatar": ast.Avatar,
|
|
},
|
|
}
|
|
|
|
// if the user message is hidden, just save the assistant message
|
|
if userMessage.Hidden {
|
|
data = []map[string]interface{}{data[1]}
|
|
}
|
|
|
|
storage.SaveHistory(ctx.Sid, data, ctx.ChatID, ctx.Map())
|
|
}
|
|
}
|
|
|
|
func (ast *Assistant) withOptions(options map[string]interface{}) map[string]interface{} {
|
|
if options == nil {
|
|
options = map[string]interface{}{}
|
|
}
|
|
|
|
// Add Custom Options
|
|
if ast.Options != nil {
|
|
for key, value := range ast.Options {
|
|
options[key] = value
|
|
}
|
|
}
|
|
|
|
// Add tool_calls
|
|
if ast.Tools != nil && ast.Tools.Tools != nil && len(ast.Tools.Tools) > 0 {
|
|
if settings, has := connectorSettings[ast.Connector]; has && settings.Tools {
|
|
options["tools"] = ast.Tools.Tools
|
|
if options["tool_choice"] == nil {
|
|
options["tool_choice"] = "auto"
|
|
}
|
|
}
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
func (ast *Assistant) withPrompts(messages []chatMessage.Message) []chatMessage.Message {
|
|
if ast.Prompts != nil {
|
|
for _, prompt := range ast.Prompts {
|
|
name := strings.ReplaceAll(ast.ID, ".", "_") // OpenAI only supports underscore in the name
|
|
if prompt.Name != "" {
|
|
name = prompt.Name
|
|
}
|
|
messages = append(messages, *chatMessage.New().Map(map[string]interface{}{"role": prompt.Role, "content": prompt.Content, "name": name}))
|
|
}
|
|
}
|
|
|
|
// Add tool_calls
|
|
if ast.Tools != nil && ast.Tools.Tools != nil && len(ast.Tools.Tools) > 0 {
|
|
settings, has := connectorSettings[ast.Connector]
|
|
if !has || !settings.Tools {
|
|
raw, _ := jsoniter.MarshalToString(ast.Tools.Tools)
|
|
|
|
examples := []string{}
|
|
for _, tool := range ast.Tools.Tools {
|
|
example := tool.Example()
|
|
examples = append(examples, example)
|
|
}
|
|
|
|
examplesStr := ""
|
|
if len(examples) > 0 {
|
|
examplesStr = "Examples:\n" + strings.Join(examples, "\n\n")
|
|
}
|
|
|
|
prompts := []map[string]interface{}{
|
|
{
|
|
"role": "system",
|
|
"name": "TOOL_CALLS_SCHEMA",
|
|
"content": raw,
|
|
},
|
|
{
|
|
"role": "system",
|
|
"name": "TOOL_CALLS_SCHEMA",
|
|
"content": "## Tool Calls Schema Definition\n" +
|
|
"Each tool call is defined with:\n" +
|
|
" - type: always 'function'\n" +
|
|
" - function:\n" +
|
|
" - name: function name\n" +
|
|
" - description: function description\n" +
|
|
" - parameters: function parameters with type and validation rules\n",
|
|
},
|
|
{
|
|
"role": "system",
|
|
"name": "TOOL_CALLS",
|
|
"content": "## Tool Response Format\n" +
|
|
"1. Only use tool calls when a function matches your task exactly\n" +
|
|
"2. Each tool call must be wrapped in <tool> and </tool> tags\n" +
|
|
"3. Tool call must be a valid JSON with:\n" +
|
|
" {\"function\": \"function_name\", \"arguments\": {parameters}}\n" +
|
|
"4. Return the function's result as your response\n" +
|
|
"5. One tool call per response\n" +
|
|
"6. Arguments must match parameter types, rules and description\n\n" +
|
|
examplesStr,
|
|
},
|
|
{
|
|
"role": "system",
|
|
"name": "TOOL_CALLS",
|
|
"content": "## Tool Usage Guidelines\n" +
|
|
"1. Use functions defined in TOOL_CALLS_SCHEMA only when they match your needs\n" +
|
|
"2. If no matching function exists, respond normally as a helpful assistant\n" +
|
|
"3. When using tools, arguments must match the schema definition exactly\n" +
|
|
"4. All parameter values must strictly adhere to the validation rules specified in properties\n" +
|
|
"5. Never skip or ignore any validation requirements defined in the schema",
|
|
},
|
|
}
|
|
|
|
// Add tool_calls developer prompts
|
|
if ast.Tools.Prompts != nil && len(ast.Tools.Prompts) > 0 {
|
|
for _, prompt := range ast.Tools.Prompts {
|
|
messages = append(messages, *chatMessage.New().Map(map[string]interface{}{
|
|
"role": prompt.Role,
|
|
"content": prompt.Content,
|
|
"name": prompt.Name,
|
|
}))
|
|
}
|
|
}
|
|
|
|
// Add the prompts
|
|
for _, prompt := range prompts {
|
|
messages = append(messages, *chatMessage.New().Map(prompt))
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
func (ast *Assistant) withHistory(ctx chatctx.Context, input interface{}) ([]chatMessage.Message, error) {
|
|
|
|
var userMessage *chatMessage.Message
|
|
var inputMessages []*chatMessage.Message
|
|
switch v := input.(type) {
|
|
case string:
|
|
userMessage = chatMessage.New().Map(map[string]interface{}{"role": "user", "content": v})
|
|
|
|
case map[string]interface{}:
|
|
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:
|
|
userMessage = v
|
|
default:
|
|
return nil, fmt.Errorf("unknown input type: %T", input)
|
|
}
|
|
|
|
messages := []chatMessage.Message{}
|
|
if storage != nil {
|
|
history, err := storage.GetHistory(ctx.Sid, ctx.ChatID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Add history messages
|
|
for _, h := range history {
|
|
msgs, err := chatMessage.NewHistory(h)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
messages = append(messages, msgs...)
|
|
}
|
|
}
|
|
|
|
// Add system prompts
|
|
messages = ast.withPrompts(messages)
|
|
|
|
// Add user message
|
|
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
|
|
}
|
|
|
|
// Chat implements the chat functionality
|
|
func (ast *Assistant) Chat(ctx context.Context, messages []chatMessage.Message, option map[string]interface{}, cb func(data []byte) int) error {
|
|
if ast.openai == nil {
|
|
return fmt.Errorf("openai is not initialized")
|
|
}
|
|
|
|
requestMessages, err := ast.requestMessages(ctx, messages)
|
|
if err != nil {
|
|
return fmt.Errorf("request messages error: %s", err.Error())
|
|
}
|
|
|
|
_, ext := ast.openai.ChatCompletionsWith(ctx, requestMessages, option, cb)
|
|
if ext != nil {
|
|
return fmt.Errorf("openai chat completions with error: %s", ext.Message)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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{}{
|
|
{
|
|
"role": "system",
|
|
"name": "SYSTEM_TIME",
|
|
"content": "System Time: " + time.Now().Format(time.RFC3339) + "\n\n" + "It's the system time, please use it for reference.",
|
|
},
|
|
}
|
|
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
|
|
}
|
|
|
|
// Development log for DUI platform
|
|
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
|
|
}
|
|
|
|
role := message.Role
|
|
if role == "" {
|
|
if os.Getenv("YAO_AGENT_PRINT_REQUEST_MESSAGES") == "true" {
|
|
raw, _ := jsoniter.MarshalToString(message)
|
|
color.Red("Request Message Error, role is empty:")
|
|
fmt.Println(raw)
|
|
}
|
|
return nil, fmt.Errorf("role must be string")
|
|
}
|
|
|
|
content := message.String()
|
|
if content == "" {
|
|
// fmt.Println("--------------------------------")
|
|
// fmt.Println("Request Message Error")
|
|
// utils.Dump(message)
|
|
// fmt.Println("--------------------------------")
|
|
// return nil, fmt.Errorf("content must be string")
|
|
continue
|
|
}
|
|
|
|
newMessage := map[string]interface{}{
|
|
"role": role,
|
|
"content": content,
|
|
}
|
|
|
|
// Keep the name for user messages
|
|
if name := message.Name; name != "" {
|
|
if role != "system" {
|
|
newMessage["name"] = stringHash(name)
|
|
} else {
|
|
newMessage["name"] = name
|
|
}
|
|
}
|
|
|
|
// Special handling for user messages with JSON content last message
|
|
if role == "user" && index == length-1 {
|
|
content = strings.TrimSpace(content)
|
|
msg, err := chatMessage.NewString(content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new string error: %s", err.Error())
|
|
}
|
|
|
|
newMessage["content"] = msg.Text
|
|
if message.Attachments != nil {
|
|
contents, err := ast.withAttachments(ctx, &message)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("with attachments error: %s", err.Error())
|
|
}
|
|
|
|
// if current assistant is vision capable, add the contents directly
|
|
if ast.vision {
|
|
newMessage["content"] = contents
|
|
continue
|
|
}
|
|
|
|
// If current assistant is not vision capable, add the description of the image
|
|
if contents != nil {
|
|
for _, content := range contents {
|
|
newMessages = append(newMessages, content)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 processedMessages {
|
|
raw, _ := jsoniter.MarshalToString(message)
|
|
log.Trace("[Request Message] %s", raw)
|
|
}
|
|
}
|
|
|
|
return processedMessages, nil
|
|
}
|
|
|
|
func (ast *Assistant) withAttachments(ctx context.Context, msg *chatMessage.Message) ([]map[string]interface{}, error) {
|
|
contents := []map[string]interface{}{{"type": "text", "text": msg.Text}}
|
|
if !ast.vision {
|
|
contents = []map[string]interface{}{{"role": "user", "content": msg.Text}}
|
|
}
|
|
|
|
images := []string{}
|
|
for _, attachment := range msg.Attachments {
|
|
if strings.HasPrefix(attachment.ContentType, "image/") {
|
|
if ast.vision {
|
|
images = append(images, attachment.URL)
|
|
continue
|
|
}
|
|
|
|
// If the current assistant is not vision capable, add the description of the image
|
|
raw, err := jsoniter.MarshalToString(attachment)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal attachment error: %s", err.Error())
|
|
}
|
|
contents = append(contents, map[string]interface{}{
|
|
"role": "system",
|
|
"content": raw,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(images) == 0 {
|
|
return contents, nil
|
|
}
|
|
|
|
// If the current assistant is vision capable, add the image to the contents directly
|
|
if ast.vision {
|
|
for _, url := range images {
|
|
|
|
// If the image is already a URL, add it directly
|
|
if strings.HasPrefix(url, "http") {
|
|
contents = append(contents, map[string]interface{}{
|
|
"type": "image_url",
|
|
"image_url": map[string]string{
|
|
"url": url,
|
|
},
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Read base64
|
|
bytes64, err := ast.ReadBase64(ctx, url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read base64 error: %s", err.Error())
|
|
}
|
|
contents = append(contents, map[string]interface{}{
|
|
"type": "image_url",
|
|
"image_url": map[string]string{
|
|
"url": fmt.Sprintf("data:image/jpeg;base64,%s", bytes64),
|
|
},
|
|
})
|
|
}
|
|
return contents, nil
|
|
}
|
|
|
|
// If the current assistant is not vision capable, add the description of the image
|
|
|
|
return contents, nil
|
|
}
|
|
|
|
// ReadBase64 implements base64 file reading functionality
|
|
func (ast *Assistant) ReadBase64(ctx context.Context, fileID string) (string, error) {
|
|
data, err := fs.Get("data")
|
|
if err != nil {
|
|
return "", fmt.Errorf("get filesystem error: %s", err.Error())
|
|
}
|
|
|
|
exists, err := data.Exists(fileID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("check file error: %s", err.Error())
|
|
}
|
|
if !exists {
|
|
return "", fmt.Errorf("file %s not found", fileID)
|
|
}
|
|
|
|
content, err := data.ReadFile(fileID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read file error: %s", err.Error())
|
|
}
|
|
|
|
return base64.StdEncoding.EncodeToString(content), nil
|
|
}
|