219 lines
7.2 KiB
Go
219 lines
7.2 KiB
Go
// 声明式协议插件的结果处理:输出提取、媒体下载重试与错误归类。
|
||
|
||
package app
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"yingce/backend/internal/protocol"
|
||
)
|
||
|
||
func appendProtocolQuery(rawURL string, values map[string][]string) (string, error) {
|
||
if len(values) == 0 {
|
||
return rawURL, nil
|
||
}
|
||
parsed, err := url.Parse(rawURL)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
query := parsed.Query()
|
||
for key, items := range values {
|
||
for _, item := range items {
|
||
query.Add(key, item)
|
||
}
|
||
}
|
||
parsed.RawQuery = query.Encode()
|
||
return parsed.String(), nil
|
||
}
|
||
|
||
func finishProtocolResult(ctx context.Context, config providerConfig, mode string, taskID string, result *protocol.Result, pollPolicy videoPollPolicy) (map[string]interface{}, error) {
|
||
if result == nil {
|
||
return nil, errors.New("声明式协议已完成但没有返回结果")
|
||
}
|
||
if mode == "text" {
|
||
output := map[string]interface{}{"mode": "text", "text": result.Text}
|
||
if strings.TrimSpace(result.Reasoning) != "" {
|
||
output["reasoning"] = result.Reasoning
|
||
}
|
||
return output, nil
|
||
}
|
||
var references []protocol.MediaReference
|
||
switch mode {
|
||
case "image":
|
||
references = result.Images
|
||
case "video":
|
||
references = result.Videos
|
||
case "audio":
|
||
references = result.Audios
|
||
default:
|
||
return nil, fmt.Errorf("声明式协议不支持生成模式 %s", mode)
|
||
}
|
||
if len(references) == 0 {
|
||
return nil, errors.New("声明式协议已完成但没有返回媒体地址")
|
||
}
|
||
if output, handled, err := recoverProtocolMedia(ctx, config, mode, references); handled {
|
||
return output, err
|
||
}
|
||
items := make([]interface{}, 0, len(references))
|
||
for _, reference := range references {
|
||
var data []byte
|
||
var mimeType string
|
||
var err error
|
||
if mode == "video" {
|
||
data, mimeType, err = runVideoDownload(ctx, taskID, pollPolicy, func(ctx context.Context) ([]byte, string, error) {
|
||
return protocolMediaBytesOnce(ctx, config, reference)
|
||
})
|
||
} else {
|
||
data, mimeType, err = protocolMediaBytes(ctx, config, reference)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
item := map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}
|
||
items = append(items, item)
|
||
}
|
||
switch mode {
|
||
case "image":
|
||
return map[string]interface{}{"mode": "image", "images": items}, nil
|
||
case "video":
|
||
return map[string]interface{}{"mode": "video", "video": items[0]}, nil
|
||
default:
|
||
return map[string]interface{}{"mode": "audio", "audio": items[0]}, nil
|
||
}
|
||
}
|
||
|
||
// finishProtocolAdapterResult 优先消费 create/poll 已返回的内联或 URL 结果,只有插件明确声明独立结果端点时才下载。
|
||
// 空响应或下载失败必须向上失败,不能生成伪素材;application/octet-stream 仅表示传输层未知类型,
|
||
// 不会把未知内容伪装成具体图片、视频或音频 MIME。
|
||
func finishProtocolAdapterResult(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter, request protocol.GenerationRequest, taskID string, result *protocol.Result, pollPolicy videoPollPolicy) (map[string]interface{}, error) {
|
||
if protocolResultHasOutput(input.Mode, result) {
|
||
return finishProtocolResult(ctx, input.Config, input.Mode, taskID, result, pollPolicy)
|
||
}
|
||
resultAdapter, ok := adapter.(protocol.ResultAdapter)
|
||
capability, hasCapability := adapter.(protocol.ResultCapability)
|
||
if !ok || !hasCapability || !capability.ResultAvailable() {
|
||
return finishProtocolResult(ctx, input.Config, input.Mode, taskID, result, pollPolicy)
|
||
}
|
||
spec, err := resultAdapter.BuildResult(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
download := func(ctx context.Context) ([]byte, string, error) {
|
||
return executeProtocolBinaryRequest(withProviderRequestKind(ctx, "download"), input.Config, spec)
|
||
}
|
||
var data []byte
|
||
var mimeType string
|
||
if input.Mode == "video" {
|
||
data, mimeType, err = runVideoDownload(ctx, taskID, pollPolicy, download)
|
||
} else {
|
||
data, mimeType, err = download(ctx)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("声明式协议结果下载失败:%w", err)
|
||
}
|
||
if len(data) == 0 {
|
||
return nil, errors.New("声明式协议结果下载返回空内容")
|
||
}
|
||
mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0])
|
||
if mimeType == "" {
|
||
mimeType = "application/octet-stream"
|
||
}
|
||
reference := protocol.MediaReference{DataURL: dataURL(mimeType, data), MIMEType: mimeType}
|
||
downloaded := &protocol.Result{}
|
||
switch input.Mode {
|
||
case "image":
|
||
downloaded.Images = []protocol.MediaReference{reference}
|
||
case "video":
|
||
downloaded.Videos = []protocol.MediaReference{reference}
|
||
case "audio":
|
||
downloaded.Audios = []protocol.MediaReference{reference}
|
||
default:
|
||
return nil, fmt.Errorf("声明式协议结果下载不支持生成模式 %s", input.Mode)
|
||
}
|
||
return finishProtocolResult(ctx, input.Config, input.Mode, taskID, downloaded, pollPolicy)
|
||
}
|
||
|
||
func protocolResultHasOutput(mode string, result *protocol.Result) bool {
|
||
if result == nil {
|
||
return false
|
||
}
|
||
switch mode {
|
||
case "text":
|
||
return strings.TrimSpace(result.Text) != ""
|
||
case "image":
|
||
return len(result.Images) > 0
|
||
case "video":
|
||
return len(result.Videos) > 0
|
||
case "audio":
|
||
return len(result.Audios) > 0
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func protocolMediaBytes(ctx context.Context, config providerConfig, reference protocol.MediaReference) ([]byte, string, error) {
|
||
var data []byte
|
||
var mimeType string
|
||
var err error
|
||
for attempt := 0; attempt < 3; attempt++ {
|
||
data, mimeType, err = protocolMediaBytesOnce(ctx, config, reference)
|
||
if err == nil {
|
||
return data, mimeType, nil
|
||
}
|
||
if attempt == 2 || !retryableProtocolMediaDownload(err) {
|
||
break
|
||
}
|
||
if waitErr := sleepContext(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||
return nil, "", fmt.Errorf("声明式协议媒体结果下载失败:%w", waitErr)
|
||
}
|
||
}
|
||
return nil, "", fmt.Errorf("声明式协议媒体结果下载失败:%w", err)
|
||
}
|
||
|
||
func protocolMediaBytesOnce(ctx context.Context, config providerConfig, reference protocol.MediaReference) ([]byte, string, error) {
|
||
if strings.TrimSpace(reference.DataURL) != "" {
|
||
mimeType, data, err := decodeProviderDataURL(reference.DataURL)
|
||
return data, mimeType, err
|
||
}
|
||
value := strings.TrimSpace(reference.URL)
|
||
if value == "" {
|
||
return nil, "", errors.New("声明式协议媒体结果地址为空")
|
||
}
|
||
data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), config, value)
|
||
return data, normalizedMediaMimeType(mimeType, data), err
|
||
}
|
||
|
||
func retryableProtocolMediaDownload(err error) bool {
|
||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||
return false
|
||
}
|
||
var networkError net.Error
|
||
if errors.As(err, &networkError) && (networkError.Timeout() || networkError.Temporary()) {
|
||
return true
|
||
}
|
||
message := strings.ToLower(err.Error())
|
||
for _, marker := range []string{"tls handshake timeout", "connection reset", "unexpected eof", "broken pipe"} {
|
||
if strings.Contains(message, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func protocolResultError(message, taskID string) error {
|
||
message = strings.TrimSpace(message)
|
||
if message == "" {
|
||
message = "上游返回失败状态"
|
||
}
|
||
if taskID == "" {
|
||
return errors.New(message)
|
||
}
|
||
return fmt.Errorf("声明式协议任务失败(任务 %s):%s", taskID, message)
|
||
}
|