diff --git a/neo/assistant/api.go b/neo/assistant/api.go index 6c4188f6..b6b684ef 100644 --- a/neo/assistant/api.go +++ b/neo/assistant/api.go @@ -283,6 +283,8 @@ func (ast *Assistant) streamChat( errorRaw := "" isFirst := true + isFirstThink := true + isThinking := false currentMessageID := "" err := ast.Chat(c.Request.Context(), messages, options, func(data []byte) int { select { @@ -290,7 +292,7 @@ func (ast *Assistant) streamChat( return 0 // break default: - msg := chatMessage.NewOpenAI(data) + msg := chatMessage.NewOpenAI(data, isThinking) if msg == nil { return 1 // continue } @@ -314,6 +316,29 @@ func (ast *Assistant) streamChat( return 0 // break } + // for api reasoning_content response + if msg.Type == "think" { + if isFirstThink { + msg.Text = "\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\n", "type": "think", "delta": true}) + end.Write(c.Writer) + end.ID = currentMessageID + end.AppendTo(contents) + isThinking = false + + // Clear the token and make a new line + contents.NewText([]byte{}, currentMessageID) + contents.ClearToken() + } + delta := msg.String() // Chunk the delta diff --git a/neo/message/contents.go b/neo/message/contents.go index 6aaa00fa..f8b8c97a 100644 --- a/neo/message/contents.go +++ b/neo/message/contents.go @@ -66,7 +66,7 @@ func (c *Contents) ScanTokens(currentID string, cb func(token string, id string, c.UpdateType(c.token, map[string]interface{}{"text": text}, c.id) c.NewText([]byte(tails), c.id) // Create new text with the tails cb(c.token, c.id, false, text, tails) - c.token = "" // clear the token + c.ClearToken() // clear the token return } @@ -88,6 +88,11 @@ func (c *Contents) ScanTokens(currentID string, cb func(token string, id string, } } +// ClearToken clear the token +func (c *Contents) ClearToken() { + c.token = "" +} + // RemoveLastEmpty remove the last empty data func (c *Contents) RemoveLastEmpty() { if c.Current == -1 { diff --git a/neo/message/message.go b/neo/message/message.go index f6b4e169..9f5e96e3 100644 --- a/neo/message/message.go +++ b/neo/message/message.go @@ -183,7 +183,7 @@ func NewAny(content interface{}) (*Message, error) { } // NewOpenAI create a new message from OpenAI response -func NewOpenAI(data []byte) *Message { +func NewOpenAI(data []byte, isThinking bool) *Message { if data == nil || len(data) == 0 { return nil } @@ -212,7 +212,7 @@ func NewOpenAI(data []byte) *Message { } case strings.Contains(text, `"delta":{`) && strings.Contains(text, `"content":`): - var message openai.Message + var message openai.MessageWithReasoningContent if err := jsoniter.Unmarshal(data, &message); err != nil { color.Red("JSON parse error: %s", err.Error()) color.White(string(data)) @@ -224,7 +224,26 @@ func NewOpenAI(data []byte) *Message { msg.Type = "text" if len(message.Choices) > 0 { - msg.Text = message.Choices[0].Delta.Content + if reasoningContent, ok := message.Choices[0].Delta["reasoning_content"].(string); ok { + msg.Text = reasoningContent + msg.Type = "think" + return msg + } + + if content, ok := message.Choices[0].Delta["content"].(string); ok && content != "" { + msg.Text = content + msg.Type = "text" + return msg + } + + if isThinking { + msg.Type = "think" + msg.Text = "" + return msg + } + + msg.Text = "" + return msg } case strings.Index(text, `{"code":`) == 0: diff --git a/neo/neo.go b/neo/neo.go index a13a85ea..76d94bce 100644 --- a/neo/neo.go +++ b/neo/neo.go @@ -124,13 +124,16 @@ func (neo *DSL) GenerateWithAI(ctx chatctx.Context, input string, messageType st } errorRaw := "" + isFirstThink := true + isThinking := false + currentMessageID := "" err := ast.Chat(c.Request.Context(), msgList, neo.Option, func(data []byte) int { select { case <-clientBreak: return 0 // break default: - msg := message.NewOpenAI(data) + msg := message.NewOpenAI(data, isThinking) if msg == nil { return 1 // continue } @@ -146,15 +149,61 @@ func (neo *DSL) GenerateWithAI(ctx chatctx.Context, input string, messageType st return 0 // break } + // for api reasoning_content response + if msg.Type == "think" { + if isFirstThink { + msg.Text = "\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 := message.New().Map(map[string]interface{}{"text": "\n\n", "type": "think", "delta": true}) + end.Write(c.Writer) + end.ID = currentMessageID + end.AppendTo(contents) + isThinking = false + + // Clear the token and make a new line + contents.NewText([]byte{}, currentMessageID) + contents.ClearToken() + } + // Append content and send message msg.AppendTo(contents) + + // Scan the tokens + contents.ScanTokens(currentMessageID, func(token string, id string, begin bool, text string, tails string) { + currentMessageID = id + msg.ID = id + msg.Type = token + msg.Text = "" // clear the text + msg.Props = map[string]interface{}{"text": text} // Update props + + // End of the token clear the text + if begin { + return + } + + // New message with the tails + newMsg, err := message.NewString(tails, id) + if err != nil { + return + } + msgList = append(msgList, *newMsg) + }) + if !silent { value := msg.String() if value != "" { message.New(). Map(map[string]interface{}{ - "text": value, - "done": msg.IsDone, + "text": value, + "delta": true, + "done": msg.IsDone, }). Write(c.Writer) } diff --git a/openai/openai.go b/openai/openai.go index 2ed4eb25..528326d6 100644 --- a/openai/openai.go +++ b/openai/openai.go @@ -28,6 +28,7 @@ type OpenAI struct { key string model string host string + baseURL string organization string maxToken int } @@ -71,8 +72,16 @@ func NewOpenAI(setting map[string]interface{}) (*OpenAI, error) { } host := "https://api.openai.com" + baseURL := "/v1" if v, ok := setting["host"].(string); ok { + // Trim trailing slashes + v = strings.TrimRight(v, "/") host = v + parts := strings.Split(v, "/") + if len(parts) > 3 { + host = strings.Join(parts[0:3], "/") + baseURL = "/" + strings.Join(parts[3:], "/") + } } organization := "" @@ -89,6 +98,7 @@ func NewOpenAI(setting map[string]interface{}) (*OpenAI, error) { key: key, model: model, host: host, + baseURL: baseURL, organization: organization, maxToken: maxToken, }, nil @@ -142,11 +152,11 @@ func (openai OpenAI) Completions(prompt interface{}, option map[string]interface if cb != nil { option["stream"] = true - return nil, openai.stream(context.Background(), "/v1/completions", option, cb) + return nil, openai.stream(context.Background(), openai.baseURL+"/completions", option, cb) } option["stream"] = false - return openai.post("/v1/completions", option) + return openai.post(openai.baseURL+"/completions", option) } // CompletionsWith Creates a completion for the provided prompt and parameters. @@ -159,11 +169,11 @@ func (openai OpenAI) CompletionsWith(ctx context.Context, prompt interface{}, op if cb != nil { option["stream"] = true - return nil, openai.stream(ctx, "/v1/completions", option, cb) + return nil, openai.stream(ctx, openai.baseURL+"/completions", option, cb) } option["stream"] = false - return openai.post("/v1/completions", option) + return openai.post(openai.baseURL+"/completions", option) } // ChatCompletions Creates a model response for the given chat conversation. @@ -176,11 +186,11 @@ func (openai OpenAI) ChatCompletions(messages []map[string]interface{}, option m if cb != nil { option["stream"] = true - return nil, openai.stream(context.Background(), "/v1/chat/completions", option, cb) + return nil, openai.stream(context.Background(), openai.baseURL+"/chat/completions", option, cb) } option["stream"] = false - return openai.post("/v1/chat/completions", option) + return openai.post(openai.baseURL+"/chat/completions", option) } // ChatCompletionsWith Creates a model response for the given chat conversation. @@ -193,11 +203,11 @@ func (openai OpenAI) ChatCompletionsWith(ctx context.Context, messages []map[str if cb != nil { option["stream"] = true - return nil, openai.stream(ctx, "/v1/chat/completions", option, cb) + return nil, openai.stream(ctx, openai.baseURL+"/chat/completions", option, cb) } option["stream"] = false - return openai.post("/v1/chat/completions", option) + return openai.post(openai.baseURL+"/chat/completions", option) } // Edits Creates a new edit for the provided input, instruction, and parameters. @@ -207,7 +217,7 @@ func (openai OpenAI) Edits(instruction string, option map[string]interface{}) (i option = map[string]interface{}{} } option["instruction"] = instruction - return openai.post("/v1/edits", option) + return openai.post(openai.baseURL+"/edits", option) } // Embeddings Creates an embedding vector representing the input text. @@ -217,7 +227,7 @@ func (openai OpenAI) Embeddings(input interface{}, user string) (interface{}, *e if user != "" { payload["user"] = user } - return openai.post("/v1/embeddings", payload) + return openai.post(openai.baseURL+"/embeddings", payload) } // AudioTranscriptions Transcribes audio into the input language. @@ -231,7 +241,7 @@ func (openai OpenAI) AudioTranscriptions(dataBase64 string, option map[string]in if option == nil { option = map[string]interface{}{} } - return openai.postFile("/v1/audio/transcriptions", map[string][]byte{"file": data}, option) + return openai.postFile(openai.baseURL+"/audio/transcriptions", map[string][]byte{"file": data}, option) } // ImagesGenerations Creates an image given a prompt. @@ -246,7 +256,7 @@ func (openai OpenAI) ImagesGenerations(prompt string, option map[string]interfac } option["prompt"] = prompt - return openai.postWithoutModel("/v1/images/generations", option) + return openai.postWithoutModel(openai.baseURL+"/images/generations", option) } // ImagesEdits Creates an edited or extended image given an original image and a prompt. @@ -277,7 +287,7 @@ func (openai OpenAI) ImagesEdits(imageBase64 string, prompt string, option map[s } option["prompt"] = prompt - return openai.postFileWithoutModel("/v1/images/edits", files, option) + return openai.postFileWithoutModel(openai.baseURL+"/images/edits", files, option) } // ImagesVariations Creates a variation of a given image. @@ -298,7 +308,7 @@ func (openai OpenAI) ImagesVariations(imageBase64 string, option map[string]inte option["response_format"] = "b64_json" } - return openai.postFileWithoutModel("/v1/images/variations", files, option) + return openai.postFileWithoutModel(openai.baseURL+"/images/variations", files, option) } // Tiktoken get number of tokens diff --git a/openai/types.go b/openai/types.go index 0c6d58b4..82433f68 100644 --- a/openai/types.go +++ b/openai/types.go @@ -16,6 +16,19 @@ type Message struct { } `json:"choices,omitempty"` } +// MessageWithReasoningContent is the response from OpenAI +type MessageWithReasoningContent struct { + ID string `json:"id,omitempty"` + Object string `json:"object,omitempty"` + Created int64 `json:"created,omitempty"` + Model string `json:"model,omitempty"` + Choices []struct { + Delta map[string]interface{} `json:"delta,omitempty"` + Index int `json:"index,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + } `json:"choices,omitempty"` +} + // ToolCalls is the response from OpenAI type ToolCalls struct { ID string `json:"id,omitempty"`