feat(server): provider switching API, WS/REST cancel, summarize endpoints
This commit is contained in:
parent
f1b2093b22
commit
49fdc5a189
7 changed files with 463 additions and 28 deletions
|
|
@ -10,6 +10,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ import (
|
|||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/crypto/byteclaw/internal/agent"
|
||||
"github.com/crypto/byteclaw/internal/providers"
|
||||
"github.com/crypto/byteclaw/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -31,6 +33,8 @@ type Server struct {
|
|||
RunTimeout time.Duration
|
||||
// Info is returned by GET /api/info (never contains secrets).
|
||||
Info Info
|
||||
// Providers enables runtime provider switching (optional).
|
||||
Providers *providers.Manager
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
|
|
@ -44,7 +48,14 @@ func (s *Server) Handler() http.Handler {
|
|||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) })
|
||||
api := http.NewServeMux()
|
||||
api.HandleFunc("GET /api/info", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.Info) })
|
||||
api.HandleFunc("GET /api/info", s.info)
|
||||
api.HandleFunc("GET /api/providers", s.listProviders)
|
||||
api.HandleFunc("PUT /api/providers/active", s.setActiveProvider)
|
||||
api.HandleFunc("GET /api/providers/{name}/models", s.listModels)
|
||||
api.HandleFunc("PUT /api/sessions/{id}/provider", s.setSessionProvider)
|
||||
api.HandleFunc("POST /api/sessions/{id}/cancel", s.cancel)
|
||||
api.HandleFunc("POST /api/sessions/{id}/summarize", s.summarize)
|
||||
api.HandleFunc("GET /api/sessions/{id}/summary", s.getSummary)
|
||||
api.HandleFunc("GET /api/sessions", s.listSessions)
|
||||
api.HandleFunc("POST /api/sessions", s.createSession)
|
||||
api.HandleFunc("GET /api/sessions/{id}", s.getSession)
|
||||
|
|
@ -110,7 +121,7 @@ func (s *Server) originCheck(h http.Handler) http.Handler {
|
|||
w.Header().Set("Vary", "Origin")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
|
@ -252,6 +263,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
|||
ctx, cancel := context.WithTimeout(r.Context(), s.RunTimeout)
|
||||
defer cancel()
|
||||
reply, err := s.Agent.Run(ctx, id, req.Text, nil)
|
||||
if errors.Is(err, agent.ErrCancelled) {
|
||||
writeJSON(w, 200, map[string]any{"session_id": id, "reply": reply, "cancelled": true})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.Log.Warn("agent run", "err", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"session_id": id, "error": err.Error()})
|
||||
|
|
@ -260,8 +275,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, 200, map[string]string{"session_id": id, "reply": reply})
|
||||
}
|
||||
|
||||
// ws protocol: client sends {"type":"message","session_id":"...","text":"..."}
|
||||
// server streams agent.Event JSON objects ending with type "done" or "error".
|
||||
// ws protocol: client sends {"type":"message","session_id":"...","text":"..."};
|
||||
// the server streams agent.Event JSON objects ending with "done", "cancelled"
|
||||
// or "error". While an answer is streaming the client may send
|
||||
// {"type":"cancel","session_id":"..."} (or call POST /api/sessions/{id}/cancel).
|
||||
func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
|
||||
// Origin was already validated by originCheck; the library's own check is
|
||||
// therefore disabled here to avoid duplicating (and diverging from) policy.
|
||||
|
|
@ -271,37 +288,71 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
defer c.CloseNow()
|
||||
c.SetReadLimit(maxBody)
|
||||
ctx := r.Context()
|
||||
ctx, stop := context.WithCancel(r.Context())
|
||||
defer stop()
|
||||
var wmu sync.Mutex
|
||||
send := func(e agent.Event) {
|
||||
wmu.Lock()
|
||||
defer wmu.Unlock()
|
||||
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
wsjson.Write(wctx, c, e)
|
||||
}
|
||||
var running sync.WaitGroup
|
||||
defer running.Wait()
|
||||
busy := make(chan struct{}, 1) // one answer at a time per connection
|
||||
for {
|
||||
var req chatReq
|
||||
if err := wsjson.Read(ctx, c, &req); err != nil {
|
||||
stop() // connection gone: abort any running answer
|
||||
return
|
||||
}
|
||||
if req.Type == "ping" {
|
||||
wsjson.Write(ctx, c, agent.Event{Type: "pong"})
|
||||
switch req.Type {
|
||||
case "ping":
|
||||
send(agent.Event{Type: "pong"})
|
||||
continue
|
||||
case "cancel":
|
||||
if req.SessionID == "" || !s.Agent.Cancel(req.SessionID) {
|
||||
send(agent.Event{Type: "error", Text: "nothing to cancel", Session: req.SessionID})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
wsjson.Write(ctx, c, agent.Event{Type: "error", Text: "text required"})
|
||||
send(agent.Event{Type: "error", Text: "text required"})
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case busy <- struct{}{}:
|
||||
default:
|
||||
send(agent.Event{Type: "error", Text: "an answer is still streaming; cancel it first"})
|
||||
continue
|
||||
}
|
||||
id, err := s.ensureSession(ctx, req.SessionID, req.Text)
|
||||
if err != nil {
|
||||
wsjson.Write(ctx, c, agent.Event{Type: "error", Text: "unknown session"})
|
||||
<-busy
|
||||
send(agent.Event{Type: "error", Text: "unknown session"})
|
||||
continue
|
||||
}
|
||||
wsjson.Write(ctx, c, agent.Event{Type: "session", Session: id})
|
||||
runCtx, cancel := context.WithTimeout(ctx, s.RunTimeout)
|
||||
reply, err := s.Agent.Run(runCtx, id, req.Text, func(e agent.Event) {
|
||||
e.Session = id
|
||||
wsjson.Write(runCtx, c, e)
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.Log.Warn("agent run", "err", err)
|
||||
wsjson.Write(ctx, c, agent.Event{Type: "error", Text: err.Error(), Session: id})
|
||||
continue
|
||||
}
|
||||
wsjson.Write(ctx, c, agent.Event{Type: "done", Text: reply, Session: id})
|
||||
send(agent.Event{Type: "session", Session: id})
|
||||
running.Add(1)
|
||||
go func(id, text string) {
|
||||
defer running.Done()
|
||||
defer func() { <-busy }()
|
||||
runCtx, cancel := context.WithTimeout(ctx, s.RunTimeout)
|
||||
defer cancel()
|
||||
reply, err := s.Agent.Run(runCtx, id, text, func(e agent.Event) {
|
||||
e.Session = id
|
||||
send(e)
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, agent.ErrCancelled):
|
||||
send(agent.Event{Type: "cancelled", Text: reply, Session: id})
|
||||
case err != nil:
|
||||
s.Log.Warn("agent run", "err", err)
|
||||
send(agent.Event{Type: "error", Text: err.Error(), Session: id})
|
||||
default:
|
||||
send(agent.Event{Type: "done", Text: reply, Session: id})
|
||||
}
|
||||
}(id, req.Text)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue