feat(web-chat): Pico channel SSE fallback and proxy

This commit is contained in:
zz96 2026-03-24 09:57:14 +08:00
parent aa3300c1bd
commit ce7259693f
11 changed files with 995 additions and 170 deletions

View file

@ -26,7 +26,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) | | **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) |
| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | | | **Pico** | ⭐ 简单 | PicoClaw 原生协议通道(内置 Web 聊天) | [本节](#pico) |
--- ---
@ -663,3 +663,19 @@ picoclaw gateway
``` ```
</details> </details>
<a id="pico"></a>
<details>
<summary><b>Pico内置 Web 聊天)</b></summary>
Pico 是 PicoClaw 原生协议通道,用于自带 Web UI 与 Agent 对话。
* **主路径**:浏览器通过 **WebSocket** 连接 `GET /pico/ws`(由 Web 服务反代到 Gateway`ws_url` 一致)。
* **降级**:若 WebSocket 无法建立(超时或失败),前端会自动改用 **SSE** 接收推送:`GET /pico/events?session_id=...`,并使用 **`POST /pico/send`** 发送用户消息。两者均使用请求头 `Authorization: Bearer <token>`token 与 `GET /api/pico/token` 返回的相同;同一次响应中还会提供 `events_url``send_url` 供客户端使用。
* **反向代理**:若 Web UI 前有 **nginx** 等代理,请对 `/pico/events` **关闭响应缓冲**(例如 `proxy_buffering off;`),否则 SSE 可能被缓冲导致无法实时显示流式回复。
* **单会话连接**:同一 `session_id` 在服务端只保留 **一条** 实时下行连接(新的 WebSocket 或 SSE 会断开同会话的旧连接),避免重复推送;前端对相同 `message_id``message.create` 也会合并为一条展示。
启用方式见项目配置中的 `channels.pico`;运行 `picoclaw gateway` 并打开 Web 控制台即可使用内置聊天。
</details>

View file

