sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"ai-operator/internal/ai"
|
||||
"ai-operator/internal/audio"
|
||||
"ai-operator/internal/config"
|
||||
"ai-operator/internal/media"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/gorilla/websocket"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
conn *websocket.Conn
|
||||
events chan ai.VoiceEvent
|
||||
mu sync.Mutex
|
||||
stats ai.VoiceProviderStats
|
||||
inputBytes, outputBytes int64
|
||||
eventsClosed bool
|
||||
}
|
||||
|
||||
func NewProvider(cfg config.Config, logger *slog.Logger) *Provider {
|
||||
return &Provider{cfg: cfg, logger: logger, events: make(chan ai.VoiceEvent, 64)}
|
||||
}
|
||||
func (p *Provider) StartSession(ctx context.Context, sc ai.VoiceSessionConfig) error {
|
||||
if p.cfg.OpenAI.APIKey == "" {
|
||||
return errors.New("OPENAI_API_KEY is required for OpenAI Realtime")
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.events = make(chan ai.VoiceEvent, 64)
|
||||
p.eventsClosed = false
|
||||
p.stats = ai.VoiceProviderStats{}
|
||||
p.inputBytes = 0
|
||||
p.outputBytes = 0
|
||||
p.mu.Unlock()
|
||||
u, _, err := BuildURL(p.cfg.OpenAI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h := http.Header{}
|
||||
h.Set("Authorization", "Bearer "+p.cfg.OpenAI.APIKey)
|
||||
h.Set("OpenAI-Safety-Identifier", safetyID(sc.CallID))
|
||||
d := websocket.Dialer{HandshakeTimeout: p.cfg.OpenAI.RealtimeConnectTimeout}
|
||||
conn, _, err := d.DialContext(ctx, u, h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.conn = conn
|
||||
now := time.Now().UTC()
|
||||
p.mu.Lock()
|
||||
p.stats.StartedAt = &now
|
||||
p.mu.Unlock()
|
||||
if err := p.waitFor(ctx, "session.created"); err != nil {
|
||||
return err
|
||||
}
|
||||
instr := sc.SystemPrompt
|
||||
if instr == "" {
|
||||
instr = "You are a test voice provider adapter for an AI operator. For now, keep responses very short. Do not claim to access customer data. Do not answer business questions yet."
|
||||
}
|
||||
msg, err := BuildSessionUpdate(p.cfg.OpenAI, instr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = p.waitFor(ctx, "session.updated")
|
||||
go p.readLoop(sc.CallID)
|
||||
if p.cfg.OpenAI.RealtimeInitialGreeting {
|
||||
greeting := "Say exactly this short natural greeting in a calm, natural contact-center voice, without asking to choose language or region: Здравствуйте, меня зовут Жанна. Я AI-оператор QazAimaqGas. Чем могу помочь?"
|
||||
rc, err := BuildResponseCreateWithInstructions(greeting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, rc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (p *Provider) waitFor(ctx context.Context, want string) error {
|
||||
deadline := time.After(p.cfg.OpenAI.RealtimeConnectTimeout)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline:
|
||||
return errors.New("timeout waiting for " + want)
|
||||
default:
|
||||
_, data, err := p.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var m map[string]any
|
||||
_ = jsonUnmarshal(data, &m)
|
||||
if toStr(m["type"]) == want {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func (p *Provider) readLoop(callID string) {
|
||||
defer p.closeEvents()
|
||||
for {
|
||||
_, data, err := p.conn.ReadMessage()
|
||||
if err != nil {
|
||||
p.emit(ai.VoiceEvent{Type: ai.VoiceEventClosed, CallID: callID, At: time.Now().UTC()})
|
||||
return
|
||||
}
|
||||
ev, _, err := ParseServerEvent(data)
|
||||
if err != nil {
|
||||
p.emit(ai.VoiceEvent{Type: ai.VoiceEventError, CallID: callID, Error: err.Error(), At: time.Now().UTC()})
|
||||
continue
|
||||
}
|
||||
if ev.Type != "" {
|
||||
ev.CallID = callID
|
||||
p.emit(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (p *Provider) SendAudio(ctx context.Context, ch media.AudioChunk) error {
|
||||
if p.conn == nil {
|
||||
return errors.New("openai realtime websocket not connected")
|
||||
}
|
||||
if !audio.IsPCM16Aligned(ch.Data) {
|
||||
return errors.New("pcm16 audio is not aligned")
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.inputBytes += int64(len(ch.Data))
|
||||
if p.inputBytes > int64(p.cfg.OpenAI.RealtimeMaxInputAudioBytes) {
|
||||
p.mu.Unlock()
|
||||
return errors.New("max input audio bytes exceeded")
|
||||
}
|
||||
p.stats.InputAudioFrames++
|
||||
p.stats.InputAudioBytes += int64(len(ch.Data))
|
||||
p.mu.Unlock()
|
||||
chunks := audio.ChunkBytes(ch.Data, p.cfg.OpenAI.RealtimeMaxAudioChunkBytes)
|
||||
for _, c := range chunks {
|
||||
msg, err := BuildAudioAppend(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.stats.EventsSent++
|
||||
p.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (p *Provider) SendToolResult(ctx context.Context, r ai.ToolResult) error {
|
||||
if p.conn == nil {
|
||||
return errors.New("openai realtime websocket not connected")
|
||||
}
|
||||
msg, err := BuildToolResult(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, _ := BuildResponseCreate()
|
||||
return p.conn.WriteMessage(websocket.TextMessage, rc)
|
||||
}
|
||||
func (p *Provider) Close(ctx context.Context) error {
|
||||
p.mu.Lock()
|
||||
now := time.Now().UTC()
|
||||
p.stats.ClosedAt = &now
|
||||
p.mu.Unlock()
|
||||
if p.conn != nil {
|
||||
_ = p.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return p.conn.Close()
|
||||
}
|
||||
p.closeEvents()
|
||||
return nil
|
||||
}
|
||||
func (p *Provider) Events() <-chan ai.VoiceEvent { return p.events }
|
||||
func (p *Provider) Stats() ai.VoiceProviderStats { p.mu.Lock(); defer p.mu.Unlock(); return p.stats }
|
||||
func (p *Provider) emit(e ai.VoiceEvent) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats.EventsReceived++
|
||||
if e.Type == ai.VoiceEventAssistantAudioDelta {
|
||||
p.outputBytes += int64(len(e.Audio))
|
||||
p.stats.OutputAudioFrames++
|
||||
p.stats.OutputAudioBytes += int64(len(e.Audio))
|
||||
}
|
||||
if e.Type == ai.VoiceEventToolCall {
|
||||
p.stats.ToolCallsReceived++
|
||||
}
|
||||
if e.Type == ai.VoiceEventError {
|
||||
p.stats.Errors++
|
||||
}
|
||||
if p.eventsClosed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case p.events <- e:
|
||||
default:
|
||||
}
|
||||
}
|
||||
func (p *Provider) closeEvents() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if !p.eventsClosed {
|
||||
close(p.events)
|
||||
p.eventsClosed = true
|
||||
}
|
||||
}
|
||||
func safetyID(callID string) string {
|
||||
sum := sha256.Sum256([]byte(callID))
|
||||
return "aiop-" + hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
Reference in New Issue
Block a user