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

328 lines
9.7 KiB
Go

// 普通文本任务的流式解析与增量推送(文本节点、提示词优化等非 Agent 场景)。
package app
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
func requestTextProvider(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, stream bool, onDelta func(string)) (providerTextResult, error) {
if stream {
return postStreamingTextResult(ctx, config, path, body, protocol, onDelta)
}
var payload map[string]interface{}
if err := postJSON(ctx, config, path, body, &payload); err != nil {
return providerTextResult{}, err
}
parsed, err := parseAgentToolPayload(payload, protocol)
if err != nil {
return providerTextResult{}, err
}
result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")}
if result.Text == "" {
return providerTextResult{}, errors.New("文本接口没有返回内容")
}
return result, nil
}
func postStreamingText(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (string, error) {
result, err := postStreamingTextResult(ctx, config, path, body, protocol, onDelta)
return result.Text, err
}
func postStreamingTextResult(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (providerTextResult, error) {
// 文本创作与 Agent 共用同一套 SSE 解析,确保正文、推理摘要和供应商错误语义一致。
parsed, err := postStreamingAgent(ctx, config, path, body, protocol, onDelta)
if err != nil {
return providerTextResult{}, err
}
result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")}
if result.Text == "" {
return providerTextResult{}, errors.New("流式文本接口没有返回内容")
}
return result, nil
}
func extractTextPayload(payload map[string]interface{}, protocol string) string {
if protocol == "claude-api" {
content, _ := payload["content"].([]interface{})
var result strings.Builder
for _, item := range content {
record, _ := item.(map[string]interface{})
if stringField(record, "type") == "text" {
result.WriteString(stringField(record, "text"))
}
}
return result.String()
}
if protocol == "responses" {
text := stringField(payload, "output_text")
if text == "" {
text = extractResponseText(payload)
}
return text
}
return extractChatCompletionText(payload)
}
func validateTextPayload(payload map[string]interface{}) error {
if code, ok := payload["code"].(float64); ok && code != 0 {
rawMessage := defaultString(stringField(payload, "msg"), "请求失败")
return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)}
}
if errValue, ok := payload["error"].(map[string]interface{}); ok {
if message := stringField(errValue, "message"); message != "" {
return providerPayloadError{raw: message, message: providerPayloadErrorMessage(message)}
}
}
return nil
}
func parseTextEventStream(data []byte, protocol string) (string, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 64<<10), len(data)+1)
var text strings.Builder
var eventName string
var dataLines []string
flush := func() error {
if len(dataLines) == 0 {
eventName = ""
return nil
}
raw := strings.TrimSpace(strings.Join(dataLines, "\n"))
dataLines = nil
if raw == "" || raw == "[DONE]" {
eventName = ""
return nil
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fmt.Errorf("流式文本事件解析失败:%w", err)
}
if eventName == "error" {
if err := validateTextPayload(payload); err != nil {
return err
}
return errors.New("上游流式文本请求失败")
}
if err := validateTextPayload(payload); err != nil {
return err
}
if protocol == "responses" {
text.WriteString(stringField(payload, "delta"))
} else if protocol == "claude-api" {
delta, _ := payload["delta"].(map[string]interface{})
if stringField(delta, "type") == "text_delta" {
text.WriteString(stringField(delta, "text"))
}
} else {
choices, _ := payload["choices"].([]interface{})
for _, choice := range choices {
record, _ := choice.(map[string]interface{})
delta, _ := record["delta"].(map[string]interface{})
text.WriteString(streamContentText(delta["content"]))
}
}
eventName = ""
return nil
}
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
if line == "" {
if err := flush(); err != nil {
return "", err
}
continue
}
switch {
case strings.HasPrefix(line, "event:"):
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
value := strings.TrimPrefix(line, "data:")
dataLines = append(dataLines, strings.TrimPrefix(value, " "))
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("读取流式文本响应失败:%w", err)
}
if err := flush(); err != nil {
return "", err
}
if text.Len() == 0 {
return "", errors.New("流式文本接口没有返回内容")
}
return text.String(), nil
}
func streamContentText(value interface{}) string {
if text, ok := value.(string); ok {
return text
}
parts, ok := value.([]interface{})
if !ok {
return ""
}
var result strings.Builder
for _, part := range parts {
record, _ := part.(map[string]interface{})
result.WriteString(stringField(record, "text"))
}
return result.String()
}
type streamingTextDeltaParser struct {
protocol string
buffer string
emit func(string)
}
func newStreamingTextDeltaParser(protocol string, emit func(string)) *streamingTextDeltaParser {
return &streamingTextDeltaParser{protocol: protocol, emit: emit}
}
func (p *streamingTextDeltaParser) consume(mimeType string, chunk []byte) {
if p == nil || p.emit == nil || !strings.Contains(strings.ToLower(mimeType), "event-stream") || len(chunk) == 0 {
return
}
p.buffer += string(chunk)
p.consumeFrames(false)
}
func (p *streamingTextDeltaParser) flush() {
if p == nil || p.emit == nil {
return
}
p.consumeFrames(true)
}
func (p *streamingTextDeltaParser) consumeFrames(flush bool) {
for {
match := sseFrameBoundaryPattern.FindStringIndex(p.buffer)
if match == nil {
break
}
p.consumeFrame(p.buffer[:match[0]])
p.buffer = p.buffer[match[1]:]
}
if flush && strings.TrimSpace(p.buffer) != "" {
p.consumeFrame(p.buffer)
p.buffer = ""
}
}
func (p *streamingTextDeltaParser) consumeFrame(frame string) {
var eventName string
var dataLines []string
for _, line := range strings.Split(strings.ReplaceAll(frame, "\r\n", "\n"), "\n") {
switch {
case strings.HasPrefix(line, "event:"):
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
raw := strings.TrimSpace(strings.Join(dataLines, "\n"))
if raw == "" || raw == "[DONE]" {
return
}
var payload map[string]interface{}
if json.Unmarshal([]byte(raw), &payload) != nil {
return
}
if delta := streamingTextDelta(p.protocol, eventName, payload); delta != "" {
p.emit(delta)
}
}
func streamingTextDelta(protocol string, eventName string, payload map[string]interface{}) string {
if protocol == "responses" {
eventType := firstNonEmptyString(strings.TrimSpace(eventName), stringField(payload, "type"))
if eventType == "response.output_text.delta" || eventType == "output_text.delta" || eventType == "" || eventType == "message" {
return stringField(payload, "delta")
}
return ""
}
if protocol == "claude-api" {
delta, _ := payload["delta"].(map[string]interface{})
if stringField(delta, "type") == "text_delta" {
return stringField(delta, "text")
}
return ""
}
choices, _ := payload["choices"].([]interface{})
var text strings.Builder
for _, choice := range choices {
record, _ := choice.(map[string]interface{})
delta, _ := record["delta"].(map[string]interface{})
text.WriteString(streamContentText(delta["content"]))
}
return text.String()
}
func extractResponseText(payload map[string]interface{}) string {
output, ok := payload["output"].([]interface{})
if !ok {
return ""
}
var chunks []string
for _, item := range output {
record, ok := item.(map[string]interface{})
if !ok || record["type"] != "message" {
continue
}
content, _ := record["content"].([]interface{})
for _, part := range content {
partRecord, ok := part.(map[string]interface{})
if ok && stringField(partRecord, "text") != "" {
chunks = append(chunks, stringField(partRecord, "text"))
}
}
}
return strings.Join(chunks, "")
}
func extractChatCompletionText(payload map[string]interface{}) string {
if data, ok := payload["data"].(map[string]interface{}); ok {
payload = data
}
choices, ok := payload["choices"].([]interface{})
if !ok {
return ""
}
var chunks []string
for _, choice := range choices {
record, ok := choice.(map[string]interface{})
if !ok {
continue
}
if message, ok := record["message"].(map[string]interface{}); ok {
if text := stringField(message, "content"); text != "" {
chunks = append(chunks, text)
}
}
if text := stringField(record, "text"); text != "" {
chunks = append(chunks, text)
}
}
return strings.Join(chunks, "")
}
// firstRawString 返回第一个非空字符串,并原样保留首尾空白。流式增量逐 token 到达,
// 空格常常就在 token 开头(" user");用会 TrimSpace 的 firstNonEmptyString 会把词粘在一起。
func firstRawString(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}