991bd29a14
- Added functionality for image uploads in chat, allowing users to attach images for multimodal Q&A. - Enhanced the input field to handle image selection via drag-and-drop and paste, with validation for file types and sizes. - Updated the backend to process images alongside text queries, including support for image analysis. - Introduced new UI components for image previews and management, improving user interaction with uploaded content. - Added localization strings for image upload features and error messages, enhancing accessibility for users in multiple languages. These changes significantly improve the chat experience by enabling users to incorporate images into their queries, facilitating richer interactions and responses.
720 lines
21 KiB
Go
720 lines
21 KiB
Go
package chat
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/models/provider"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|
"github.com/sashabaranov/go-openai"
|
|
)
|
|
|
|
// RemoteAPIChat 实现了基于 OpenAI 兼容 API 的聊天
|
|
// 这是一个通用实现,不包含任何 provider 特定的逻辑
|
|
type RemoteAPIChat struct {
|
|
modelName string
|
|
client *openai.Client
|
|
modelID string
|
|
baseURL string
|
|
apiKey string
|
|
provider provider.ProviderName
|
|
|
|
// requestCustomizer 允许子类自定义请求
|
|
// 返回自定义请求体(如果为 nil 则使用标准请求)和是否需要使用原始 HTTP 请求
|
|
requestCustomizer func(req *openai.ChatCompletionRequest, opts *ChatOptions, isStream bool) (customReq any, useRawHTTP bool)
|
|
|
|
// endpointCustomizer 允许子类自定义请求的 endpoint
|
|
// 返回是否使用自定义请求地址, 返回空则使用默认OpenAI格式地址
|
|
endpointCustomizer func(baseURL string, modelID string, isStream bool) (endpoint string)
|
|
}
|
|
|
|
// NewRemoteAPIChat 创建远程 API 聊天实例
|
|
func NewRemoteAPIChat(chatConfig *ChatConfig) (*RemoteAPIChat, error) {
|
|
apiKey := chatConfig.APIKey
|
|
config := openai.DefaultConfig(apiKey)
|
|
if baseURL := chatConfig.BaseURL; baseURL != "" {
|
|
config.BaseURL = baseURL
|
|
}
|
|
|
|
providerName := provider.ProviderName(chatConfig.Provider)
|
|
if providerName == "" {
|
|
providerName = provider.DetectProvider(chatConfig.BaseURL)
|
|
}
|
|
|
|
return &RemoteAPIChat{
|
|
modelName: chatConfig.ModelName,
|
|
client: openai.NewClientWithConfig(config),
|
|
modelID: chatConfig.ModelID,
|
|
baseURL: chatConfig.BaseURL,
|
|
apiKey: apiKey,
|
|
provider: providerName,
|
|
}, nil
|
|
}
|
|
|
|
// SetRequestCustomizer 设置请求自定义器
|
|
func (c *RemoteAPIChat) SetRequestCustomizer(customizer func(req *openai.ChatCompletionRequest, opts *ChatOptions, isStream bool) (any, bool)) {
|
|
c.requestCustomizer = customizer
|
|
}
|
|
|
|
// SetEndpointCustomizer 设置请求地址自定义器
|
|
func (c *RemoteAPIChat) SetEndpointCustomizer(customizer func(baseURL string, modelID string, isStream bool) string) {
|
|
c.endpointCustomizer = customizer
|
|
}
|
|
|
|
// ConvertMessages 转换消息格式为 OpenAI 格式(导出供子类使用)
|
|
func (c *RemoteAPIChat) ConvertMessages(messages []Message) []openai.ChatCompletionMessage {
|
|
openaiMessages := make([]openai.ChatCompletionMessage, 0, len(messages))
|
|
for _, msg := range messages {
|
|
openaiMsg := openai.ChatCompletionMessage{
|
|
Role: msg.Role,
|
|
}
|
|
|
|
if len(msg.Images) > 0 && msg.Role == "user" {
|
|
parts := []openai.ChatMessagePart{
|
|
{Type: openai.ChatMessagePartTypeText, Text: msg.Content},
|
|
}
|
|
for _, imgURL := range msg.Images {
|
|
resolved := resolveImageURLForLLM(imgURL)
|
|
parts = append(parts, openai.ChatMessagePart{
|
|
Type: openai.ChatMessagePartTypeImageURL,
|
|
ImageURL: &openai.ChatMessageImageURL{
|
|
URL: resolved,
|
|
Detail: openai.ImageURLDetailAuto,
|
|
},
|
|
})
|
|
}
|
|
openaiMsg.MultiContent = parts
|
|
} else if msg.Content != "" {
|
|
openaiMsg.Content = msg.Content
|
|
}
|
|
|
|
if len(msg.ToolCalls) > 0 {
|
|
openaiMsg.ToolCalls = make([]openai.ToolCall, 0, len(msg.ToolCalls))
|
|
for _, tc := range msg.ToolCalls {
|
|
toolType := openai.ToolType(tc.Type)
|
|
openaiMsg.ToolCalls = append(openaiMsg.ToolCalls, openai.ToolCall{
|
|
ID: tc.ID,
|
|
Type: toolType,
|
|
Function: openai.FunctionCall{
|
|
Name: tc.Function.Name,
|
|
Arguments: tc.Function.Arguments,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
if msg.Role == "tool" {
|
|
openaiMsg.ToolCallID = msg.ToolCallID
|
|
openaiMsg.Name = msg.Name
|
|
}
|
|
|
|
openaiMessages = append(openaiMessages, openaiMsg)
|
|
}
|
|
return openaiMessages
|
|
}
|
|
|
|
// BuildChatCompletionRequest 构建标准聊天请求参数(导出供子类使用)
|
|
func (c *RemoteAPIChat) BuildChatCompletionRequest(messages []Message, opts *ChatOptions, isStream bool) openai.ChatCompletionRequest {
|
|
req := openai.ChatCompletionRequest{
|
|
Model: c.modelName,
|
|
Messages: c.ConvertMessages(messages),
|
|
Stream: isStream,
|
|
}
|
|
|
|
if opts != nil {
|
|
if opts.Temperature > 0 {
|
|
req.Temperature = float32(opts.Temperature)
|
|
}
|
|
if opts.TopP > 0 {
|
|
req.TopP = float32(opts.TopP)
|
|
}
|
|
if opts.MaxTokens > 0 {
|
|
req.MaxTokens = opts.MaxTokens
|
|
}
|
|
if opts.MaxCompletionTokens > 0 {
|
|
req.MaxCompletionTokens = opts.MaxCompletionTokens
|
|
}
|
|
if opts.FrequencyPenalty > 0 {
|
|
req.FrequencyPenalty = float32(opts.FrequencyPenalty)
|
|
}
|
|
if opts.PresencePenalty > 0 {
|
|
req.PresencePenalty = float32(opts.PresencePenalty)
|
|
}
|
|
|
|
// 处理 Tools
|
|
if len(opts.Tools) > 0 {
|
|
req.Tools = make([]openai.Tool, 0, len(opts.Tools))
|
|
for _, tool := range opts.Tools {
|
|
toolType := openai.ToolType(tool.Type)
|
|
openaiTool := openai.Tool{
|
|
Type: toolType,
|
|
Function: &openai.FunctionDefinition{
|
|
Name: tool.Function.Name,
|
|
Description: tool.Function.Description,
|
|
},
|
|
}
|
|
if tool.Function.Parameters != nil {
|
|
openaiTool.Function.Parameters = tool.Function.Parameters
|
|
}
|
|
req.Tools = append(req.Tools, openaiTool)
|
|
}
|
|
}
|
|
|
|
// 处理 ToolChoice(标准实现)
|
|
if opts.ToolChoice != "" {
|
|
switch opts.ToolChoice {
|
|
case "none", "required", "auto":
|
|
req.ToolChoice = opts.ToolChoice
|
|
default:
|
|
req.ToolChoice = openai.ToolChoice{
|
|
Type: "function",
|
|
Function: openai.ToolFunction{
|
|
Name: opts.ToolChoice,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(opts.Format) > 0 {
|
|
req.ResponseFormat = &openai.ChatCompletionResponseFormat{
|
|
Type: openai.ChatCompletionResponseFormatTypeJSONObject,
|
|
}
|
|
req.Messages[len(req.Messages)-1].Content += fmt.Sprintf("\nUse this JSON schema: %s", opts.Format)
|
|
}
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// logRequest 记录请求日志
|
|
func (c *RemoteAPIChat) logRequest(ctx context.Context, req any, isStream bool) {
|
|
if jsonData, err := json.MarshalIndent(req, "", " "); err == nil {
|
|
logger.Infof(ctx, "[LLM Request] model=%s, stream=%v, request:\n%s", c.modelName, isStream, secutils.CompactImageDataURLForLog(string(jsonData)))
|
|
}
|
|
}
|
|
|
|
// Chat 进行非流式聊天
|
|
func (c *RemoteAPIChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) {
|
|
req := c.BuildChatCompletionRequest(messages, opts, false)
|
|
var customEndpoint string
|
|
if c.endpointCustomizer != nil {
|
|
customEndpoint = c.endpointCustomizer(c.baseURL, c.modelID, true)
|
|
}
|
|
// 检查是否需要自定义请求
|
|
if c.requestCustomizer != nil {
|
|
customReq, useRawHTTP := c.requestCustomizer(&req, opts, false)
|
|
if useRawHTTP && customReq != nil {
|
|
return c.chatWithRawHTTP(ctx, customEndpoint, customReq)
|
|
}
|
|
}
|
|
|
|
// 使用自定义请求地址
|
|
if customEndpoint != "" {
|
|
return c.chatWithRawHTTP(ctx, customEndpoint, &req)
|
|
}
|
|
|
|
c.logRequest(ctx, req, false)
|
|
|
|
resp, err := c.client.CreateChatCompletion(ctx, req)
|
|
if err != nil {
|
|
if isMultimodalNotSupportedError(err) {
|
|
logger.Warnf(ctx, "[LLM Request] Model %s does not support multimodal, retrying without images", c.modelName)
|
|
cleaned := stripImagesFromMessages(messages)
|
|
req = c.BuildChatCompletionRequest(cleaned, opts, false)
|
|
resp, err = c.client.CreateChatCompletion(ctx, req)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create chat completion: %w", err)
|
|
}
|
|
}
|
|
|
|
return c.parseCompletionResponse(&resp)
|
|
}
|
|
|
|
// chatWithRawHTTP 使用原始 HTTP 请求进行聊天(供自定义请求使用)
|
|
func (c *RemoteAPIChat) chatWithRawHTTP(ctx context.Context, endpoint string, customReq any) (*types.ChatResponse, error) {
|
|
jsonData, err := json.Marshal(customReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
logger.Infof(ctx, "[LLM Request] model=%s, raw HTTP request:\n%s", c.modelName, string(jsonData))
|
|
if endpoint == "" {
|
|
endpoint = c.baseURL + "/chat/completions"
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("send request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var chatResp openai.ChatCompletionResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return c.parseCompletionResponse(&chatResp)
|
|
}
|
|
|
|
// parseCompletionResponse 解析非流式响应
|
|
func (c *RemoteAPIChat) parseCompletionResponse(resp *openai.ChatCompletionResponse) (*types.ChatResponse, error) {
|
|
if len(resp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response from API")
|
|
}
|
|
|
|
choice := resp.Choices[0]
|
|
|
|
// 处理思考模型的输出:移除 <think></think> 标签包裹的思考过程
|
|
// 为设置了 Thinking=false 但模型仍返回思考内容的情况和部分不支持Thinking=false的思考模型(例如Miniax-M2.1)提供兜底策略
|
|
content := removeThinkingContent(choice.Message.Content)
|
|
|
|
response := &types.ChatResponse{
|
|
Content: content,
|
|
FinishReason: string(choice.FinishReason),
|
|
Usage: struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
}{
|
|
PromptTokens: resp.Usage.PromptTokens,
|
|
CompletionTokens: resp.Usage.CompletionTokens,
|
|
TotalTokens: resp.Usage.TotalTokens,
|
|
},
|
|
}
|
|
|
|
if len(choice.Message.ToolCalls) > 0 {
|
|
response.ToolCalls = make([]types.LLMToolCall, 0, len(choice.Message.ToolCalls))
|
|
for _, tc := range choice.Message.ToolCalls {
|
|
response.ToolCalls = append(response.ToolCalls, types.LLMToolCall{
|
|
ID: tc.ID,
|
|
Type: string(tc.Type),
|
|
Function: types.FunctionCall{
|
|
Name: tc.Function.Name,
|
|
Arguments: tc.Function.Arguments,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// removeThinkingContent 移除思考模型输出中的 <think></think> 思考过程
|
|
// 仅当内容以 <think> 开头时才处理
|
|
func removeThinkingContent(content string) string {
|
|
const thinkStartTag = "<think>"
|
|
const thinkEndTag = "</think>"
|
|
|
|
trimmed := strings.TrimSpace(content)
|
|
if !strings.HasPrefix(trimmed, thinkStartTag) {
|
|
return content
|
|
}
|
|
|
|
// 查找最后一个 </think> 标签(处理嵌套情况)
|
|
if lastEndIdx := strings.LastIndex(trimmed, thinkEndTag); lastEndIdx != -1 {
|
|
if result := strings.TrimSpace(trimmed[lastEndIdx+len(thinkEndTag):]); result != "" {
|
|
return result
|
|
}
|
|
return ""
|
|
}
|
|
|
|
return "" // 未找到 </think>,可能思考内容过长被截断,返回空字符串
|
|
}
|
|
|
|
// ChatStream 进行流式聊天
|
|
func (c *RemoteAPIChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) {
|
|
req := c.BuildChatCompletionRequest(messages, opts, true)
|
|
|
|
var customEndpoint string
|
|
if c.endpointCustomizer != nil {
|
|
customEndpoint = c.endpointCustomizer(c.baseURL, c.modelID, true)
|
|
}
|
|
|
|
// 检查是否需要自定义请求
|
|
if c.requestCustomizer != nil {
|
|
customReq, useRawHTTP := c.requestCustomizer(&req, opts, true)
|
|
if useRawHTTP && customReq != nil {
|
|
return c.chatStreamWithRawHTTP(ctx, customEndpoint, customReq)
|
|
}
|
|
}
|
|
// 使用自定义请求地址
|
|
if customEndpoint != "" {
|
|
return c.chatStreamWithRawHTTP(ctx, customEndpoint, &req)
|
|
}
|
|
c.logRequest(ctx, req, true)
|
|
|
|
streamChan := make(chan types.StreamResponse)
|
|
|
|
stream, err := c.client.CreateChatCompletionStream(ctx, req)
|
|
if err != nil {
|
|
if isMultimodalNotSupportedError(err) {
|
|
logger.Warnf(ctx, "[LLM Stream] Model %s does not support multimodal, retrying without images", c.modelName)
|
|
cleaned := stripImagesFromMessages(messages)
|
|
req = c.BuildChatCompletionRequest(cleaned, opts, true)
|
|
stream, err = c.client.CreateChatCompletionStream(ctx, req)
|
|
}
|
|
if err != nil {
|
|
close(streamChan)
|
|
return nil, fmt.Errorf("create chat completion stream: %w", err)
|
|
}
|
|
}
|
|
|
|
go c.processStream(ctx, stream, streamChan)
|
|
|
|
return streamChan, nil
|
|
}
|
|
|
|
// chatStreamWithRawHTTP 使用原始 HTTP 请求进行流式聊天
|
|
func (c *RemoteAPIChat) chatStreamWithRawHTTP(ctx context.Context, endpoint string, customReq any) (<-chan types.StreamResponse, error) {
|
|
jsonData, err := json.Marshal(customReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
logger.Infof(ctx, "[LLM Stream] model=%s", c.modelName)
|
|
|
|
if endpoint == "" {
|
|
endpoint = c.baseURL + "/chat/completions"
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
httpReq.Header.Set("Accept", "text/event-stream")
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("send request: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
streamChan := make(chan types.StreamResponse)
|
|
|
|
go c.processRawHTTPStream(ctx, resp, streamChan)
|
|
|
|
return streamChan, nil
|
|
}
|
|
|
|
// processStream 处理 OpenAI SDK 流式响应
|
|
func (c *RemoteAPIChat) processStream(ctx context.Context, stream *openai.ChatCompletionStream, streamChan chan types.StreamResponse) {
|
|
defer close(streamChan)
|
|
defer stream.Close()
|
|
|
|
state := newStreamState()
|
|
|
|
for {
|
|
response, err := stream.Recv()
|
|
if err != nil {
|
|
if err.Error() == "EOF" {
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeAnswer,
|
|
Content: "",
|
|
Done: true,
|
|
ToolCalls: state.buildOrderedToolCalls(),
|
|
}
|
|
} else {
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeError,
|
|
Content: err.Error(),
|
|
Done: true,
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if len(response.Choices) > 0 {
|
|
c.processStreamDelta(ctx, &response.Choices[0], state, streamChan)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processRawHTTPStream 处理原始 HTTP 流式响应
|
|
func (c *RemoteAPIChat) processRawHTTPStream(ctx context.Context, resp *http.Response, streamChan chan types.StreamResponse) {
|
|
defer close(streamChan)
|
|
defer resp.Body.Close()
|
|
|
|
state := newStreamState()
|
|
reader := NewSSEReader(resp.Body)
|
|
|
|
for {
|
|
event, err := reader.ReadEvent()
|
|
if err != nil {
|
|
if err.Error() != "EOF" {
|
|
logger.Errorf(ctx, "Stream read error: %v", err)
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeError,
|
|
Content: err.Error(),
|
|
Done: true,
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if event == nil {
|
|
continue
|
|
}
|
|
|
|
if event.Done {
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeAnswer,
|
|
Content: "",
|
|
Done: true,
|
|
ToolCalls: state.buildOrderedToolCalls(),
|
|
}
|
|
return
|
|
}
|
|
|
|
if event.Data == nil {
|
|
continue
|
|
}
|
|
|
|
var streamResp openai.ChatCompletionStreamResponse
|
|
if err := json.Unmarshal(event.Data, &streamResp); err != nil {
|
|
logger.Errorf(ctx, "Failed to parse stream response: %v", err)
|
|
continue
|
|
}
|
|
|
|
if len(streamResp.Choices) > 0 {
|
|
c.processStreamDelta(ctx, &streamResp.Choices[0], state, streamChan)
|
|
}
|
|
}
|
|
}
|
|
|
|
// streamState 流式处理状态
|
|
type streamState struct {
|
|
toolCallMap map[int]*types.LLMToolCall
|
|
lastFunctionName map[int]string
|
|
nameNotified map[int]bool
|
|
hasThinking bool
|
|
fieldExtractors map[int]*jsonFieldExtractor // per tool-call-index extractors for streaming field extraction
|
|
}
|
|
|
|
func newStreamState() *streamState {
|
|
return &streamState{
|
|
toolCallMap: make(map[int]*types.LLMToolCall),
|
|
lastFunctionName: make(map[int]string),
|
|
nameNotified: make(map[int]bool),
|
|
hasThinking: false,
|
|
fieldExtractors: make(map[int]*jsonFieldExtractor),
|
|
}
|
|
}
|
|
|
|
func (s *streamState) buildOrderedToolCalls() []types.LLMToolCall {
|
|
if len(s.toolCallMap) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]types.LLMToolCall, 0, len(s.toolCallMap))
|
|
for i := 0; i < len(s.toolCallMap); i++ {
|
|
if tc, ok := s.toolCallMap[i]; ok && tc != nil {
|
|
result = append(result, *tc)
|
|
}
|
|
}
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
// processStreamDelta 处理流式响应的单个 delta
|
|
func (c *RemoteAPIChat) processStreamDelta(ctx context.Context, choice *openai.ChatCompletionStreamChoice, state *streamState, streamChan chan types.StreamResponse) {
|
|
delta := choice.Delta
|
|
isDone := string(choice.FinishReason) != ""
|
|
|
|
// 处理 tool calls
|
|
if len(delta.ToolCalls) > 0 {
|
|
c.processToolCallsDelta(delta.ToolCalls, state, streamChan)
|
|
}
|
|
|
|
// 发送思考内容(ReasoningContent,支持 DeepSeek 等模型)
|
|
if delta.ReasoningContent != "" {
|
|
state.hasThinking = true
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeThinking,
|
|
Content: delta.ReasoningContent,
|
|
Done: false,
|
|
}
|
|
}
|
|
|
|
// 发送回答内容
|
|
if delta.Content != "" {
|
|
// If we had thinking content and this is the first answer chunk,
|
|
// send a thinking done event first
|
|
if state.hasThinking {
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeThinking,
|
|
Content: "",
|
|
Done: true,
|
|
}
|
|
state.hasThinking = false // Only send once
|
|
}
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeAnswer,
|
|
Content: delta.Content,
|
|
Done: isDone,
|
|
ToolCalls: state.buildOrderedToolCalls(),
|
|
}
|
|
}
|
|
|
|
if isDone && len(state.toolCallMap) > 0 {
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeAnswer,
|
|
Content: "",
|
|
Done: true,
|
|
ToolCalls: state.buildOrderedToolCalls(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// processToolCallsDelta 处理 tool calls 的增量更新
|
|
func (c *RemoteAPIChat) processToolCallsDelta(toolCalls []openai.ToolCall, state *streamState, streamChan chan types.StreamResponse) {
|
|
for _, tc := range toolCalls {
|
|
var toolCallIndex int
|
|
if tc.Index != nil {
|
|
toolCallIndex = *tc.Index
|
|
}
|
|
toolCallEntry, exists := state.toolCallMap[toolCallIndex]
|
|
if !exists || toolCallEntry == nil {
|
|
toolCallEntry = &types.LLMToolCall{
|
|
Type: string(tc.Type),
|
|
Function: types.FunctionCall{
|
|
Name: "",
|
|
Arguments: "",
|
|
},
|
|
}
|
|
state.toolCallMap[toolCallIndex] = toolCallEntry
|
|
}
|
|
|
|
if tc.ID != "" {
|
|
toolCallEntry.ID = tc.ID
|
|
}
|
|
if tc.Type != "" {
|
|
toolCallEntry.Type = string(tc.Type)
|
|
}
|
|
if tc.Function.Name != "" {
|
|
toolCallEntry.Function.Name += tc.Function.Name
|
|
}
|
|
|
|
argsUpdated := false
|
|
if tc.Function.Arguments != "" {
|
|
toolCallEntry.Function.Arguments += tc.Function.Arguments
|
|
argsUpdated = true
|
|
}
|
|
|
|
currName := toolCallEntry.Function.Name
|
|
if currName != "" &&
|
|
currName == state.lastFunctionName[toolCallIndex] &&
|
|
argsUpdated &&
|
|
!state.nameNotified[toolCallIndex] &&
|
|
toolCallEntry.ID != "" {
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeToolCall,
|
|
Content: "",
|
|
Done: false,
|
|
Data: map[string]interface{}{
|
|
"tool_name": currName,
|
|
"tool_call_id": toolCallEntry.ID,
|
|
},
|
|
}
|
|
state.nameNotified[toolCallIndex] = true
|
|
}
|
|
|
|
state.lastFunctionName[toolCallIndex] = currName
|
|
|
|
// Stream final_answer tool arguments as answer-type chunks
|
|
if toolCallEntry.Function.Name == "final_answer" && argsUpdated {
|
|
extractor, exists := state.fieldExtractors[toolCallIndex]
|
|
if !exists {
|
|
extractor = newJSONFieldExtractor("answer")
|
|
state.fieldExtractors[toolCallIndex] = extractor
|
|
}
|
|
answerChunk := extractor.Feed(tc.Function.Arguments)
|
|
if answerChunk != "" {
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeAnswer,
|
|
Content: answerChunk,
|
|
Done: false,
|
|
Data: map[string]interface{}{
|
|
"source": "final_answer_tool",
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stream thinking tool's thought field as thinking-type chunks
|
|
if toolCallEntry.Function.Name == "thinking" && argsUpdated {
|
|
extractor, exists := state.fieldExtractors[toolCallIndex]
|
|
if !exists {
|
|
extractor = newJSONFieldExtractor("thought")
|
|
state.fieldExtractors[toolCallIndex] = extractor
|
|
}
|
|
thoughtChunk := extractor.Feed(tc.Function.Arguments)
|
|
if thoughtChunk != "" {
|
|
streamChan <- types.StreamResponse{
|
|
ResponseType: types.ResponseTypeThinking,
|
|
Content: thoughtChunk,
|
|
Done: false,
|
|
Data: map[string]interface{}{
|
|
"source": "thinking_tool",
|
|
"tool_call_id": toolCallEntry.ID,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetModelName 获取模型名称
|
|
func (c *RemoteAPIChat) GetModelName() string {
|
|
return c.modelName
|
|
}
|
|
|
|
// GetModelID 获取模型ID
|
|
func (c *RemoteAPIChat) GetModelID() string {
|
|
return c.modelID
|
|
}
|
|
|
|
// GetProvider 获取 provider 名称
|
|
func (c *RemoteAPIChat) GetProvider() provider.ProviderName {
|
|
return c.provider
|
|
}
|
|
|
|
// GetBaseURL 获取 baseURL
|
|
func (c *RemoteAPIChat) GetBaseURL() string {
|
|
return c.baseURL
|
|
}
|
|
|
|
// GetAPIKey 获取 apiKey
|
|
func (c *RemoteAPIChat) GetAPIKey() string {
|
|
return c.apiKey
|
|
}
|