@ -40,6 +40,20 @@ func (pc *picoConn) writeJSON(v any) error {
return pc.conn.WriteJSON(v) return pc.conn.WriteJSON(v)
} }
// ID returns the connection id (picoSubscriber).
func (pc *picoConn) ID() string { return pc.id }
// SessionID returns the bound session (picoSubscriber).
func (pc *picoConn) SessionID() string { return pc.sessionID }
// Deliver sends a Pico message over the WebSocket (picoSubscriber).
func (pc *picoConn) Deliver(msg PicoMessage) error {
return pc.writeJSON(msg)
}
// Close shuts down the WebSocket (picoSubscriber).
func (pc *picoConn) Close() { pc.close() }
// close closes the connection. // close closes the connection.
func (pc *picoConn) close() { func (pc *picoConn) close() {
if pc.closed.CompareAndSwap(false, true) { if pc.closed.CompareAndSwap(false, true) {
@ -50,13 +64,13 @@ func (pc *picoConn) close() {
} }
} }
// PicoChannel implements the native Pico Protocol WebSocket channel. // PicoChannel implements the native Pico Protocol channel (WebSocket plus HTTP SSE fallback).
// It serves as the reference implementation for all optional capability interfaces. // It serves as the reference implementation for all optional capability interfaces.
type PicoChannel struct { type PicoChannel struct {
*channels.BaseChannel *channels.BaseChannel
config config.PicoConfig config config.PicoConfig
upgrader websocket.Upgrader upgrader websocket.Upgrader
connections sync.Map // connID → *picoConn subscribers sync.Map // connID → picoSubscriber (*picoConn or *picoSSEConn)
connCount atomic.Int32 connCount atomic.Int32
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@ -109,12 +123,12 @@ func (c *PicoChannel) Stop(ctx context.Context) error {
logger.InfoC("pico", "Stopping Pico Protocol channel") logger.InfoC("pico", "Stopping Pico Protocol channel")
c.SetRunning(false) c.SetRunning(false)
// Close all connections // Close all subscribers (WebSocket + SSE)
c.connections.Range(func(key, value any) bool { c.subscribers.Range(func(key, value any) bool {
if pc, ok := value.(*picoConn); ok { if sub, ok := value.(picoSubscriber); ok {
pc.close() sub.Close()
} }
c.connections.Delete(key) c.subscribers.Delete(key)
return true return true
}) })
@ -136,6 +150,18 @@ func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch { switch {
case path == "/ws" || path == "/ws/": case path == "/ws" || path == "/ws/":
c.handleWebSocket(w, r) c.handleWebSocket(w, r)
case path == "/events" || path == "/events/":
if r.Method == http.MethodGet {
c.handleSSE(w, r)
} else {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
case path == "/send" || path == "/send/":
if r.Method == http.MethodPost {
c.handlePostSend(w, r)
} else {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -208,21 +234,22 @@ func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
msg.SessionID = sessionID msg.SessionID = sessionID
var sent bool var sent bool
c.connections.Range(func(key, value any) bool { c.subscribers.Range(func(key, value any) bool {
pc, ok := value.(*picoConn) sub, ok := value.(picoSubscriber)
if !ok { if !ok {
return true return true
} }
if pc.sessionID == sessionID { if sub.SessionID() != sessionID {
if err := pc.writeJSON(msg); err != nil { return true
logger.DebugCF("pico", "Write to connection failed", map[string]any{ }
"conn_id": pc.id, if err := sub.Deliver(msg); err != nil {
logger.DebugCF("pico", "Write to subscriber failed", map[string]any{
"conn_id": sub.ID(),
"error": err.Error(), "error": err.Error(),
}) })
} else { } else {
sent = true sent = true
} }
}
return true return true
}) })
@ -232,6 +259,25 @@ func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
return nil return nil
} }
// disconnectSubscribersForSession closes and removes every subscriber for the given session so
// a new WebSocket or SSE connection does not overlap with the previous one (avoids duplicate pushes).
func (c *PicoChannel) disconnectSubscribersForSession(sessionID string) {
c.subscribers.Range(func(key, value any) bool {
sub, ok := value.(picoSubscriber)
if !ok || sub.SessionID() != sessionID {
return true
}
c.subscribers.Delete(key)
c.connCount.Add(-1)
sub.Close()
logger.InfoCF("pico", "Disconnected prior subscriber (single session)", map[string]any{
"conn_id": sub.ID(),
"session_id": sessionID,
})
return true
})
}
// handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle. // handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle.
func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
if !c.IsRunning() { if !c.IsRunning() {
@ -245,6 +291,13 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
return return
} }
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
sessionID = uuid.New().String()
}
// Drop any prior connection for this session before enforcing the global cap.
c.disconnectSubscribersForSession(sessionID)
// Check connection limit // Check connection limit
maxConns := c.config.MaxConnections maxConns := c.config.MaxConnections
if maxConns <= 0 { if maxConns <= 0 {
@ -269,19 +322,13 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
return return
} }
// Determine session ID from query param or generate one
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
sessionID = uuid.New().String()
}
pc := &picoConn{ pc := &picoConn{
id: uuid.New().String(), id: uuid.New().String(),
conn: conn, conn: conn,
sessionID: sessionID, sessionID: sessionID,
} }
c.connections.Store(pc.id, pc) c.subscribers.Store(pc.id, pc)
c.connCount.Add(1) c.connCount.Add(1)
logger.InfoCF("pico", "WebSocket client connected", map[string]any{ logger.InfoCF("pico", "WebSocket client connected", map[string]any{
@ -341,8 +388,9 @@ func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
func (c *PicoChannel) readLoop(pc *picoConn) { func (c *PicoChannel) readLoop(pc *picoConn) {
defer func() { defer func() {
pc.close() pc.close()
c.connections.Delete(pc.id) if _, loaded := c.subscribers.LoadAndDelete(pc.id); loaded {
c.connCount.Add(-1) c.connCount.Add(-1)
}
logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{ logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{
"conn_id": pc.id, "conn_id": pc.id,
"session_id": pc.sessionID, "session_id": pc.sessionID,
@ -452,6 +500,11 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
sessionID = pc.sessionID sessionID = pc.sessionID
} }
c.publishUserMessage(pc.id, sessionID, msg.ID, content)
}
// publishUserMessage forwards validated user text into the message bus.
func (c *PicoChannel) publishUserMessage(connID, sessionID, msgID, content string) {
chatID := "pico:" + sessionID chatID := "pico:" + sessionID
senderID := "pico-user" senderID := "pico-user"
@ -460,7 +513,7 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
metadata := map[string]string{ metadata := map[string]string{
"platform": "pico", "platform": "pico",
"session_id": sessionID, "session_id": sessionID,
"conn_id": pc.id, "conn_id": connID,
} }
logger.DebugCF("pico", "Received message", map[string]any{ logger.DebugCF("pico", "Received message", map[string]any{
@ -478,7 +531,7 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
return return
} }
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender) c.HandleMessage(c.ctx, peer, msgID, senderID, chatID, content, nil, metadata, sender)
} }
// truncate truncates a string to maxLen runes. // truncate truncates a string to maxLen runes.

View file

@ -0,0 +1,224 @@
package pico
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestPicoChannel_PostSend_Unauthorized(t *testing.T) {
t.Parallel()
ch, err := NewPicoChannel(config.PicoConfig{Token: "tok"}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := ch.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ch.Stop(ctx) })
body := `{"type":"message.send","session_id":"s1","id":"1","payload":{"content":"hi"}}`
req := httptest.NewRequest(http.MethodPost, "/pico/send", strings.NewReader(body))
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d, want 401", rec.Code)
}
}
func TestPicoChannel_PostSend_EmptyContent(t *testing.T) {
t.Parallel()
ch, err := NewPicoChannel(config.PicoConfig{Token: "tok"}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := ch.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ch.Stop(ctx) })
body := `{"type":"message.send","session_id":"s1","id":"1","payload":{"content":" "}}`
req := httptest.NewRequest(http.MethodPost, "/pico/send", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", rec.Code)
}
var m map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
t.Fatal(err)
}
if m["error"] != "empty_content" {
t.Fatalf("error = %q, want empty_content", m["error"])
}
}
func TestPicoChannel_PostSend_MissingSession(t *testing.T) {
t.Parallel()
ch, err := NewPicoChannel(config.PicoConfig{Token: "tok"}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := ch.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ch.Stop(ctx) })
body := `{"type":"message.send","id":"1","payload":{"content":"hi"}}`
req := httptest.NewRequest(http.MethodPost, "/pico/send", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", rec.Code)
}
}
func TestPicoChannel_PostSend_OK(t *testing.T) {
t.Parallel()
ch, err := NewPicoChannel(config.PicoConfig{Token: "tok"}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := ch.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ch.Stop(ctx) })
body := `{"type":"message.send","session_id":"s1","id":"m1","payload":{"content":"hello"}}`
req := httptest.NewRequest(http.MethodPost, "/pico/send", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("code = %d, want 204", rec.Code)
}
}
func TestPicoChannel_GetEvents_Unauthorized(t *testing.T) {
t.Parallel()
ch, err := NewPicoChannel(config.PicoConfig{Token: "tok"}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := ch.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ch.Stop(ctx) })
req := httptest.NewRequest(http.MethodGet, "/pico/events?session_id=s1", nil)
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d, want 401", rec.Code)
}
}
func TestPicoChannel_SSE_ReceivesBroadcast(t *testing.T) {
ch, err := NewPicoChannel(config.PicoConfig{Token: "tok"}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := ch.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ch.Stop(ctx) })
ctxReq, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodGet, "/pico/events?session_id=sse-s1", nil)
req = req.WithContext(ctxReq)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ch.ServeHTTP(rec, req)
}()
time.Sleep(200 * time.Millisecond)
if err := ch.Send(ctx, bus.OutboundMessage{ChatID: "pico:sse-s1", Content: "broadcast-body"}); err != nil {
t.Fatalf("Send: %v", err)
}
time.Sleep(150 * time.Millisecond)
cancel()
wg.Wait()
out := rec.Body.String()
if !strings.Contains(out, "broadcast-body") {
t.Fatalf("expected outbound in body, got: %q", out)
}
if !strings.Contains(out, "message.create") {
t.Fatalf("expected message type in stream, got: %q", out)
}
}
func TestPicoChannel_SSE_SecondConnectionSameSessionReplacesFirst(t *testing.T) {
ch, err := NewPicoChannel(config.PicoConfig{Token: "tok"}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := ch.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ch.Stop(ctx) })
const sess = "shared-sse"
ctx1, cancel1 := context.WithCancel(context.Background())
req1 := httptest.NewRequest(http.MethodGet, "/pico/events?session_id="+sess, nil).WithContext(ctx1)
req1.Header.Set("Authorization", "Bearer tok")
rec1 := httptest.NewRecorder()
go ch.ServeHTTP(rec1, req1)
time.Sleep(200 * time.Millisecond)
ctx2, cancel2 := context.WithCancel(context.Background())
req2 := httptest.NewRequest(http.MethodGet, "/pico/events?session_id="+sess, nil).WithContext(ctx2)
req2.Header.Set("Authorization", "Bearer tok")
rec2 := httptest.NewRecorder()
var wg2 sync.WaitGroup
wg2.Add(1)
go func() {
defer wg2.Done()
ch.ServeHTTP(rec2, req2)
}()
time.Sleep(200 * time.Millisecond)
if err := ch.Send(ctx, bus.OutboundMessage{ChatID: "pico:" + sess, Content: "only-once"}); err != nil {
t.Fatalf("Send: %v", err)
}
time.Sleep(150 * time.Millisecond)
n1 := strings.Count(rec1.Body.String(), "only-once")
n2 := strings.Count(rec2.Body.String(), "only-once")
if n2 != 1 {
t.Fatalf("second SSE should receive exactly one broadcast, got n2=%d", n2)
}
if n1 != 0 {
t.Fatalf("first SSE should be replaced and not receive broadcast, got n1=%d", n1)
}
cancel2()
wg2.Wait()
cancel1()
time.Sleep(100 * time.Millisecond)
}

