443 lines
16 KiB
Go
443 lines
16 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"strings"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
)
|
||
|
||
// The browser sends one protocol-neutral conversation. Only the selected
|
||
// provider body is materialized; declarative plugins retain their request paths.
|
||
type canonicalAgentRequest struct {
|
||
Messages []map[string]interface{} `json:"messages"`
|
||
Tools []map[string]interface{} `json:"tools"`
|
||
ToolChoice interface{} `json:"toolChoice"`
|
||
SystemPrompt string `json:"systemPrompt"`
|
||
PromptCacheKey string `json:"promptCacheKey,omitempty"`
|
||
}
|
||
|
||
func expandCanonicalAgentRequest(source *canonicalAgentRequest, config providerConfig, declarative bool) (*agentToolRequests, error) {
|
||
if len(source.Messages) == 0 {
|
||
return nil, errors.New("画布 Agent 请求缺少会话内容")
|
||
}
|
||
for _, message := range source.Messages {
|
||
if stringField(message, "type") == "function_call" {
|
||
if stringField(message, "call_id") == "" || stringField(message, "name") == "" {
|
||
return nil, errors.New("画布 Agent 工具调用缺少标识或名称")
|
||
}
|
||
if _, ok := message["arguments"].(string); !ok {
|
||
return nil, errors.New("画布 Agent 工具调用参数无效")
|
||
}
|
||
continue
|
||
}
|
||
if stringField(message, "type") != "" {
|
||
return nil, errors.New("画布 Agent 会话条目类型无效")
|
||
}
|
||
switch stringField(message, "role") {
|
||
case "system", "user", "assistant":
|
||
case "tool":
|
||
if stringField(message, "tool_call_id") == "" {
|
||
return nil, errors.New("画布 Agent 工具结果缺少调用标识")
|
||
}
|
||
if _, ok := message["content"].(string); !ok {
|
||
return nil, errors.New("画布 Agent 工具结果内容无效")
|
||
}
|
||
default:
|
||
return nil, errors.New("画布 Agent 会话角色无效")
|
||
}
|
||
if err := validateCanonicalAgentContent(message["content"]); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
for _, tool := range source.Tools {
|
||
function, ok := tool["function"].(map[string]interface{})
|
||
if !ok || stringField(function, "name") == "" {
|
||
return nil, errors.New("画布 Agent 工具定义缺少名称")
|
||
}
|
||
}
|
||
switch choice := source.ToolChoice.(type) {
|
||
case string:
|
||
if choice != "auto" && choice != "required" {
|
||
return nil, errors.New("画布 Agent 工具选择无效")
|
||
}
|
||
case map[string]interface{}:
|
||
if stringField(choice, "type") != "function" || stringField(choice, "name") == "" {
|
||
return nil, errors.New("画布 Agent 工具选择缺少名称")
|
||
}
|
||
default:
|
||
return nil, errors.New("画布 Agent 请求缺少工具选择")
|
||
}
|
||
messages := source.Messages
|
||
if prompt := strings.TrimSpace(source.SystemPrompt); prompt != "" {
|
||
present := false
|
||
for _, message := range messages {
|
||
if stringField(message, "role") == "system" && strings.TrimSpace(stringField(message, "content")) == prompt {
|
||
present = true
|
||
break
|
||
}
|
||
}
|
||
if !present {
|
||
messages = append([]map[string]interface{}{{"role": "system", "content": prompt}}, messages...)
|
||
}
|
||
}
|
||
request := *source
|
||
request.Messages = messages
|
||
// 同名工具会被多数供应商直接拒绝(400)。按名称去重,保留第一次出现的定义。
|
||
request.Tools = dedupeCanonicalAgentTools(source.Tools)
|
||
result := &agentToolRequests{}
|
||
if declarative {
|
||
result.ChatCompletion = canonicalAgentChatBody(&request, false)
|
||
result.Responses = canonicalAgentResponsesBody(&request)
|
||
result.Claude = claudeAgentBody(canonicalAgentChatBody(&request, true))
|
||
result.Claude["model"] = config.Model
|
||
result.Gemini = canonicalAgentGeminiBody(&request)
|
||
return result, nil
|
||
}
|
||
switch config.InterfaceType {
|
||
case string(model.ChannelInterfaceOpenAIResponse):
|
||
result.Responses = canonicalAgentResponsesBody(&request)
|
||
case string(model.ChannelInterfaceClaudeAPI):
|
||
result.Claude = claudeAgentBody(canonicalAgentChatBody(&request, true))
|
||
default:
|
||
result.ChatCompletion = canonicalAgentChatBody(&request, false)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func canonicalAgentChatBody(source *canonicalAgentRequest, claude bool) map[string]interface{} {
|
||
messages := make([]interface{}, 0, len(source.Messages))
|
||
format := "chat"
|
||
if claude {
|
||
format = "claude"
|
||
}
|
||
for index := 0; index < len(source.Messages); {
|
||
message := source.Messages[index]
|
||
if stringField(message, "type") == "function_call" {
|
||
calls := []interface{}{}
|
||
for index < len(source.Messages) && stringField(source.Messages[index], "type") == "function_call" {
|
||
call := source.Messages[index]
|
||
arguments := call["arguments"]
|
||
if claude {
|
||
arguments = canonicalAgentJSONObject(arguments)
|
||
}
|
||
calls = append(calls, map[string]interface{}{
|
||
"id": call["call_id"], "type": "function",
|
||
"function": map[string]interface{}{"name": call["name"], "arguments": arguments},
|
||
})
|
||
index++
|
||
}
|
||
messages = append(messages, map[string]interface{}{"role": "assistant", "content": nil, "tool_calls": calls})
|
||
continue
|
||
}
|
||
converted := map[string]interface{}{"role": message["role"], "content": canonicalAgentContent(message["content"], format)}
|
||
if calls := canonicalAgentToolCalls(message["tool_calls"]); len(calls) > 0 {
|
||
// Runtime calls are protocol-neutral; materialize the wire discriminator here.
|
||
wireCalls := make([]interface{}, 0, len(calls))
|
||
for _, call := range calls {
|
||
call["type"] = "function"
|
||
wireCalls = append(wireCalls, call)
|
||
}
|
||
converted["tool_calls"] = wireCalls
|
||
}
|
||
if claude && stringField(message, "role") == "system" {
|
||
converted["content"] = canonicalAgentText(message["content"])
|
||
}
|
||
if stringField(message, "role") == "tool" {
|
||
converted["tool_call_id"] = message["tool_call_id"]
|
||
}
|
||
messages = append(messages, converted)
|
||
index++
|
||
}
|
||
tools := make([]interface{}, 0, len(source.Tools))
|
||
for _, tool := range source.Tools {
|
||
tools = append(tools, tool)
|
||
}
|
||
choice := source.ToolChoice
|
||
if named, ok := choice.(map[string]interface{}); ok {
|
||
choice = map[string]interface{}{"type": "function", "function": map[string]interface{}{"name": named["name"]}}
|
||
}
|
||
body := map[string]interface{}{"messages": messages, "tools": tools, "tool_choice": choice, "parallel_tool_calls": false}
|
||
if !claude && source.PromptCacheKey != "" {
|
||
body["prompt_cache_key"] = source.PromptCacheKey
|
||
}
|
||
return body
|
||
}
|
||
|
||
func canonicalAgentResponsesBody(source *canonicalAgentRequest) map[string]interface{} {
|
||
messages := make([]interface{}, 0, len(source.Messages))
|
||
for _, message := range source.Messages {
|
||
switch {
|
||
case stringField(message, "type") == "function_call":
|
||
messages = append(messages, map[string]interface{}{
|
||
"type": "function_call", "call_id": message["call_id"], "name": message["name"], "arguments": message["arguments"],
|
||
})
|
||
case stringField(message, "role") == "tool":
|
||
messages = append(messages, map[string]interface{}{"type": "function_call_output", "call_id": message["tool_call_id"], "output": message["content"]})
|
||
default:
|
||
messages = append(messages, map[string]interface{}{"role": message["role"], "content": canonicalAgentContent(message["content"], "responses")})
|
||
for _, call := range canonicalAgentToolCalls(message["tool_calls"]) {
|
||
function, _ := call["function"].(map[string]interface{})
|
||
messages = append(messages, map[string]interface{}{"type": "function_call", "call_id": call["id"], "name": function["name"], "arguments": function["arguments"]})
|
||
}
|
||
}
|
||
}
|
||
tools := make([]interface{}, 0, len(source.Tools))
|
||
for _, tool := range source.Tools {
|
||
function, _ := tool["function"].(map[string]interface{})
|
||
converted := cloneStringAnyMap(function)
|
||
converted["type"] = "function"
|
||
tools = append(tools, converted)
|
||
}
|
||
body := map[string]interface{}{"input": messages, "tools": tools, "tool_choice": source.ToolChoice, "parallel_tool_calls": false}
|
||
if source.PromptCacheKey != "" {
|
||
body["prompt_cache_key"] = source.PromptCacheKey
|
||
}
|
||
return body
|
||
}
|
||
|
||
func canonicalAgentGeminiBody(source *canonicalAgentRequest) map[string]interface{} {
|
||
contents := make([]interface{}, 0, len(source.Messages))
|
||
var system []string
|
||
callNames := map[string]string{}
|
||
for _, message := range source.Messages {
|
||
role := "user"
|
||
var parts interface{}
|
||
switch {
|
||
case stringField(message, "type") == "function_call":
|
||
id, name := stringField(message, "call_id"), stringField(message, "name")
|
||
callNames[id] = name
|
||
part := map[string]interface{}{"functionCall": map[string]interface{}{"id": id, "name": name, "args": canonicalAgentJSONObject(message["arguments"])}}
|
||
if signature := stringField(message, "thoughtSignature"); signature != "" {
|
||
part["thoughtSignature"] = signature
|
||
}
|
||
role, parts = "model", []interface{}{part}
|
||
case stringField(message, "role") == "system":
|
||
if text := canonicalAgentText(message["content"]); text != "" {
|
||
system = append(system, text)
|
||
}
|
||
continue
|
||
case stringField(message, "role") == "tool":
|
||
id := stringField(message, "tool_call_id")
|
||
name := callNames[id]
|
||
if name == "" {
|
||
name = "tool_result"
|
||
}
|
||
parts = []interface{}{map[string]interface{}{"functionResponse": map[string]interface{}{
|
||
"id": id, "name": name, "response": map[string]interface{}{"result": canonicalAgentJSONValue(message["content"])},
|
||
}}}
|
||
default:
|
||
if stringField(message, "role") == "assistant" {
|
||
role = "model"
|
||
}
|
||
parts = canonicalAgentContent(message["content"], "gemini")
|
||
}
|
||
contents = append(contents, map[string]interface{}{"role": role, "parts": parts})
|
||
}
|
||
body := map[string]interface{}{"contents": contents}
|
||
if len(system) > 0 {
|
||
body["systemInstruction"] = map[string]interface{}{"parts": []interface{}{map[string]interface{}{"text": strings.Join(system, "\n\n")}}}
|
||
}
|
||
if len(source.Tools) > 0 {
|
||
declarations := make([]interface{}, 0, len(source.Tools))
|
||
for _, tool := range source.Tools {
|
||
function, _ := tool["function"].(map[string]interface{})
|
||
declaration := cloneStringAnyMap(function)
|
||
delete(declaration, "strict")
|
||
declarations = append(declarations, declaration)
|
||
}
|
||
choice := map[string]interface{}{"mode": "AUTO"}
|
||
if named, ok := source.ToolChoice.(map[string]interface{}); ok {
|
||
choice["mode"], choice["allowedFunctionNames"] = "ANY", []interface{}{named["name"]}
|
||
} else if source.ToolChoice == "required" {
|
||
choice["mode"] = "ANY"
|
||
}
|
||
body["tools"] = []interface{}{map[string]interface{}{"functionDeclarations": declarations}}
|
||
body["toolConfig"] = map[string]interface{}{"functionCallingConfig": choice}
|
||
}
|
||
return body
|
||
}
|
||
|
||
func canonicalAgentContent(content interface{}, format string) interface{} {
|
||
items, array := content.([]interface{})
|
||
if !array {
|
||
if content == nil {
|
||
content = ""
|
||
}
|
||
if format == "gemini" {
|
||
return []interface{}{map[string]interface{}{"text": content}}
|
||
}
|
||
return content
|
||
}
|
||
result := make([]interface{}, 0, len(items))
|
||
for _, value := range items {
|
||
item, _ := value.(map[string]interface{})
|
||
kind := stringField(item, "type")
|
||
if kind == "text" {
|
||
part := map[string]interface{}{"text": item["text"]}
|
||
if format != "gemini" {
|
||
part["type"] = "text"
|
||
if format == "responses" {
|
||
part["type"] = "input_text"
|
||
}
|
||
}
|
||
result = append(result, part)
|
||
continue
|
||
}
|
||
file, _ := item[kind].(map[string]interface{})
|
||
url := stringField(file, "url")
|
||
mimeType := stringField(file, "mimeType")
|
||
if kind == "image_url" {
|
||
mimeType = "image/png"
|
||
}
|
||
inlineMIME, inlineData, inline := canonicalAgentDataURL(url)
|
||
var part map[string]interface{}
|
||
switch format {
|
||
case "responses":
|
||
part = map[string]interface{}{"type": "input_image", "image_url": url}
|
||
if kind == "file_url" {
|
||
part = map[string]interface{}{"type": "input_file", "filename": file["name"]}
|
||
if strings.HasPrefix(url, "data:") {
|
||
part["file_data"] = url
|
||
} else {
|
||
part["file_url"] = url
|
||
}
|
||
}
|
||
case "claude":
|
||
if inline {
|
||
blockType := "image"
|
||
if kind == "file_url" && inlineMIME == "application/pdf" {
|
||
blockType = "document"
|
||
}
|
||
part = map[string]interface{}{"type": blockType, "source": map[string]interface{}{"type": "base64", "media_type": inlineMIME, "data": inlineData}}
|
||
} else if kind == "image_url" {
|
||
part = map[string]interface{}{"type": "image", "source": map[string]interface{}{"type": "url", "url": url}}
|
||
} else {
|
||
part = map[string]interface{}{"type": "text", "text": stringField(file, "name") + ": " + url}
|
||
}
|
||
case "gemini":
|
||
if inline {
|
||
part = map[string]interface{}{"inlineData": map[string]interface{}{"mimeType": inlineMIME, "data": inlineData}}
|
||
} else {
|
||
part = map[string]interface{}{"fileData": map[string]interface{}{"fileUri": url, "mimeType": mimeType}}
|
||
}
|
||
default:
|
||
part = item
|
||
if kind == "file_url" {
|
||
converted := map[string]interface{}{"filename": file["name"]}
|
||
if strings.HasPrefix(url, "data:") {
|
||
converted["file_data"] = url
|
||
} else {
|
||
converted["file_url"] = url
|
||
}
|
||
part = map[string]interface{}{"type": "file", "file": converted}
|
||
}
|
||
}
|
||
result = append(result, part)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func canonicalAgentDataURL(value string) (string, string, bool) {
|
||
if !strings.HasPrefix(value, "data:") {
|
||
return "", "", false
|
||
}
|
||
mimeType, data, ok := strings.Cut(strings.TrimPrefix(value, "data:"), ";base64,")
|
||
return mimeType, data, ok && mimeType != "" && data != ""
|
||
}
|
||
|
||
func validateCanonicalAgentContent(content interface{}) error {
|
||
if _, ok := content.(string); ok {
|
||
return nil
|
||
}
|
||
parts, ok := content.([]interface{})
|
||
if !ok {
|
||
return errors.New("画布 Agent 消息内容无效")
|
||
}
|
||
for _, value := range parts {
|
||
part, _ := value.(map[string]interface{})
|
||
switch kind := stringField(part, "type"); kind {
|
||
case "text":
|
||
if _, ok := part["text"].(string); !ok {
|
||
return errors.New("画布 Agent 文本内容无效")
|
||
}
|
||
case "image_url", "file_url":
|
||
file, _ := part[kind].(map[string]interface{})
|
||
if stringField(file, "url") == "" {
|
||
return errors.New("画布 Agent 素材地址为空")
|
||
}
|
||
if kind == "file_url" && (stringField(file, "name") == "" || stringField(file, "mimeType") == "") {
|
||
return errors.New("画布 Agent 文件缺少名称或类型")
|
||
}
|
||
default:
|
||
return errors.New("画布 Agent 消息内容类型无效")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func canonicalAgentJSONValue(value interface{}) interface{} {
|
||
if raw, ok := value.(string); ok {
|
||
var parsed interface{}
|
||
if json.Unmarshal([]byte(raw), &parsed) == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
return value
|
||
}
|
||
|
||
func canonicalAgentToolCalls(value interface{}) []map[string]interface{} {
|
||
raw, err := json.Marshal(value)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var calls []map[string]interface{}
|
||
if json.Unmarshal(raw, &calls) != nil {
|
||
return nil
|
||
}
|
||
return calls
|
||
}
|
||
|
||
func canonicalAgentJSONObject(value interface{}) map[string]interface{} {
|
||
if parsed, ok := canonicalAgentJSONValue(value).(map[string]interface{}); ok {
|
||
return parsed
|
||
}
|
||
return map[string]interface{}{}
|
||
}
|
||
|
||
func canonicalAgentText(content interface{}) string {
|
||
if text, ok := content.(string); ok {
|
||
return text
|
||
}
|
||
var texts []string
|
||
if parts, ok := content.([]interface{}); ok {
|
||
for _, value := range parts {
|
||
part, _ := value.(map[string]interface{})
|
||
if stringField(part, "type") == "text" {
|
||
texts = append(texts, stringField(part, "text"))
|
||
} else if image, ok := part["image_url"].(map[string]interface{}); ok {
|
||
texts = append(texts, stringField(image, "url"))
|
||
} else if file, ok := part["file_url"].(map[string]interface{}); ok {
|
||
texts = append(texts, stringField(file, "name")+": "+stringField(file, "url"))
|
||
}
|
||
}
|
||
}
|
||
return strings.Join(texts, "\n")
|
||
}
|
||
|
||
func dedupeCanonicalAgentTools(tools []map[string]interface{}) []map[string]interface{} {
|
||
seen := make(map[string]bool, len(tools))
|
||
result := make([]map[string]interface{}, 0, len(tools))
|
||
for _, tool := range tools {
|
||
function, _ := tool["function"].(map[string]interface{})
|
||
name := stringField(function, "name")
|
||
if seen[name] {
|
||
continue
|
||
}
|
||
seen[name] = true
|
||
result = append(result, tool)
|
||
}
|
||
return result
|
||
}
|