- Updated message structures to replace 'StreamStartData' and 'StreamEndData' with 'EventStreamStartData' and 'EventStreamEndData' for better clarity and consistency. - Introduced 'EventMessageStartData' and 'EventMessageEndData' to represent individual message lifecycle events, enhancing the granularity of message tracking. - Refactored the 'streamState' and 'groupTracker' to utilize the new message structures, improving the organization and handling of streaming events. - Enhanced the context management by integrating an ID generator for unique message identifiers, facilitating better tracking of message sequences. - Updated documentation and tests to reflect the new message structures and ensure proper functionality across the system.
340 lines
10 KiB
Go
340 lines
10 KiB
Go
package openai
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/yaoapp/yao/agent/i18n"
|
|
"github.com/yaoapp/yao/agent/output/message"
|
|
)
|
|
|
|
// ConverterRegistry manages message type converters
|
|
type ConverterRegistry struct {
|
|
converters map[string]ConverterFunc
|
|
}
|
|
|
|
// NewConverterRegistry creates a new converter registry with default converters
|
|
func NewConverterRegistry() *ConverterRegistry {
|
|
return &ConverterRegistry{
|
|
converters: map[string]ConverterFunc{
|
|
message.TypeText: convertText,
|
|
message.TypeThinking: convertThinking,
|
|
message.TypeLoading: convertLoading,
|
|
message.TypeToolCall: convertToolCall,
|
|
message.TypeError: convertError,
|
|
message.TypeImage: convertImage,
|
|
message.TypeAudio: convertToLink,
|
|
message.TypeVideo: convertToLink,
|
|
message.TypeAction: convertAction,
|
|
message.EventStreamStart: convertStreamStart, // Handle stream_start events
|
|
},
|
|
}
|
|
}
|
|
|
|
// Register registers a custom converter for a message type
|
|
func (r *ConverterRegistry) Register(msgType string, converter ConverterFunc) {
|
|
r.converters[msgType] = converter
|
|
}
|
|
|
|
// GetConverter retrieves a converter for a given message type.
|
|
func (r *ConverterRegistry) GetConverter(msgType string) (ConverterFunc, bool) {
|
|
converter, exists := r.converters[msgType]
|
|
return converter, exists
|
|
}
|
|
|
|
// Convert converts a message using registered converters
|
|
// If no converter is found, converts to link format
|
|
func (r *ConverterRegistry) Convert(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
// Check for registered converter
|
|
if converter, exists := r.converters[msg.Type]; exists {
|
|
return converter(msg, config)
|
|
}
|
|
|
|
// Fallback: convert to link format
|
|
return convertToLink(msg, config)
|
|
}
|
|
|
|
// convertText converts text messages to OpenAI format
|
|
func convertText(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
content := getStringProp(msg.Props, "content", "")
|
|
|
|
return []interface{}{
|
|
createOpenAIChunk(msg.MessageID, config.Model, map[string]interface{}{
|
|
"content": content,
|
|
}),
|
|
}, nil
|
|
}
|
|
|
|
// convertThinking converts thinking messages to OpenAI reasoning format (o1 series)
|
|
func convertThinking(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
content := getStringProp(msg.Props, "content", "")
|
|
|
|
return []interface{}{
|
|
createOpenAIChunk(msg.MessageID, config.Model, map[string]interface{}{
|
|
"reasoning_content": content,
|
|
}),
|
|
}, nil
|
|
}
|
|
|
|
// convertLoading converts loading messages to OpenAI reasoning format
|
|
// This makes loading messages visible in standard OpenAI clients as thinking process
|
|
func convertLoading(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
message := getStringProp(msg.Props, "message", "Processing...")
|
|
|
|
// Convert loading to reasoning_content so it shows in OpenAI clients
|
|
return []interface{}{
|
|
createOpenAIChunk(msg.MessageID, config.Model, map[string]interface{}{
|
|
"reasoning_content": message,
|
|
}),
|
|
}, nil
|
|
}
|
|
|
|
// convertToolCall converts tool_call messages to OpenAI format
|
|
func convertToolCall(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
// Tool call format varies, pass through the props
|
|
toolCalls := []map[string]interface{}{}
|
|
|
|
// If props contain tool call data, use it
|
|
if id, ok := msg.Props["id"].(string); ok {
|
|
toolCall := map[string]interface{}{
|
|
"id": id,
|
|
"type": "function",
|
|
}
|
|
|
|
if function, ok := msg.Props["function"].(map[string]interface{}); ok {
|
|
toolCall["function"] = function
|
|
}
|
|
|
|
toolCalls = append(toolCalls, toolCall)
|
|
}
|
|
|
|
return []interface{}{
|
|
createOpenAIChunk(msg.MessageID, config.Model, map[string]interface{}{
|
|
"tool_calls": toolCalls,
|
|
}),
|
|
}, nil
|
|
}
|
|
|
|
// convertError converts error messages to OpenAI error format
|
|
func convertError(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
message := getStringProp(msg.Props, "message", "An error occurred")
|
|
code := getStringProp(msg.Props, "code", "server_error")
|
|
|
|
return []interface{}{
|
|
map[string]interface{}{
|
|
"error": map[string]interface{}{
|
|
"message": message,
|
|
"type": code,
|
|
"code": code,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// convertAction converts action messages to nothing (silent in OpenAI clients)
|
|
// Action messages are system-level commands (open panel, navigate, etc.)
|
|
// and should not be sent to standard chat clients
|
|
func convertAction(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
// Return empty slice - no output for action messages in OpenAI format
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
// convertStreamStart converts stream_start event to OpenAI format
|
|
// If model supports reasoning: converts to reasoning_content (thinking)
|
|
// Otherwise: converts to regular Markdown text with trace link
|
|
func convertStreamStart(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
// Extract stream_start data from props
|
|
data, ok := msg.Props["data"]
|
|
if !ok {
|
|
// No data, skip this message
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
// Try to convert to EventStreamStartData
|
|
var startData message.EventStreamStartData
|
|
switch v := data.(type) {
|
|
case message.EventStreamStartData:
|
|
startData = v
|
|
case map[string]interface{}:
|
|
// If it's a map, try to extract traceID
|
|
if traceID, ok := v["trace_id"].(string); ok {
|
|
startData.TraceID = traceID
|
|
}
|
|
if requestID, ok := v["request_id"].(string); ok {
|
|
startData.RequestID = requestID
|
|
}
|
|
default:
|
|
// Unknown data type, skip
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
// Check if we have a trace ID to link to
|
|
if startData.TraceID == "" {
|
|
// No trace ID, skip this message
|
|
return []interface{}{}, nil
|
|
}
|
|
|
|
// Generate trace link
|
|
traceLink := generateTraceLink(startData.TraceID, config)
|
|
|
|
// Check if model supports reasoning
|
|
supportsReasoning := false
|
|
if config.Capabilities != nil && config.Capabilities.Reasoning != nil {
|
|
supportsReasoning = *config.Capabilities.Reasoning
|
|
}
|
|
|
|
// Get localized text using i18n
|
|
streamStartText := i18n.T(config.Locale, "output.stream_start")
|
|
viewTraceText := i18n.T(config.Locale, "output.view_trace")
|
|
|
|
// Convert based on reasoning support
|
|
if supportsReasoning {
|
|
// Convert to thinking format (reasoning_content)
|
|
// Reasoning models display this as part of the thinking process
|
|
content := fmt.Sprintf("🔍 %s - [%s](%s)\n", streamStartText, viewTraceText, traceLink)
|
|
chunk := createOpenAIChunk(msg.MessageID, config.Model, map[string]interface{}{
|
|
"reasoning_content": content,
|
|
})
|
|
return []interface{}{chunk}, nil
|
|
}
|
|
|
|
// Convert to regular Markdown text
|
|
content := fmt.Sprintf("🚀 %s - [%s](%s)\n", streamStartText, viewTraceText, traceLink)
|
|
chunk := createOpenAIChunk(msg.MessageID, config.Model, map[string]interface{}{
|
|
"content": content,
|
|
})
|
|
return []interface{}{chunk}, nil
|
|
}
|
|
|
|
// generateTraceLink generates a trace link URL
|
|
// Uses 'view' mode (clean page without sidebar) for better viewing experience in chat
|
|
func generateTraceLink(traceID string, config *AdapterConfig) string {
|
|
baseURL := config.BaseURL
|
|
if baseURL == "" {
|
|
// If no base URL, return a relative link
|
|
return fmt.Sprintf("/trace/%s/view", traceID)
|
|
}
|
|
return fmt.Sprintf("%s/trace/%s/view", baseURL, traceID)
|
|
}
|
|
|
|
// convertImage converts image messages to Markdown image format
|
|
// Uses  which displays inline in Markdown-supporting clients
|
|
func convertImage(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
// Get URL
|
|
url, ok := msg.Props["url"].(string)
|
|
if !ok || url == "" {
|
|
return nil, fmt.Errorf("image message missing url")
|
|
}
|
|
|
|
// Transform URL if transformer is provided
|
|
if config.LinkTransformer != nil {
|
|
transformedURL, err := config.LinkTransformer(url, msg.Type, msg.MessageID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
url = transformedURL
|
|
}
|
|
|
|
// Get alt text (default to "Image")
|
|
alt := getStringProp(msg.Props, "alt", "Image")
|
|
|
|
// Format as Markdown image: 
|
|
template := getLinkTemplate(msg.Type, config)
|
|
text := fmt.Sprintf(template, alt, url)
|
|
|
|
return []interface{}{
|
|
createOpenAIChunk(msg.MessageID, config.Model, map[string]interface{}{
|
|
"content": text,
|
|
}),
|
|
}, nil
|
|
}
|
|
|
|
// convertToLink converts any message type to a Markdown link format
|
|
func convertToLink(msg *message.Message, config *AdapterConfig) ([]interface{}, error) {
|
|
// Generate link
|
|
link, err := generateViewLink(msg, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get template
|
|
template := getLinkTemplate(msg.Type, config)
|
|
|
|
// Format text
|
|
var text string
|
|
if msg.Type == "button" {
|
|
// Button is special: needs button text
|
|
buttonText := getStringProp(msg.Props, "text", "Button")
|
|
text = fmt.Sprintf(template, buttonText, link)
|
|
} else {
|
|
text = fmt.Sprintf(template, link)
|
|
}
|
|
|
|
return []interface{}{
|
|
createOpenAIChunk(msg.MessageID, config.Model, map[string]interface{}{
|
|
"content": text,
|
|
}),
|
|
}, nil
|
|
}
|
|
|
|
// generateViewLink generates a view link for a message
|
|
func generateViewLink(msg *message.Message, config *AdapterConfig) (string, error) {
|
|
// If Props contains a URL, use it
|
|
if url, ok := msg.Props["url"].(string); ok {
|
|
// Transform URL if transformer is provided
|
|
if config.LinkTransformer != nil {
|
|
return config.LinkTransformer(url, msg.Type, msg.MessageID)
|
|
}
|
|
return url, nil
|
|
}
|
|
|
|
// Generate view link: {baseURL}/agent/view/{type}/{id}
|
|
baseURL := config.BaseURL
|
|
if baseURL == "" {
|
|
baseURL = "" // TODO: Get from environment or context
|
|
}
|
|
|
|
viewURL := fmt.Sprintf("%s/agent/view/%s/%s", baseURL, msg.Type, msg.MessageID)
|
|
|
|
// Transform URL if transformer is provided
|
|
if config.LinkTransformer != nil {
|
|
return config.LinkTransformer(viewURL, msg.Type, msg.MessageID)
|
|
}
|
|
|
|
return viewURL, nil
|
|
}
|
|
|
|
// getLinkTemplate gets the link template for a message type
|
|
func getLinkTemplate(msgType string, config *AdapterConfig) string {
|
|
if template, exists := config.LinkTemplates[msgType]; exists {
|
|
return template
|
|
}
|
|
|
|
// Default fallback template
|
|
return "📎 [View %s](" + msgType + ")"
|
|
}
|
|
|
|
// createOpenAIChunk creates an OpenAI chat completion chunk
|
|
func createOpenAIChunk(id string, model string, delta map[string]interface{}) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"id": id,
|
|
"object": "chat.completion.chunk",
|
|
"created": time.Now().Unix(),
|
|
"model": model,
|
|
"choices": []map[string]interface{}{
|
|
{
|
|
"index": 0,
|
|
"delta": delta,
|
|
"finish_reason": nil,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// getStringProp safely gets a string property from props
|
|
func getStringProp(props map[string]interface{}, key string, defaultValue string) string {
|
|
if val, ok := props[key].(string); ok {
|
|
return val
|
|
}
|
|
return defaultValue
|
|
}
|