View file

@ -0,0 +1,221 @@
package pico
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/sipeed/picoclaw/pkg/logger"
)
// picoSubscriber is a WebSocket or SSE client registered for outbound Pico messages.
type picoSubscriber interface {
ID() string
SessionID() string
Deliver(msg PicoMessage) error
Close()
}
// picoSSEConn streams outbound messages as Server-Sent Events.
type picoSSEConn struct {
id string
sessionID string
w http.ResponseWriter
rc *http.ResponseController
writeMu sync.Mutex
closed atomic.Bool
cancel context.CancelFunc
}
func (s *picoSSEConn) ID() string { return s.id }
func (s *picoSSEConn) SessionID() string { return s.sessionID }
func (s *picoSSEConn) Deliver(msg PicoMessage) error {
if s.closed.Load() {
return fmt.Errorf("connection closed")
}
b, err := json.Marshal(msg)
if err != nil {
return err
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
if s.closed.Load() {
return fmt.Errorf("connection closed")
}
if _, err := fmt.Fprintf(s.w, "data: %s\n\n", b); err != nil {
return err
}
return s.rc.Flush()
}
func (s *picoSSEConn) Close() {
s.shutdown()
}
func (s *picoSSEConn) shutdown() {
if s.closed.CompareAndSwap(false, true) {
if s.cancel != nil {
s.cancel()
}
}
}
func (c *PicoChannel) handleSSE(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !c.IsRunning() {
http.Error(w, "channel not running", http.StatusServiceUnavailable)
return
}
if !c.authenticate(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
sessionID = uuid.New().String()
}
c.disconnectSubscribersForSession(sessionID)
maxConns := c.config.MaxConnections
if maxConns <= 0 {
maxConns = 100
}
if int(c.connCount.Load()) >= maxConns {
http.Error(w, "too many connections", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
id := uuid.New().String()
ctx, cancel := context.WithCancel(r.Context())
sse := &picoSSEConn{
id: id,
sessionID: sessionID,
w: w,
rc: rc,
cancel: cancel,
}
c.subscribers.Store(id, sse)
c.connCount.Add(1)
defer func() {
sse.shutdown()
if _, loaded := c.subscribers.LoadAndDelete(id); loaded {
c.connCount.Add(-1)
}
logger.InfoCF("pico", "SSE client disconnected", map[string]any{
"conn_id": id,
"session_id": sessionID,
})
}()
logger.InfoCF("pico", "SSE client connected", map[string]any{
"conn_id": id,
"session_id": sessionID,
})
ready, err := json.Marshal(map[string]string{"conn_id": id, "session_id": sessionID})
if err != nil {
return
}
sse.writeMu.Lock()
_, werr := fmt.Fprintf(w, "event: ready\ndata: %s\n\n", ready)
sse.writeMu.Unlock()
if werr != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
pingInterval := time.Duration(c.config.PingInterval) * time.Second
if pingInterval <= 0 {
pingInterval = 30 * time.Second
}
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ctx.Done():
return
case <-ticker.C:
sse.writeMu.Lock()
if sse.closed.Load() {
sse.writeMu.Unlock()
return
}
_, werr := fmt.Fprintf(w, ": ping\n\n")
sse.writeMu.Unlock()
if werr != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
}
}
}
func (c *PicoChannel) handlePostSend(w http.ResponseWriter, r *http.Request) {
if !c.IsRunning() {
http.Error(w, "channel not running", http.StatusServiceUnavailable)
return
}
if !c.authenticate(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var msg PicoMessage
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
writePicoSendError(w, http.StatusBadRequest, "invalid_json")
return
}
if msg.Type != TypeMessageSend {
writePicoSendError(w, http.StatusBadRequest, "unsupported_type")
return
}
content, _ := msg.Payload["content"].(string)
if strings.TrimSpace(content) == "" {
writePicoSendError(w, http.StatusBadRequest, "empty_content")
return
}
sessionID := strings.TrimSpace(msg.SessionID)
if sessionID == "" {
writePicoSendError(w, http.StatusBadRequest, "session_id_required")
return
}
connID := "http-" + uuid.New().String()
c.publishUserMessage(connID, sessionID, msg.ID, content)
w.WriteHeader(http.StatusNoContent)
}
func writePicoSendError(w http.ResponseWriter, code int, errCode string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": errCode})
}

View file

@ -22,6 +22,9 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
// This allows the frontend to connect via the same port as the web UI, // This allows the frontend to connect via the same port as the web UI,
// avoiding the need to expose extra ports for WebSocket communication. // avoiding the need to expose extra ports for WebSocket communication.
mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy()) mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy())
// SSE and HTTP send fallback (same gateway Pico channel).
mux.HandleFunc("GET /pico/events", h.handleGatewayProxy())
mux.HandleFunc("POST /pico/send", h.handleGatewayProxy())
} }
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. // createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
@ -37,6 +40,11 @@ func (h *Handler) createWsProxy() *httputil.ReverseProxy {
// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. // handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections.
// The reverse proxy forwards the incoming upgrade handshake as-is. // The reverse proxy forwards the incoming upgrade handshake as-is.
func (h *Handler) handleWebSocketProxy() http.HandlerFunc { func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
return h.handleGatewayProxy()
}
// handleGatewayProxy forwards /pico/ws, /pico/events, and /pico/send to the gateway HTTP server.
func (h *Handler) handleGatewayProxy() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
proxy := h.createWsProxy() proxy := h.createWsProxy()
proxy.ServeHTTP(w, r) proxy.ServeHTTP(w, r)
@ -54,11 +62,15 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
} }
wsURL := h.buildWsURL(r, cfg) wsURL := h.buildWsURL(r, cfg)
eventsURL := h.buildPicoEventsURL(r, cfg)
sendURL := h.buildPicoSendURL(r, cfg)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
"token": cfg.Channels.Pico.Token(), "token": cfg.Channels.Pico.Token,
"ws_url": wsURL, "ws_url": wsURL,
"events_url": eventsURL,
"send_url": sendURL,
"enabled": cfg.Channels.Pico.Enabled, "enabled": cfg.Channels.Pico.Enabled,
}) })
} }
@ -82,11 +94,15 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
} }
wsURL := h.buildWsURL(r, cfg) wsURL := h.buildWsURL(r, cfg)
eventsURL := h.buildPicoEventsURL(r, cfg)
sendURL := h.buildPicoSendURL(r, cfg)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
"token": token, "token": token,
"ws_url": wsURL, "ws_url": wsURL,
"events_url": eventsURL,
"send_url": sendURL,
}) })
} }
@ -147,11 +163,15 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
} }
wsURL := h.buildWsURL(r, cfg) wsURL := h.buildWsURL(r, cfg)
eventsURL := h.buildPicoEventsURL(r, cfg)
sendURL := h.buildPicoSendURL(r, cfg)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
"token": cfg.Channels.Pico.Token(), "token": cfg.Channels.Pico.Token,
"ws_url": wsURL, "ws_url": wsURL,
"events_url": eventsURL,
"send_url": sendURL,
"enabled": true, "enabled": true,
"changed": changed, "changed": changed,
}) })

