FILE / ScuroNeko/go-deepseek

sse.go

Исходный файл и его история в репозитории.
FILE 41025817afb53ebcae31d8dc26e8a520ff0d0d65
Files
go-deepseek/sse.go
T
2026-07-31 14:21:48 +03:00

583 lines
12 KiB
Go

package deepseek
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"strings"
)
// SSEEvent is one event decoded from a server-sent event stream.
type SSEEvent struct {
ID string
Event string
Data []byte
Retry string
}
// SSEReader parses server-sent events from an input stream.
type SSEReader struct {
reader *bufio.Reader
}
// NewSSEReader creates an SSE reader for r.
func NewSSEReader(r io.Reader) *SSEReader {
return &SSEReader{
reader: bufio.NewReaderSize(r, 64*1024),
}
}
// Next reads and returns the next SSE event. It returns io.EOF when the input
// ends without another event and checks ctx between blocking reads.
func (r *SSEReader) Next(ctx context.Context) (SSEEvent, error) {
var event SSEEvent
var dataLines []string
for {
if err := ctx.Err(); err != nil {
return SSEEvent{}, err
}
line, err := r.reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return SSEEvent{}, fmt.Errorf(
"read SSE stream: %w",
err,
)
}
line = strings.TrimSuffix(line, "\n")
line = strings.TrimSuffix(line, "\r")
if line == "" {
if len(dataLines) == 0 &&
event.ID == "" &&
event.Event == "" &&
event.Retry == "" {
if errors.Is(err, io.EOF) {
return SSEEvent{}, io.EOF
}
continue
}
event.Data = []byte(
strings.Join(dataLines, "\n"),
)
return event, nil
}
// Строки-комментарии SSE.
if strings.HasPrefix(line, ":") {
if errors.Is(err, io.EOF) {
return SSEEvent{}, io.EOF
}
continue
}
field, value, found := strings.Cut(line, ":")
if found && strings.HasPrefix(value, " ") {
value = value[1:]
}
switch field {
case "event":
event.Event = value
case "data":
dataLines = append(dataLines, value)
case "id":
event.ID = value
case "retry":
event.Retry = value
}
if errors.Is(err, io.EOF) {
if len(dataLines) > 0 ||
event.ID != "" ||
event.Event != "" ||
event.Retry != "" {
event.Data = []byte(
strings.Join(dataLines, "\n"),
)
return event, nil
}
return SSEEvent{}, io.EOF
}
}
}
// ReadyEvent identifies the request and response messages for a stream.
type ReadyEvent struct {
RequestMessageID int64 `json:"request_message_id"`
ResponseMessageID int64 `json:"response_message_id"`
ModelType string `json:"model_type"`
}
// UpdateSessionEvent reports the latest session update timestamp.
type UpdateSessionEvent struct {
UpdatedAt float64 `json:"updated_at"`
}
// TitleEvent contains a generated chat title.
type TitleEvent struct {
Content string `json:"content"`
}
// CloseEvent describes how a completed stream should be closed or resumed.
type CloseEvent struct {
ClickBehavior string `json:"click_behavior"`
AutoResume bool `json:"auto_resume"`
}
// PatchEvent describes a compact patch in the DeepSeek stream protocol.
//
// Pointer fields distinguish:
//
// {"v":"text"}
//
// from:
//
// {"p":"","o":"","v":"text"}
//
// This distinction is required to inherit the previous path and operation.
type PatchEvent struct {
Path *string `json:"p,omitempty"`
Operation *string `json:"o,omitempty"`
Value json.RawMessage `json:"v"`
}
// ResponseFragment is one typed content fragment in a response snapshot.
type ResponseFragment struct {
ID int64 `json:"id"`
Type string `json:"type"`
Content string `json:"content"`
References []json.RawMessage `json:"references"`
StageID int64 `json:"stage_id"`
}
// ResponseSnapshot is a complete response state delivered by the stream.
type ResponseSnapshot struct {
MessageID int64 `json:"message_id"`
ParentID int64 `json:"parent_id"`
Model string `json:"model"`
Role string `json:"role"`
ThinkingEnabled bool `json:"thinking_enabled"`
Status string `json:"status"`
QuasiStatus string `json:"quasi_status"`
AccumulatedTokenUsage int64 `json:"accumulated_token_usage"`
Content string `json:"content"`
ThinkingContent *string `json:"thinking_content"`
Fragments []ResponseFragment `json:"fragments"`
}
// SnapshotEvent wraps a complete response snapshot.
type SnapshotEvent struct {
Response *ResponseSnapshot `json:"response"`
}
// StreamState is the accumulated state of a completion stream.
// Content and ThinkingContent must not be copied after their first use.
type StreamState struct {
RequestMessageID int64
ResponseMessageID int64
Content strings.Builder
ThinkingContent strings.Builder
Status string
TokenUsage int64
Title string
SessionUpdated float64
Finished bool
Closed bool
lastPath string
lastOp string
}
// Apply incorporates event into s.
func (s *StreamState) Apply(event SSEEvent) error {
switch event.Event {
case "ready":
return s.applyReady(event.Data)
case "update_session":
return s.applyUpdateSession(event.Data)
case "title":
return s.applyTitle(event.Data)
case "finish":
s.Finished = true
return nil
case "close":
s.Closed = true
return nil
}
if len(bytes.TrimSpace(event.Data)) == 0 {
return nil
}
var patch PatchEvent
if err := json.Unmarshal(event.Data, &patch); err != nil {
return fmt.Errorf(
"decode stream patch: %w; data=%s",
err,
event.Data,
)
}
if len(patch.Value) == 0 {
return nil
}
/*
Snapshot имеет вид:
{
"v": {
"response": {
...
}
}
}
У него отсутствуют и p, и o. Проверять snapshot нужно до
наследования lastPath/lastOp, иначе он может быть принят
за продолжение предыдущего patch.
*/
if patch.Path == nil && patch.Operation == nil {
isSnapshot, err := s.tryApplySnapshot(patch.Value)
if err != nil {
return err
}
if isSnapshot {
return nil
}
}
path, operation := s.resolvePatchLocation(patch)
return s.applyPatch(
path,
operation,
patch.Value,
)
}
func (s *StreamState) applyReady(data []byte) error {
var value ReadyEvent
if err := json.Unmarshal(data, &value); err != nil {
return fmt.Errorf("decode ready: %w", err)
}
s.RequestMessageID = value.RequestMessageID
s.ResponseMessageID = value.ResponseMessageID
return nil
}
func (s *StreamState) applyUpdateSession(data []byte) error {
var value UpdateSessionEvent
if err := json.Unmarshal(data, &value); err != nil {
return fmt.Errorf(
"decode update_session: %w",
err,
)
}
s.SessionUpdated = value.UpdatedAt
return nil
}
func (s *StreamState) applyTitle(data []byte) error {
var value TitleEvent
if err := json.Unmarshal(data, &value); err != nil {
return fmt.Errorf("decode title: %w", err)
}
s.Title = value.Content
return nil
}
func (s *StreamState) resolvePatchLocation(
patch PatchEvent,
) (path string, operation string) {
switch {
/*
Пришёл новый путь.
Если операция отсутствует, она не должна наследовать старый
APPEND. Для этого сохраняем lastOp как пустую строку.
*/
case patch.Path != nil:
path = *patch.Path
s.lastPath = path
if patch.Operation != nil {
operation = *patch.Operation
} else {
operation = ""
}
s.lastOp = operation
/*
Операция изменилась, а путь остался прежним.
*/
case patch.Operation != nil:
path = s.lastPath
operation = *patch.Operation
s.lastOp = operation
/*
Сокращённое событие:
{"v":"текст"}
Наследует и путь, и операцию.
*/
default:
path = s.lastPath
operation = s.lastOp
}
return path, operation
}
func (s *StreamState) tryApplySnapshot(
value json.RawMessage,
) (bool, error) {
var snapshot SnapshotEvent
if err := json.Unmarshal(value, &snapshot); err != nil {
// Значение может быть обычной строкой, числом и т. п.
// В таком случае это не snapshot.
return false, nil
}
if snapshot.Response == nil {
return false, nil
}
s.applyResponseSnapshot(*snapshot.Response)
return true, nil
}
func (s *StreamState) applyResponseSnapshot(
response ResponseSnapshot,
) {
if response.MessageID != 0 {
s.ResponseMessageID = response.MessageID
}
s.Status = response.Status
s.TokenUsage = response.AccumulatedTokenUsage
s.Content.Reset()
s.ThinkingContent.Reset()
if response.Content != "" {
s.Content.WriteString(response.Content)
}
if response.ThinkingContent != nil {
s.ThinkingContent.WriteString(
*response.ThinkingContent,
)
}
for _, fragment := range response.Fragments {
switch fragment.Type {
case "RESPONSE":
s.Content.WriteString(fragment.Content)
case "THINKING":
s.ThinkingContent.WriteString(fragment.Content)
}
}
if response.QuasiStatus != "" {
s.Status = response.QuasiStatus
}
}
func (s *StreamState) applyPatch(
path string,
operation string,
value json.RawMessage,
) error {
switch path {
case "response/content",
"response/fragments/-1/content":
return applyStringPatch(
&s.Content,
operation,
value,
)
case "response/thinking_content",
"response/fragments/-1/thinking_content":
return applyStringPatch(
&s.ThinkingContent,
operation,
value,
)
case "response/accumulated_token_usage":
if err := json.Unmarshal(value, &s.TokenUsage); err != nil {
return fmt.Errorf(
"decode token usage: %w",
err,
)
}
case "response/status",
"response/quasi_status":
if err := json.Unmarshal(value, &s.Status); err != nil {
return fmt.Errorf(
"decode response status: %w",
err,
)
}
if s.Status == "FINISHED" {
s.Finished = true
}
case "response":
if operation != "BATCH" {
log.Printf(
"unknown response operation: op=%q value=%s",
operation,
value,
)
return nil
}
if err := s.applyBatch(value); err != nil {
return err
}
default:
log.Printf(
"unknown patch: path=%q op=%q value=%s",
path,
operation,
value,
)
}
return nil
}
func (s *StreamState) applyBatch(
value json.RawMessage,
) error {
var batch []PatchEvent
if err := json.Unmarshal(value, &batch); err != nil {
return fmt.Errorf("decode batch: %w", err)
}
for _, item := range batch {
if len(item.Value) == 0 {
continue
}
itemPath := ""
if item.Path != nil {
itemPath = *item.Path
}
itemOperation := ""
if item.Operation != nil {
itemOperation = *item.Operation
}
/*
Внутри response/BATCH пути относительные:
accumulated_token_usage
quasi_status
Преобразуем их в абсолютные.
*/
if itemPath != "" &&
!strings.HasPrefix(itemPath, "response/") {
itemPath = "response/" + itemPath
}
if err := s.applyPatch(
itemPath,
itemOperation,
item.Value,
); err != nil {
return err
}
}
return nil
}
func applyStringPatch(
builder *strings.Builder,
operation string,
value json.RawMessage,
) error {
var text string
if err := json.Unmarshal(value, &text); err != nil {
return fmt.Errorf(
"decode string patch: %w",
err,
)
}
switch operation {
case "", "APPEND":
builder.WriteString(text)
case "SET":
builder.Reset()
builder.WriteString(text)
default:
return fmt.Errorf(
"unsupported string patch operation %q",
operation,
)
}
return nil
}