Files
ttstd-infinite-canvas/backend/internal/app/provider_text.go

325 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
// 文本生成、Agent 工具循环和文本流解析。
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/protocol"
)
func runTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if _, ok := declarativeProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
return runDeclarativeProtocolTask(ctx, input)
}
switch input.Config.InterfaceType {
case "chat-completion":
return runChatCompletionsTextTask(ctx, input)
case "openai-response":
return runResponsesTextTask(ctx, input)
case string(model.ChannelInterfaceClaudeAPI):
return runClaudeTextTask(ctx, input)
}
return runLegacyTextTask(ctx, input)
}
// Known text protocols keep the plugin's request mapping and host transport,
// while sharing the SSE parser used by text generation and Agent requests.
func executeProtocolCreateRequest(ctx context.Context, input canvasGenerationInput, spec protocol.RequestSpec) ([]byte, *protocol.Result, error) {
wire := input.Config.InterfaceType
if wire == string(model.ChannelInterfaceOpenAIResponse) {
wire = "responses"
}
if input.Mode != "text" || !input.StreamText || (wire != "chat-completion" && wire != "responses" && wire != "claude-api") {
data, err := executeProtocolRequest(ctx, input.Config, spec)
return data, nil, err
}
body := protocolBodyObject(spec.Body)
if body == nil {
return nil, nil, errors.New("声明式流式文本请求体必须是 JSON 对象")
}
body["stream"] = true
if wire == "chat-completion" {
if err := ensureChatCompletionStreamUsage(body); err != nil {
return nil, nil, err
}
}
spec.Body = body
parser := newStreamingAgentParser(wire, input.OnTextDelta)
parser.emitReasoning = input.OnReasoningDelta
data, mimeType, err := executeProtocolBinaryRequestWithConsumer(ctx, input.Config, spec, parser.consume)
if err != nil || !strings.Contains(strings.ToLower(mimeType), "event-stream") {
return data, nil, err
}
parser.flush()
parsed, err := parser.result()
if err != nil {
return nil, nil, err
}
text := stringField(parsed, "text")
if text == "" {
return nil, nil, errors.New("流式文本接口没有返回内容")
}
return data, &protocol.Result{Text: text, Reasoning: stringField(parsed, "reasoning")}, nil
}
func runLegacyTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
responseInput, err := textResponseInput(input)
if err != nil {
return nil, err
}
body := map[string]interface{}{"model": input.Config.Model, "input": responseInput}
applyTextThinking(body, input, "responses")
applyTextOutputLimit(body, input.MaxOutputTokens, "max_output_tokens")
result, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText, input.OnTextDelta)
if err != nil {
if !shouldFallbackTextToChat(err) {
return nil, err
}
result, chatErr := runChatCompletionsTextTask(ctx, input)
if chatErr == nil {
return result, nil
}
return nil, fmt.Errorf("文本接口请求失败:Responses API %v;Chat Completions %v", err, chatErr)
}
return providerTextTaskResult(result), nil
}
func runResponsesTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
responseInput, err := textResponseInput(input)
if err != nil {
return nil, err
}
body := map[string]interface{}{"model": input.Config.Model, "input": responseInput}
applyTextThinking(body, input, "responses")
applyTextOutputLimit(body, input.MaxOutputTokens, "max_output_tokens")
result, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText, input.OnTextDelta)
if err != nil {
return nil, err
}
return providerTextTaskResult(result), nil
}
func runChatCompletionsTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
messages := []map[string]interface{}{}
if systemPrompt := strings.TrimSpace(input.Config.SystemPrompt); systemPrompt != "" {
messages = append(messages, map[string]interface{}{"role": "system", "content": systemPrompt})
}
messages = append(messages, validatedTextHistory(input.TextHistory)...)
userContent, err := textChatContent(input)
if err != nil {
return nil, err
}
messages = append(messages, map[string]interface{}{"role": "user", "content": userContent})
body := map[string]interface{}{"model": input.Config.Model, "messages": messages}
applyTextThinking(body, input, "chat-completion")
applyTextOutputLimit(body, input.MaxOutputTokens, "max_tokens")
result, err := requestTextProvider(ctx, input.Config, "/chat/completions", body, "chat-completion", input.StreamText, input.OnTextDelta)
if err != nil {
return nil, err
}
return providerTextTaskResult(result), nil
}
func runClaudeTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if len(input.ReferenceVideos) > 0 {
return nil, errors.New("Claude API 当前不支持视频参考输入")
}
messages := make([]map[string]interface{}, 0, len(input.TextHistory)+1)
for _, message := range validatedTextHistory(input.TextHistory) {
messages = append(messages, message)
}
content, err := claudeTextContent(input)
if err != nil {
return nil, err
}
messages = append(messages, map[string]interface{}{"role": "user", "content": content})
maxTokens := 4096
if input.MaxOutputTokens > 0 {
maxTokens = input.MaxOutputTokens
}
body := map[string]interface{}{"model": input.Config.Model, "max_tokens": maxTokens, "messages": messages}
applyTextThinking(body, input, "claude-api")
if systemPrompt := strings.TrimSpace(input.Config.SystemPrompt); systemPrompt != "" {
body["system"] = systemPrompt
}
result, err := requestTextProvider(ctx, input.Config, "/messages", body, "claude-api", input.StreamText, input.OnTextDelta)
if err != nil {
return nil, err
}
return providerTextTaskResult(result), nil
}
func applyTextThinking(body map[string]interface{}, input canvasGenerationInput, protocol string) {
if !input.TextOptions.Thinking {
return
}
switch protocol {
case "responses":
body["reasoning"] = map[string]interface{}{"effort": "medium", "summary": "auto"}
case "chat-completion":
body["reasoning_effort"] = "medium"
case "claude-api":
body["thinking"] = map[string]interface{}{"type": "enabled", "budget_tokens": 1024}
}
}
// Chat Completion defaults to automatic tool selection when tools are present,
// so an explicit "auto" only reduces compatibility. Reasoning endpoints also
// disagree on forced choices. Normalize before the first network request while
// preserving required/named choices for non-reasoning structured tasks.
func normalizeAgentToolChoice(body map[string]interface{}, input canvasGenerationInput, protocol string) {
if protocol == "chat-completion" && (input.TextOptions.Thinking || isAutoAgentToolChoice(body["tool_choice"])) {
delete(body, "tool_choice")
}
}
type providerTextResult struct {
Text string
Reasoning string
}
func providerTextTaskResult(result providerTextResult) map[string]interface{} {
payload := map[string]interface{}{"mode": "text", "text": result.Text}
if strings.TrimSpace(result.Reasoning) != "" {
payload["reasoning"] = result.Reasoning
}
return payload
}
func applyTextOutputLimit(body map[string]interface{}, limit int, field string) {
if limit > 0 {
body[field] = limit
}
}
func claudeTextContent(input canvasGenerationInput) (interface{}, error) {
if len(input.ReferenceImages) == 0 {
return input.Prompt, nil
}
content := []map[string]interface{}{{"type": "text", "text": input.Prompt}}
for _, image := range input.ReferenceImages {
value, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
if strings.HasPrefix(value, "data:") {
mimeType, data, ok := splitDataURL(value)
if !ok {
return nil, errors.New("Claude 参考图片 data URL 无效")
}
content = append(content, map[string]interface{}{"type": "image", "source": map[string]interface{}{"type": "base64", "media_type": mimeType, "data": data}})
} else {
content = append(content, map[string]interface{}{"type": "image", "source": map[string]interface{}{"type": "url", "url": value}})
}
}
return content, nil
}
func splitDataURL(value string) (string, string, bool) {
if !strings.HasPrefix(value, "data:") {
return "", "", false
}
separator := strings.Index(value, ",")
if separator <= len("data:") {
return "", "", false
}
header := strings.TrimPrefix(value[:separator], "data:")
if !strings.HasSuffix(header, ";base64") {
return "", "", false
}
return strings.TrimSuffix(header, ";base64"), value[separator+1:], value[separator+1:] != ""
}
func textResponseInput(input canvasGenerationInput) (interface{}, error) {
systemPrompt := strings.TrimSpace(input.Config.SystemPrompt)
if len(input.TextHistory) == 0 && len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
return withSystemPrompt(input.Config, input.Prompt), nil
}
messages := make([]map[string]interface{}, 0, len(input.TextHistory)+2)
if systemPrompt != "" {
messages = append(messages, map[string]interface{}{"role": "system", "content": systemPrompt})
}
messages = append(messages, validatedTextHistory(input.TextHistory)...)
content, err := textResponseContent(input)
if err != nil {
return nil, err
}
messages = append(messages, map[string]interface{}{"role": "user", "content": content})
return messages, nil
}
func validatedTextHistory(history []providerTextMessage) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(history))
for _, message := range history {
role := strings.ToLower(strings.TrimSpace(message.Role))
content := strings.TrimSpace(message.Content)
if (role != "user" && role != "assistant") || content == "" {
continue
}
result = append(result, map[string]interface{}{"role": role, "content": content})
}
return result
}
func textResponseContent(input canvasGenerationInput) ([]map[string]interface{}, error) {
content := []map[string]interface{}{{"type": "input_text", "text": input.Prompt}}
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "input_image", "image_url": url})
}
for _, video := range input.ReferenceVideos {
url, err := openAIVideoInputURL(video)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "input_video", "video_url": url})
}
return content, nil
}
func textChatContent(input canvasGenerationInput) (interface{}, error) {
if len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
return input.Prompt, nil
}
content := []map[string]interface{}{{"type": "text", "text": input.Prompt}}
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": url}})
}
for _, video := range input.ReferenceVideos {
url, err := openAIVideoInputURL(video)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "video_url", "video_url": map[string]interface{}{"url": url}})
}
return content, nil
}
func shouldFallbackTextToChat(err error) bool {
var httpErr providerHTTPError
if !errors.As(err, &httpErr) {
return false
}
// 只在上游明确说这个路径不存在/不允许时,才把 Responses 换成 Chat Completions。
// 502/503/504 是瞬时故障,换协议不会修好,还会把真正的上游故障伪装成“能力缺失后的第二次失败”。
switch httpErr.StatusCode {
case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented:
return true
default:
return false
}
}