View file

@ -3,12 +3,16 @@
interface PicoTokenResponse { interface PicoTokenResponse {
token: string token: string
ws_url: string ws_url: string
events_url?: string
send_url?: string
enabled: boolean enabled: boolean
} }
interface PicoSetupResponse { interface PicoSetupResponse {
token: string token: string
ws_url: string ws_url: string
events_url?: string
send_url?: string
enabled: boolean enabled: boolean
changed: boolean changed: boolean
} }

View file

@ -79,9 +79,9 @@ export function ChatPage() {
} }
}, [messages, isTyping, isAtBottom]) }, [messages, isTyping, isAtBottom])
const handleSend = () => { const handleSend = async () => {
if (!input.trim() || !canSend) return if (!input.trim() || !canSend) return
if (sendMessage(input.trim())) { if (await sendMessage(input.trim())) {
setInput("") setInput("")
} }
} }

View file

@ -1,12 +1,14 @@
import { getDefaultStore } from "jotai" import { getDefaultStore } from "jotai"
import { toast } from "sonner" import { toast } from "sonner"
import { launcherFetch } from "@/api/http"
import { getPicoToken } from "@/api/pico" import { getPicoToken } from "@/api/pico"
import { import {
loadSessionMessages, loadSessionMessages,
mergeHistoryMessages, mergeHistoryMessages,
} from "@/features/chat/history" } from "@/features/chat/history"
import { type PicoMessage, handlePicoMessage } from "@/features/chat/protocol" import { type PicoMessage, handlePicoMessage } from "@/features/chat/protocol"
import { parsePicoSSEData, readPicoSSEStream } from "@/features/chat/sse"
import { import {
clearStoredSessionId, clearStoredSessionId,
generateSessionId, generateSessionId,
@ -23,6 +25,8 @@ import { type GatewayState, gatewayAtom } from "@/store/gateway"
const store = getDefaultStore() const store = getDefaultStore()
const WS_CONNECT_TIMEOUT_MS = 8000
let wsRef: WebSocket | null = null let wsRef: WebSocket | null = null
let isConnecting = false let isConnecting = false
let msgIdCounter = 0 let msgIdCounter = 0
@ -35,6 +39,12 @@ let reconnectTimer: number | null = null
let reconnectAttempts = 0 let reconnectAttempts = 0
let shouldMaintainConnection = false let shouldMaintainConnection = false
/** SSE long-poll stream active (fetch returned 200 and body is being read). */
let sseActive = false
let sseAbort: AbortController | null = null
let picoTokenRef: string | null = null
let picoSendUrlRef: string | null = null
function clearReconnectTimer() { function clearReconnectTimer() {
if (reconnectTimer !== null) { if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer) window.clearTimeout(reconnectTimer)
@ -42,6 +52,14 @@ function clearReconnectTimer() {
} }
} }
function stopSSE() {
sseAbort?.abort()
sseAbort = null
sseActive = false
picoSendUrlRef = null
picoTokenRef = null
}
function shouldReconnectFor(generation: number, sessionId: string): boolean { function shouldReconnectFor(generation: number, sessionId: string): boolean {
return ( return (
shouldMaintainConnection && shouldMaintainConnection &&
@ -100,6 +118,7 @@ function disconnectChatInternal({
isConnecting = false isConnecting = false
invalidateSocket(socket) invalidateSocket(socket)
stopSSE()
updateChatStore({ updateChatStore({
connectionState: "disconnected", connectionState: "disconnected",
@ -107,74 +126,11 @@ function disconnectChatInternal({
}) })
} }
export async function connectChat() { function attachWebSocketHandlers(
if ( socket: WebSocket,
store.get(gatewayAtom).status !== "running" || generation: number,
needsActiveSessionHydration() sessionId: string,
) { ) {
return
}
if (
isConnecting ||
(wsRef &&
(wsRef.readyState === WebSocket.OPEN ||
wsRef.readyState === WebSocket.CONNECTING))
) {
return
}
const generation = connectionGeneration + 1
connectionGeneration = generation
isConnecting = true
clearReconnectTimer()
updateChatStore({ connectionState: "connecting" })
try {
const { token, ws_url } = await getPicoToken()
const sessionId = activeSessionIdRef
if (generation !== connectionGeneration) {
isConnecting = false
return
}
if (!token) {
console.error("No pico token available")
updateChatStore({ connectionState: "error" })
isConnecting = false
scheduleReconnect(generation, sessionId)
return
}
const finalWsUrl = normalizeWsUrlForBrowser(ws_url)
const url = `${finalWsUrl}?session_id=${encodeURIComponent(sessionId)}`
const socket = new WebSocket(url, [`token.${token}`])
if (generation !== connectionGeneration) {
isConnecting = false
invalidateSocket(socket)
return
}
socket.onopen = () => {
if (
!isCurrentSocket({
socket,
currentSocket: wsRef,
generation,
currentGeneration: connectionGeneration,
sessionId,
currentSessionId: activeSessionIdRef,
})
) {
return
}
updateChatStore({ connectionState: "connected" })
isConnecting = false
reconnectAttempts = 0
}
socket.onmessage = (event) => { socket.onmessage = (event) => {
if ( if (
!isCurrentSocket({ !isCurrentSocket({
@ -236,14 +192,239 @@ export async function connectChat() {
updateChatStore({ connectionState: "error" }) updateChatStore({ connectionState: "error" })
scheduleReconnect(generation, sessionId) scheduleReconnect(generation, sessionId)
} }
}
/**
* Try WebSocket first. Resolves false if connection fails or times out (then caller may use SSE).
*/
function tryOpenWebSocket(
generation: number,
sessionId: string,
token: string,
wsUrl: string,
): Promise<boolean> {
return new Promise((resolve) => {
const finalWsUrl = normalizeWsUrlForBrowser(wsUrl)
const url = `${finalWsUrl}?session_id=${encodeURIComponent(sessionId)}`
const socket = new WebSocket(url, [`token.${token}`])
if (generation !== connectionGeneration) {
invalidateSocket(socket)
resolve(false)
return
}
let settled = false
const finishFail = () => {
if (settled) {
return
}
settled = true
window.clearTimeout(timer)
invalidateSocket(socket)
try {
socket.close()
} catch {
/* ignore */
}
resolve(false)
}
const timer = window.setTimeout(() => {
if (socket.readyState !== WebSocket.OPEN) {
finishFail()
}
}, WS_CONNECT_TIMEOUT_MS)
socket.onerror = () => {
if (socket.readyState !== WebSocket.OPEN) {
finishFail()
}
}
socket.onopen = () => {
window.clearTimeout(timer)
if (settled || generation !== connectionGeneration) {
invalidateSocket(socket)
resolve(false)
return
}
settled = true
wsRef = socket wsRef = socket
updateChatStore({ connectionState: "connected" })
isConnecting = false
reconnectAttempts = 0
attachWebSocketHandlers(socket, generation, sessionId)
resolve(true)
}
})
}
async function openSSETransport(
generation: number,
sessionId: string,
token: string,
eventsUrlRaw: string | undefined,
sendUrlRaw: string | undefined,
): Promise<void> {
const eventsPath = eventsUrlRaw?.trim() || "/pico/events"
const sendPath = sendUrlRaw?.trim() || "/pico/send"
const eventsURL = new URL(eventsPath, window.location.origin)
eventsURL.searchParams.set("session_id", sessionId)
const sendURL = new URL(sendPath, window.location.origin)
picoTokenRef = token
picoSendUrlRef = sendURL.toString()
sseAbort = new AbortController()
const signal = sseAbort.signal
let res: Response
try {
res = await launcherFetch(eventsURL.toString(), {
headers: { Authorization: `Bearer ${token}` },
signal,
credentials: "same-origin",
})
} catch {
if (generation !== connectionGeneration) {
return
}
stopSSE()
isConnecting = false
updateChatStore({ connectionState: "error" })
scheduleReconnect(generation, sessionId)
return
}
if (generation !== connectionGeneration) {
stopSSE()
return
}
if (!res.ok || !res.body) {
stopSSE()
isConnecting = false
updateChatStore({ connectionState: "error" })
scheduleReconnect(generation, sessionId)
return
}
sseActive = true
isConnecting = false
reconnectAttempts = 0
updateChatStore({ connectionState: "connected" })
try {
await readPicoSSEStream(res.body, signal, (data) => {
if (generation !== connectionGeneration) {
return
}
const message = parsePicoSSEData(data)
if (message) {
handlePicoMessage(message, sessionId)
}
})
} catch {
/* aborted or read error */
} finally {
sseActive = false
sseAbort = null
picoSendUrlRef = null
picoTokenRef = null
if (
generation === connectionGeneration &&
shouldReconnectFor(generation, sessionId)
) {
updateChatStore({
connectionState: "disconnected",
isTyping: false,
})
scheduleReconnect(generation, sessionId)
}
}
}
export async function connectChat() {
if (
store.get(gatewayAtom).status !== "running" ||
needsActiveSessionHydration()
) {
return
}
if (isConnecting) {
return
}
if (sseActive) {
return
}
if (
wsRef &&
(wsRef.readyState === WebSocket.OPEN ||
wsRef.readyState === WebSocket.CONNECTING)
) {
return
}
const generation = connectionGeneration + 1
connectionGeneration = generation
isConnecting = true
clearReconnectTimer()
invalidateSocket(wsRef)
wsRef = null
stopSSE()
updateChatStore({ connectionState: "connecting" })
const sessionId = activeSessionIdRef
try {
const { token, ws_url, events_url, send_url } = await getPicoToken()
if (generation !== connectionGeneration) {
isConnecting = false
return
}
if (!token) {
console.error("No pico token available")
updateChatStore({ connectionState: "error" })
isConnecting = false
scheduleReconnect(generation, sessionId)
return
}
const wsOk = await tryOpenWebSocket(
generation,
sessionId,
token,
ws_url,
)
if (generation !== connectionGeneration) {
isConnecting = false
return
}
if (wsOk) {
return
}
isConnecting = true
updateChatStore({ connectionState: "connecting" })
await openSSETransport(generation, sessionId, token, events_url, send_url)
} catch (error) { } catch (error) {
if (generation !== connectionGeneration) { if (generation !== connectionGeneration) {
isConnecting = false isConnecting = false
return return
} }
console.error("Failed to connect to pico:", error) console.error("Failed to connect to pico:", error)
stopSSE()
updateChatStore({ connectionState: "error" }) updateChatStore({ connectionState: "error" })
isConnecting = false isConnecting = false
scheduleReconnect(generation, activeSessionIdRef) scheduleReconnect(generation, activeSessionIdRef)
@ -324,25 +505,28 @@ export async function hydrateActiveSession() {
return hydratePromise return hydratePromise
} }
export function sendChatMessage(content: string) { export async function sendChatMessage(content: string): Promise<boolean> {
if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
console.warn("WebSocket not connected")
return false
}
const socket = wsRef
const id = `msg-${++msgIdCounter}-${Date.now()}` const id = `msg-${++msgIdCounter}-${Date.now()}`
const optimistic = () =>
updateChatStore((prev) => ({ updateChatStore((prev) => ({
messages: [ messages: [
...prev.messages, ...prev.messages,
{ id, role: "user", content, timestamp: Date.now() }, { id, role: "user" as const, content, timestamp: Date.now() },
], ],
isTyping: true, isTyping: true,
})) }))
const rollback = () =>
updateChatStore((prev) => ({
messages: prev.messages.filter((message) => message.id !== id),
isTyping: false,
}))
if (wsRef && wsRef.readyState === WebSocket.OPEN) {
optimistic()
try { try {
socket.send( wsRef.send(
JSON.stringify({ JSON.stringify({
type: "message.send", type: "message.send",
id, id,
@ -352,14 +536,44 @@ export function sendChatMessage(content: string) {
return true return true
} catch (error) { } catch (error) {
console.error("Failed to send pico message:", error) console.error("Failed to send pico message:", error)
updateChatStore((prev) => ({ rollback()
messages: prev.messages.filter((message) => message.id !== id),
isTyping: false,
}))
return false return false
} }
} }
if (sseActive && picoSendUrlRef && picoTokenRef) {
optimistic()
try {
const res = await launcherFetch(picoSendUrlRef, {
method: "POST",
credentials: "same-origin",
headers: {
Authorization: `Bearer ${picoTokenRef}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "message.send",
id,
session_id: activeSessionIdRef,
payload: { content },
}),
})
if (!res.ok) {
rollback()
return false
}
return true
} catch (error) {
console.error("Failed to send pico message:", error)
rollback()
return false
}
}
console.warn("Pico chat not connected")
return false
}
export async function switchChatSession(sessionId: string) { export async function switchChatSession(sessionId: string) {
if (sessionId === activeSessionIdRef) { if (sessionId === activeSessionIdRef) {
return return

View file

@ -29,7 +29,18 @@ export function handlePicoMessage(
? normalizeUnixTimestamp(Number(message.timestamp)) ? normalizeUnixTimestamp(Number(message.timestamp))
: Date.now() : Date.now()
updateChatStore((prev) => ({ updateChatStore((prev) => {
const exists = prev.messages.some((m) => m.id === messageId)
if (exists) {
return {
...prev,
messages: prev.messages.map((m) =>
m.id === messageId ? { ...m, content, timestamp } : m,
),
isTyping: false,
}
}
return {
messages: [ messages: [
...prev.messages, ...prev.messages,
{ {
@ -40,7 +51,8 @@ export function handlePicoMessage(
}, },
], ],
isTyping: false, isTyping: false,
})) }
})
break break
} }

View file

@ -0,0 +1,56 @@
import type { PicoMessage } from "@/features/chat/protocol"
/**
* Reads a fetch() response body as text/event-stream and invokes onMessage for each data payload.
* Comment lines (:) are ignored. Supports simple multi-line data: fields (joined with newlines).
*/
export async function readPicoSSEStream(
body: ReadableStream<Uint8Array>,
signal: AbortSignal,
onMessage: (data: string) => void,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buf = ""
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done) {
break
}
buf += decoder.decode(value, { stream: true })
for (;;) {
const sep = buf.indexOf("\n\n")
if (sep < 0) {
break
}
const raw = buf.slice(0, sep)
buf = buf.slice(sep + 2)
const lines = raw.split("\n")
const dataParts: string[] = []
for (const line of lines) {
if (line.startsWith(":")) {
continue
}
if (line.startsWith("data:")) {
dataParts.push(line.slice(5).trimStart())
}
}
const data = dataParts.join("\n").trimEnd()
if (data.length > 0) {
onMessage(data)
}
}
}
}
export function parsePicoSSEData(data: string): PicoMessage | null {
try {
return JSON.parse(data) as PicoMessage
} catch {
console.warn("Non-JSON SSE data from pico:", data)
return null
}
}

View file

@ -29,6 +29,11 @@ export default defineConfig({
target: "http://localhost:18800", target: "http://localhost:18800",
changeOrigin: true, changeOrigin: true,
}, },
"/pico": {
target: "http://localhost:18800",
changeOrigin: true,
ws: true,
},
"/ws": { "/ws": {
target: "ws://localhost:18800", target: "ws://localhost:18800",
ws: true, ws: true,