2026-02-22 20:55:15 +00:00
|
|
|
package pico
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-04-07 13:19:11 +00:00
|
|
|
"encoding/base64"
|
2026-02-22 20:55:15 +00:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2026-04-22 03:28:04 +00:00
|
|
|
"mime"
|
2026-02-22 20:55:15 +00:00
|
|
|
"net/http"
|
2026-04-22 03:28:04 +00:00
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-02-22 20:55:15 +00:00
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-22 22:56:48 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
2026-02-22 20:55:15 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-04-25 15:43:10 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
2026-02-22 20:55:15 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// picoConn represents a single WebSocket connection.
|
|
|
|
|
type picoConn struct {
|
|
|
|
|
id string
|
|
|
|
|
conn *websocket.Conn
|
|
|
|
|
sessionID string
|
|
|
|
|
writeMu sync.Mutex
|
|
|
|
|
closed atomic.Bool
|
2026-03-20 12:43:40 +00:00
|
|
|
cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop)
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 13:19:11 +00:00
|
|
|
var allowedInlineImageMIMETypes = map[string]struct{}{
|
|
|
|
|
"image/jpeg": {},
|
|
|
|
|
"image/png": {},
|
|
|
|
|
"image/gif": {},
|
|
|
|
|
"image/webp": {},
|
|
|
|
|
"image/bmp": {},
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 05:25:07 +00:00
|
|
|
func outboundMessageIsThought(msg bus.OutboundMessage) bool {
|
|
|
|
|
if len(msg.Context.Raw) == 0 {
|
2026-04-10 12:23:12 +00:00
|
|
|
return false
|
|
|
|
|
}
|
2026-04-13 05:25:07 +00:00
|
|
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindThought)
|
2026-04-10 12:23:12 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
|
|
|
|
if len(msg.Context.Raw) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 15:43:10 +00:00
|
|
|
func outboundMessageIsToolCalls(msg bus.OutboundMessage) bool {
|
|
|
|
|
if len(msg.Context.Raw) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindToolCalls)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
func outboundMessageFinalizesTrackedToolFeedback(msg bus.OutboundMessage) bool {
|
2026-04-25 15:43:10 +00:00
|
|
|
return !outboundMessageIsToolFeedback(msg) &&
|
|
|
|
|
!outboundMessageIsThought(msg) &&
|
|
|
|
|
!outboundMessageIsToolCalls(msg)
|
2026-04-23 02:35:50 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
// writeJSON sends a JSON message to the connection with write locking.
|
|
|
|
|
func (pc *picoConn) writeJSON(v any) error {
|
|
|
|
|
if pc.closed.Load() {
|
|
|
|
|
return fmt.Errorf("connection closed")
|
|
|
|
|
}
|
|
|
|
|
pc.writeMu.Lock()
|
|
|
|
|
defer pc.writeMu.Unlock()
|
|
|
|
|
return pc.conn.WriteJSON(v)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// close closes the connection.
|
|
|
|
|
func (pc *picoConn) close() {
|
|
|
|
|
if pc.closed.CompareAndSwap(false, true) {
|
2026-03-20 12:43:40 +00:00
|
|
|
if pc.cancel != nil {
|
|
|
|
|
pc.cancel()
|
|
|
|
|
}
|
2026-02-22 20:55:15 +00:00
|
|
|
pc.conn.Close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PicoChannel implements the native Pico Protocol WebSocket channel.
|
|
|
|
|
// It serves as the reference implementation for all optional capability interfaces.
|
|
|
|
|
type PicoChannel struct {
|
|
|
|
|
*channels.BaseChannel
|
2026-04-11 16:57:26 +00:00
|
|
|
bc *config.Channel
|
|
|
|
|
config *config.PicoSettings
|
2026-03-24 15:25:27 +00:00
|
|
|
upgrader websocket.Upgrader
|
|
|
|
|
connections map[string]*picoConn // connID -> *picoConn
|
|
|
|
|
sessionConnections map[string]map[string]*picoConn // sessionID -> connID -> *picoConn
|
|
|
|
|
connsMu sync.RWMutex
|
|
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
2026-04-23 02:35:50 +00:00
|
|
|
progress *channels.ToolFeedbackAnimator
|
|
|
|
|
deleteMessageFn func(context.Context, string, string) error
|
2026-06-22 11:15:35 +00:00
|
|
|
// broadcastFn lets tests intercept outbound broadcasts. nil → broadcastToSession.
|
|
|
|
|
broadcastFn func(chatID string, msg PicoMessage) error
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewPicoChannel creates a new Pico Protocol channel.
|
2026-04-11 16:57:26 +00:00
|
|
|
func NewPicoChannel(
|
|
|
|
|
bc *config.Channel,
|
|
|
|
|
cfg *config.PicoSettings,
|
|
|
|
|
messageBus *bus.MessageBus,
|
|
|
|
|
) (*PicoChannel, error) {
|
2026-03-27 16:03:34 +00:00
|
|
|
if cfg.Token.String() == "" {
|
2026-02-22 20:55:15 +00:00
|
|
|
return nil, fmt.Errorf("pico token is required")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
base := channels.NewBaseChannel("pico", cfg, messageBus, bc.AllowFrom)
|
2026-02-22 20:55:15 +00:00
|
|
|
|
|
|
|
|
allowOrigins := cfg.AllowOrigins
|
|
|
|
|
checkOrigin := func(r *http.Request) bool {
|
|
|
|
|
if len(allowOrigins) == 0 {
|
|
|
|
|
return true // allow all if not configured
|
|
|
|
|
}
|
|
|
|
|
origin := r.Header.Get("Origin")
|
|
|
|
|
for _, allowed := range allowOrigins {
|
|
|
|
|
if allowed == "*" || allowed == origin {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
ch := &PicoChannel{
|
2026-02-22 20:55:15 +00:00
|
|
|
BaseChannel: base,
|
2026-04-11 16:57:26 +00:00
|
|
|
bc: bc,
|
2026-02-22 20:55:15 +00:00
|
|
|
config: cfg,
|
|
|
|
|
upgrader: websocket.Upgrader{
|
|
|
|
|
CheckOrigin: checkOrigin,
|
|
|
|
|
ReadBufferSize: 1024,
|
|
|
|
|
WriteBufferSize: 1024,
|
|
|
|
|
},
|
2026-03-24 15:25:27 +00:00
|
|
|
connections: make(map[string]*picoConn),
|
|
|
|
|
sessionConnections: make(map[string]map[string]*picoConn),
|
2026-04-23 02:35:50 +00:00
|
|
|
}
|
|
|
|
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
|
|
|
|
ch.deleteMessageFn = ch.DeleteMessage
|
|
|
|
|
return ch, nil
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 15:25:27 +00:00
|
|
|
// createAndAddConnection checks MaxConnections and registers a connection atomically.
|
|
|
|
|
func (c *PicoChannel) createAndAddConnection(conn *websocket.Conn, sessionID string, maxConns int) (*picoConn, error) {
|
|
|
|
|
c.connsMu.Lock()
|
|
|
|
|
defer c.connsMu.Unlock()
|
|
|
|
|
if len(c.connections) >= maxConns {
|
|
|
|
|
return nil, channels.ErrTemporary
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var connID string
|
|
|
|
|
for {
|
|
|
|
|
connID = uuid.New().String()
|
|
|
|
|
if _, exists := c.connections[connID]; !exists {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pc := &picoConn{
|
|
|
|
|
id: connID,
|
|
|
|
|
conn: conn,
|
|
|
|
|
sessionID: sessionID,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.connections[pc.id] = pc
|
|
|
|
|
bySession, ok := c.sessionConnections[pc.sessionID]
|
|
|
|
|
if !ok {
|
|
|
|
|
bySession = make(map[string]*picoConn)
|
|
|
|
|
c.sessionConnections[pc.sessionID] = bySession
|
|
|
|
|
}
|
|
|
|
|
bySession[pc.id] = pc
|
|
|
|
|
|
|
|
|
|
return pc, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// removeConnection deletes a connection from indexes and returns it when found.
|
|
|
|
|
func (c *PicoChannel) removeConnection(connID string) *picoConn {
|
|
|
|
|
c.connsMu.Lock()
|
|
|
|
|
defer c.connsMu.Unlock()
|
|
|
|
|
|
|
|
|
|
pc, ok := c.connections[connID]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
delete(c.connections, connID)
|
|
|
|
|
if bySession, ok := c.sessionConnections[pc.sessionID]; ok {
|
|
|
|
|
delete(bySession, connID)
|
|
|
|
|
if len(bySession) == 0 {
|
|
|
|
|
delete(c.sessionConnections, pc.sessionID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pc
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// takeAllConnections snapshots and clears all connection indexes.
|
|
|
|
|
func (c *PicoChannel) takeAllConnections() []*picoConn {
|
|
|
|
|
c.connsMu.Lock()
|
|
|
|
|
defer c.connsMu.Unlock()
|
|
|
|
|
|
|
|
|
|
all := make([]*picoConn, 0, len(c.connections))
|
|
|
|
|
for _, pc := range c.connections {
|
|
|
|
|
all = append(all, pc)
|
|
|
|
|
}
|
|
|
|
|
clear(c.connections)
|
|
|
|
|
clear(c.sessionConnections)
|
|
|
|
|
|
|
|
|
|
return all
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sessionConnectionsSnapshot returns all active connections for a session.
|
|
|
|
|
func (c *PicoChannel) sessionConnectionsSnapshot(sessionID string) []*picoConn {
|
|
|
|
|
c.connsMu.RLock()
|
|
|
|
|
defer c.connsMu.RUnlock()
|
|
|
|
|
|
|
|
|
|
bySession, ok := c.sessionConnections[sessionID]
|
|
|
|
|
if !ok || len(bySession) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
conns := make([]*picoConn, 0, len(bySession))
|
|
|
|
|
for _, pc := range bySession {
|
|
|
|
|
conns = append(conns, pc)
|
|
|
|
|
}
|
|
|
|
|
return conns
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// currentConnCount returns a lock-protected snapshot of active connection count.
|
|
|
|
|
func (c *PicoChannel) currentConnCount() int {
|
|
|
|
|
c.connsMu.RLock()
|
|
|
|
|
defer c.connsMu.RUnlock()
|
|
|
|
|
return len(c.connections)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
// Start implements Channel.
|
|
|
|
|
func (c *PicoChannel) Start(ctx context.Context) error {
|
|
|
|
|
logger.InfoC("pico", "Starting Pico Protocol channel")
|
|
|
|
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
|
|
|
|
c.SetRunning(true)
|
|
|
|
|
logger.InfoC("pico", "Pico Protocol channel started")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop implements Channel.
|
|
|
|
|
func (c *PicoChannel) Stop(ctx context.Context) error {
|
|
|
|
|
logger.InfoC("pico", "Stopping Pico Protocol channel")
|
|
|
|
|
c.SetRunning(false)
|
|
|
|
|
|
|
|
|
|
// Close all connections
|
2026-03-24 15:25:27 +00:00
|
|
|
for _, pc := range c.takeAllConnections() {
|
|
|
|
|
pc.close()
|
|
|
|
|
}
|
2026-02-22 20:55:15 +00:00
|
|
|
|
|
|
|
|
if c.cancel != nil {
|
|
|
|
|
c.cancel()
|
|
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
if c.progress != nil {
|
|
|
|
|
c.progress.StopAll()
|
|
|
|
|
}
|
2026-02-22 20:55:15 +00:00
|
|
|
|
|
|
|
|
logger.InfoC("pico", "Pico Protocol channel stopped")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WebhookPath implements channels.WebhookHandler.
|
|
|
|
|
func (c *PicoChannel) WebhookPath() string { return "/pico/" }
|
|
|
|
|
|
|
|
|
|
// ServeHTTP implements http.Handler for the shared HTTP server.
|
|
|
|
|
func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/pico")
|
|
|
|
|
|
2026-03-24 15:25:27 +00:00
|
|
|
switch path {
|
|
|
|
|
case "/ws", "/ws/":
|
2026-02-22 20:55:15 +00:00
|
|
|
c.handleWebSocket(w, r)
|
|
|
|
|
default:
|
2026-04-22 03:28:04 +00:00
|
|
|
if strings.HasPrefix(path, "/media/") {
|
|
|
|
|
c.handleMediaDownload(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-22 20:55:15 +00:00
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send implements Channel — sends a message to the appropriate WebSocket connection.
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
2026-02-22 20:55:15 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
2026-04-13 05:25:07 +00:00
|
|
|
isThought := outboundMessageIsThought(msg)
|
2026-04-23 02:35:50 +00:00
|
|
|
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
2026-04-25 15:43:10 +00:00
|
|
|
isToolCalls := outboundMessageIsToolCalls(msg)
|
2026-04-23 02:35:50 +00:00
|
|
|
if isToolFeedback {
|
|
|
|
|
if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return []string{msgID}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID)
|
|
|
|
|
if outboundMessageFinalizesTrackedToolFeedback(msg) {
|
|
|
|
|
if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled {
|
|
|
|
|
return msgIDs, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
content := msg.Content
|
|
|
|
|
if isToolFeedback {
|
|
|
|
|
content = channels.InitialAnimatedToolFeedbackContent(msg.Content)
|
|
|
|
|
}
|
|
|
|
|
msgID := uuid.New().String()
|
2026-02-22 20:55:15 +00:00
|
|
|
|
2026-04-21 08:30:02 +00:00
|
|
|
payload := map[string]any{
|
2026-04-23 02:35:50 +00:00
|
|
|
PayloadKeyContent: content,
|
|
|
|
|
"message_id": msgID,
|
2026-04-21 08:30:02 +00:00
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
if modelName := strings.TrimSpace(msg.Context.Raw[PayloadKeyModelName]); modelName != "" {
|
|
|
|
|
payload[PayloadKeyModelName] = modelName
|
|
|
|
|
}
|
2026-04-28 02:17:12 +00:00
|
|
|
switch {
|
|
|
|
|
case isThought:
|
|
|
|
|
payload[PayloadKeyKind] = MessageKindThought
|
|
|
|
|
|
|
|
|
|
// This field is kept solely for compatibility with legacy pico clients that
|
|
|
|
|
// do not yet support the newer "kind" field.
|
|
|
|
|
// DO NOT use it for any purpose other than legacy client compatibility.
|
|
|
|
|
payload[PayloadKeyThought] = true
|
|
|
|
|
|
|
|
|
|
case isToolCalls:
|
2026-04-25 15:43:10 +00:00
|
|
|
payload[PayloadKeyKind] = MessageKindToolCalls
|
|
|
|
|
if toolCalls, ok := picoToolCallsPayload(msg); ok {
|
|
|
|
|
payload[PayloadKeyToolCalls] = toolCalls
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-21 08:30:02 +00:00
|
|
|
setContextUsagePayload(payload, msg.ContextUsage)
|
|
|
|
|
outMsg := newMessage(TypeMessageCreate, payload)
|
2026-02-22 20:55:15 +00:00
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if isToolFeedback {
|
|
|
|
|
c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content)
|
|
|
|
|
} else if hasTrackedMsg && outboundMessageFinalizesTrackedToolFeedback(msg) {
|
|
|
|
|
c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID)
|
|
|
|
|
}
|
|
|
|
|
return []string{msgID}, nil
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EditMessage implements channels.MessageEditor.
|
|
|
|
|
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
2026-04-23 02:35:50 +00:00
|
|
|
return c.editMessage(ctx, chatID, messageID, content, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 05:42:21 +00:00
|
|
|
func (c *PicoChannel) EditMessageWithPayload(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
chatID string,
|
|
|
|
|
messageID string,
|
|
|
|
|
payload map[string]any,
|
|
|
|
|
) error {
|
|
|
|
|
return c.editMessagePayload(ctx, chatID, messageID, payload, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
// DeleteMessage implements channels.MessageDeleter.
|
|
|
|
|
func (c *PicoChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
|
|
|
|
|
outMsg := newMessage(TypeMessageDelete, map[string]any{
|
2026-02-22 20:55:15 +00:00
|
|
|
"message_id": messageID,
|
|
|
|
|
})
|
|
|
|
|
return c.broadcastToSession(chatID, outMsg)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
func (c *PicoChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
|
|
|
|
|
if c.progress == nil {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return c.progress.Current(chatID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *PicoChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) {
|
|
|
|
|
if c.progress == nil {
|
|
|
|
|
return "", "", false
|
|
|
|
|
}
|
|
|
|
|
return c.progress.Take(chatID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *PicoChannel) RecordToolFeedbackMessage(chatID, messageID, content string) {
|
|
|
|
|
if c.progress == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c.progress.Record(chatID, messageID, content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *PicoChannel) ClearToolFeedbackMessage(chatID string) {
|
|
|
|
|
if c.progress == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c.progress.Clear(chatID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *PicoChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) {
|
|
|
|
|
msgID, ok := c.currentToolFeedbackMessage(chatID)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *PicoChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) {
|
|
|
|
|
if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c.ClearToolFeedbackMessage(chatID)
|
|
|
|
|
deleteFn := c.deleteMessageFn
|
|
|
|
|
if deleteFn == nil {
|
|
|
|
|
deleteFn = c.DeleteMessage
|
|
|
|
|
}
|
|
|
|
|
_ = deleteFn(ctx, chatID, messageID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *PicoChannel) finalizeTrackedToolFeedbackMessage(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
chatID string,
|
|
|
|
|
content string,
|
2026-05-20 05:42:21 +00:00
|
|
|
editFn func(context.Context, string, string, map[string]any, *bus.ContextUsage) error,
|
|
|
|
|
payload map[string]any,
|
2026-04-23 02:35:50 +00:00
|
|
|
contextUsage *bus.ContextUsage,
|
|
|
|
|
) ([]string, bool) {
|
|
|
|
|
msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID)
|
|
|
|
|
if !ok || editFn == nil {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
if payload == nil {
|
|
|
|
|
payload = map[string]any{
|
|
|
|
|
PayloadKeyContent: content,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, ok := payload[PayloadKeyContent]; !ok {
|
|
|
|
|
payload[PayloadKeyContent] = content
|
|
|
|
|
}
|
|
|
|
|
if err := editFn(ctx, chatID, msgID, payload, contextUsage); err != nil {
|
2026-04-23 02:35:50 +00:00
|
|
|
c.RecordToolFeedbackMessage(chatID, msgID, baseContent)
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return []string{msgID}, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *PicoChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) {
|
|
|
|
|
if !outboundMessageFinalizesTrackedToolFeedback(msg) {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
payload := map[string]any{
|
|
|
|
|
PayloadKeyContent: msg.Content,
|
|
|
|
|
}
|
|
|
|
|
if modelName := strings.TrimSpace(msg.Context.Raw[PayloadKeyModelName]); modelName != "" {
|
|
|
|
|
payload[PayloadKeyModelName] = modelName
|
|
|
|
|
}
|
|
|
|
|
return c.finalizeTrackedToolFeedbackMessage(
|
|
|
|
|
ctx,
|
|
|
|
|
msg.ChatID,
|
|
|
|
|
msg.Content,
|
|
|
|
|
c.editMessagePayload,
|
|
|
|
|
payload,
|
|
|
|
|
msg.ContextUsage,
|
|
|
|
|
)
|
2026-04-23 02:35:50 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
// StartTyping implements channels.TypingCapable.
|
|
|
|
|
func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
|
|
|
|
startMsg := newMessage(TypeTypingStart, nil)
|
|
|
|
|
if err := c.broadcastToSession(chatID, startMsg); err != nil {
|
|
|
|
|
return func() {}, err
|
|
|
|
|
}
|
|
|
|
|
return func() {
|
|
|
|
|
stopMsg := newMessage(TypeTypingStop, nil)
|
|
|
|
|
c.broadcastToSession(chatID, stopMsg)
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
|
|
|
|
// It sends a placeholder message via the Pico Protocol that will later be
|
|
|
|
|
// edited to the actual response via EditMessage (channels.MessageEditor).
|
|
|
|
|
func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
2026-04-11 16:57:26 +00:00
|
|
|
if !c.bc.Placeholder.Enabled {
|
2026-02-26 19:02:40 +00:00
|
|
|
return "", nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
text := c.bc.Placeholder.GetRandomText()
|
2026-02-26 19:02:40 +00:00
|
|
|
|
|
|
|
|
msgID := uuid.New().String()
|
|
|
|
|
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
2026-05-19 08:38:47 +00:00
|
|
|
PayloadKeyContent: text,
|
|
|
|
|
PayloadKeyPlaceholder: true,
|
|
|
|
|
"message_id": msgID,
|
2026-02-26 19:02:40 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if err := c.broadcastToSession(chatID, outMsg); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return msgID, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
// BeginStream implements channels.StreamingCapable for Pico WebUI.
|
|
|
|
|
func (c *PicoChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) {
|
|
|
|
|
if c == nil || c.config == nil || !c.config.Streaming.Enabled {
|
|
|
|
|
return nil, fmt.Errorf("streaming disabled in config")
|
|
|
|
|
}
|
|
|
|
|
if !c.IsRunning() {
|
|
|
|
|
return nil, channels.ErrNotRunning
|
|
|
|
|
}
|
|
|
|
|
streamCfg := c.config.Streaming.WithDefaults(0, 1)
|
|
|
|
|
return &picoStreamer{
|
|
|
|
|
channel: c,
|
|
|
|
|
chatID: chatID,
|
|
|
|
|
throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second,
|
|
|
|
|
minGrowth: streamCfg.MinGrowthChars,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type picoStreamer struct {
|
|
|
|
|
channel *PicoChannel
|
|
|
|
|
chatID string
|
2026-05-20 05:42:21 +00:00
|
|
|
modelName string
|
2026-06-22 11:07:48 +00:00
|
|
|
turnInputTokens int
|
|
|
|
|
turnOutputTokens int
|
2026-05-19 08:38:47 +00:00
|
|
|
messageID string
|
|
|
|
|
reasoningID string
|
|
|
|
|
throttleInterval time.Duration
|
|
|
|
|
minGrowth int
|
|
|
|
|
lastLen int
|
|
|
|
|
lastAt time.Time
|
|
|
|
|
lastContent string
|
|
|
|
|
reasoningLastLen int
|
|
|
|
|
reasoningLastAt time.Time
|
|
|
|
|
reasoningContent string
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 05:42:21 +00:00
|
|
|
func (s *picoStreamer) SetModelName(modelName string) {
|
|
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.modelName = strings.TrimSpace(modelName)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 11:07:48 +00:00
|
|
|
// SetTurnUsage records the real per-turn LLM token usage to emit on finalize.
|
|
|
|
|
func (s *picoStreamer) SetTurnUsage(inputTokens, outputTokens int) {
|
|
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.turnInputTokens = inputTokens
|
|
|
|
|
s.turnOutputTokens = outputTokens
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
func (s *picoStreamer) Update(ctx context.Context, content string) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
return s.updateLocked(ctx, content, false, nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *picoStreamer) Finalize(ctx context.Context, content string) error {
|
|
|
|
|
return s.FinalizeWithContext(ctx, content, nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *picoStreamer) FinalizeWithContext(ctx context.Context, content string, contextUsage *bus.ContextUsage) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
return s.updateLocked(ctx, content, true, contextUsage)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *picoStreamer) UpdateReasoning(ctx context.Context, content string) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
return s.updateReasoningLocked(ctx, content, false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *picoStreamer) FinalizeReasoning(ctx context.Context, content string) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
return s.updateReasoningLocked(ctx, content, true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *picoStreamer) Cancel(ctx context.Context) {
|
|
|
|
|
if s == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
if s.channel == nil || s.messageID == "" {
|
|
|
|
|
if s.channel != nil && s.reasoningID != "" {
|
|
|
|
|
_ = s.channel.DeleteMessage(ctx, s.chatID, s.reasoningID)
|
|
|
|
|
s.reasoningID = ""
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = s.channel.DeleteMessage(ctx, s.chatID, s.messageID)
|
|
|
|
|
s.messageID = ""
|
|
|
|
|
if s.reasoningID != "" {
|
|
|
|
|
_ = s.channel.DeleteMessage(ctx, s.chatID, s.reasoningID)
|
|
|
|
|
s.reasoningID = ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *picoStreamer) updateLocked(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
content string,
|
|
|
|
|
force bool,
|
|
|
|
|
contextUsage *bus.ContextUsage,
|
|
|
|
|
) error {
|
|
|
|
|
if s == nil || s.channel == nil {
|
|
|
|
|
return fmt.Errorf("streamer is not initialized")
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(content) == "" && s.messageID == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
contentLen := len([]rune(content))
|
|
|
|
|
if s.messageID != "" && !force {
|
|
|
|
|
growth := contentLen - s.lastLen
|
|
|
|
|
if now.Sub(s.lastAt) < s.throttleInterval || growth < s.minGrowth {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s.sendLocked(ctx, content, contextUsage)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *picoStreamer) updateReasoningLocked(ctx context.Context, content string, force bool) error {
|
|
|
|
|
if s == nil || s.channel == nil {
|
|
|
|
|
return fmt.Errorf("streamer is not initialized")
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(content) == "" && s.reasoningID == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
contentLen := len([]rune(content))
|
|
|
|
|
if s.reasoningID != "" && !force {
|
|
|
|
|
growth := contentLen - s.reasoningLastLen
|
|
|
|
|
if now.Sub(s.reasoningLastAt) < s.throttleInterval || growth < s.minGrowth {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s.sendReasoningLocked(ctx, content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *picoStreamer) sendLocked(ctx context.Context, content string, contextUsage *bus.ContextUsage) error {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
contentLen := len([]rune(content))
|
|
|
|
|
|
|
|
|
|
if s.messageID == "" {
|
|
|
|
|
s.messageID = uuid.New().String()
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
PayloadKeyContent: content,
|
|
|
|
|
"message_id": s.messageID,
|
|
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
if s.modelName != "" {
|
|
|
|
|
payload[PayloadKeyModelName] = s.modelName
|
|
|
|
|
}
|
2026-05-19 08:38:47 +00:00
|
|
|
setContextUsagePayload(payload, contextUsage)
|
2026-06-22 11:15:35 +00:00
|
|
|
setTurnUsagePayload(payload, s.turnInputTokens, s.turnOutputTokens)
|
2026-05-19 08:38:47 +00:00
|
|
|
outMsg := newMessage(TypeMessageCreate, payload)
|
2026-06-22 11:15:35 +00:00
|
|
|
if err := s.channel.broadcast(s.chatID, outMsg); err != nil {
|
2026-05-19 08:38:47 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
} else if content != s.lastContent || contextUsage != nil {
|
2026-05-20 05:42:21 +00:00
|
|
|
payload := map[string]any{
|
|
|
|
|
PayloadKeyContent: content,
|
|
|
|
|
"message_id": s.messageID,
|
|
|
|
|
}
|
|
|
|
|
if s.modelName != "" {
|
|
|
|
|
payload[PayloadKeyModelName] = s.modelName
|
|
|
|
|
}
|
2026-06-22 11:15:35 +00:00
|
|
|
setTurnUsagePayload(payload, s.turnInputTokens, s.turnOutputTokens)
|
2026-05-20 05:42:21 +00:00
|
|
|
if err := s.channel.editMessagePayload(ctx, s.chatID, s.messageID, payload, contextUsage); err != nil {
|
2026-05-19 08:38:47 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.lastContent = content
|
|
|
|
|
s.lastLen = contentLen
|
|
|
|
|
s.lastAt = now
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *picoStreamer) sendReasoningLocked(ctx context.Context, content string) error {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
contentLen := len([]rune(content))
|
|
|
|
|
|
|
|
|
|
if s.reasoningID == "" {
|
|
|
|
|
s.reasoningID = uuid.New().String()
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
PayloadKeyContent: content,
|
|
|
|
|
"message_id": s.reasoningID,
|
|
|
|
|
PayloadKeyKind: MessageKindThought,
|
|
|
|
|
PayloadKeyThought: true,
|
|
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
if s.modelName != "" {
|
|
|
|
|
payload[PayloadKeyModelName] = s.modelName
|
|
|
|
|
}
|
2026-05-19 08:38:47 +00:00
|
|
|
outMsg := newMessage(TypeMessageCreate, payload)
|
|
|
|
|
if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
} else if content != s.reasoningContent {
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
PayloadKeyContent: content,
|
|
|
|
|
"message_id": s.reasoningID,
|
|
|
|
|
PayloadKeyKind: MessageKindThought,
|
|
|
|
|
PayloadKeyThought: true,
|
|
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
if s.modelName != "" {
|
|
|
|
|
payload[PayloadKeyModelName] = s.modelName
|
|
|
|
|
}
|
2026-05-19 08:38:47 +00:00
|
|
|
outMsg := newMessage(TypeMessageUpdate, payload)
|
|
|
|
|
if err := s.channel.broadcastToSession(s.chatID, outMsg); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.reasoningContent = content
|
|
|
|
|
s.reasoningLastLen = contentLen
|
|
|
|
|
s.reasoningLastAt = now
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 03:28:04 +00:00
|
|
|
// SendMedia implements channels.MediaSender for the Pico web UI.
|
|
|
|
|
// Media is delivered as a normal assistant message carrying structured
|
|
|
|
|
// attachments plus an authenticated same-origin download URL.
|
|
|
|
|
func (c *PicoChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
|
|
|
|
if !c.IsRunning() {
|
|
|
|
|
return nil, channels.ErrNotRunning
|
|
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID)
|
2026-04-22 03:28:04 +00:00
|
|
|
|
|
|
|
|
store := c.GetMediaStore()
|
|
|
|
|
if store == nil {
|
|
|
|
|
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
attachments := make([]map[string]any, 0, len(msg.Parts))
|
|
|
|
|
caption := ""
|
|
|
|
|
|
|
|
|
|
for _, part := range msg.Parts {
|
|
|
|
|
localPath, meta, err := store.ResolveWithMeta(part.Ref)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("pico", "Failed to resolve media ref", map[string]any{
|
|
|
|
|
"ref": part.Ref,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filename := strings.TrimSpace(part.Filename)
|
|
|
|
|
if filename == "" {
|
|
|
|
|
filename = strings.TrimSpace(meta.Filename)
|
|
|
|
|
}
|
|
|
|
|
if filename == "" {
|
|
|
|
|
filename = filepath.Base(localPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
contentType := strings.TrimSpace(part.ContentType)
|
|
|
|
|
if contentType == "" {
|
|
|
|
|
contentType = strings.TrimSpace(meta.ContentType)
|
|
|
|
|
}
|
|
|
|
|
if contentType == "" {
|
|
|
|
|
contentType = "application/octet-stream"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
attachmentType := strings.TrimSpace(part.Type)
|
|
|
|
|
if attachmentType == "" {
|
|
|
|
|
attachmentType = picoInferAttachmentType(filename, contentType)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
attachmentURL, err := picoDownloadURLForRef(part.Ref)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("pico", "Failed to build media download URL", map[string]any{
|
|
|
|
|
"ref": part.Ref,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
attachments = append(attachments, map[string]any{
|
|
|
|
|
"type": attachmentType,
|
|
|
|
|
"url": attachmentURL,
|
|
|
|
|
"filename": filename,
|
|
|
|
|
"content_type": contentType,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if caption == "" && strings.TrimSpace(part.Caption) != "" {
|
|
|
|
|
caption = strings.TrimSpace(part.Caption)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(attachments) == 0 {
|
|
|
|
|
return nil, fmt.Errorf("no deliverable media parts: %w", channels.ErrSendFailed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msgID := uuid.New().String()
|
|
|
|
|
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
|
|
|
|
PayloadKeyContent: caption,
|
|
|
|
|
"attachments": attachments,
|
|
|
|
|
"message_id": msgID,
|
|
|
|
|
})
|
2026-05-20 05:42:21 +00:00
|
|
|
if modelName := strings.TrimSpace(msg.Context.Raw[PayloadKeyModelName]); modelName != "" {
|
|
|
|
|
outMsg.Payload[PayloadKeyModelName] = modelName
|
|
|
|
|
}
|
2026-04-22 03:28:04 +00:00
|
|
|
|
|
|
|
|
if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
if hasTrackedMsg {
|
|
|
|
|
c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID)
|
|
|
|
|
}
|
2026-04-22 03:28:04 +00:00
|
|
|
|
|
|
|
|
return []string{msgID}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func picoDownloadURLForRef(ref string) (string, error) {
|
|
|
|
|
refID, err := picoMediaRefID(ref)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return "/pico/media/" + url.PathEscape(refID), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func picoMediaRefID(ref string) (string, error) {
|
|
|
|
|
refID := strings.TrimSpace(strings.TrimPrefix(ref, "media://"))
|
|
|
|
|
if refID == "" || strings.Contains(refID, "/") {
|
|
|
|
|
return "", fmt.Errorf("invalid media ref %q", ref)
|
|
|
|
|
}
|
|
|
|
|
return refID, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func picoInferAttachmentType(filename, contentType string) string {
|
|
|
|
|
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
|
|
|
|
filename = strings.ToLower(strings.TrimSpace(filename))
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasPrefix(contentType, "image/"):
|
|
|
|
|
return "image"
|
|
|
|
|
case strings.HasPrefix(contentType, "audio/"):
|
|
|
|
|
return "audio"
|
|
|
|
|
case strings.HasPrefix(contentType, "video/"):
|
|
|
|
|
return "video"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch ext := filepath.Ext(filename); ext {
|
|
|
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
|
|
|
|
return "image"
|
|
|
|
|
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
|
|
|
|
return "audio"
|
|
|
|
|
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
|
|
|
|
return "video"
|
|
|
|
|
default:
|
|
|
|
|
return "file"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func picoAllowsInlineDisplay(filename, contentType string) bool {
|
|
|
|
|
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
|
|
|
|
filename = strings.ToLower(strings.TrimSpace(filename))
|
|
|
|
|
|
|
|
|
|
if strings.Contains(contentType, "svg") || filepath.Ext(filename) == ".svg" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return picoInferAttachmentType(filename, contentType) == "image"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *PicoChannel) handleMediaDownload(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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
refID := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/pico/media/"), "/"))
|
|
|
|
|
if refID == "" {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
store := c.GetMediaStore()
|
|
|
|
|
if store == nil {
|
|
|
|
|
http.Error(w, "media store unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
localPath, meta, err := store.ResolveWithMeta("media://" + refID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file, err := os.Open(localPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to open media", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer file.Close()
|
|
|
|
|
|
|
|
|
|
info, err := file.Stat()
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to stat media", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filename := strings.TrimSpace(meta.Filename)
|
|
|
|
|
if filename == "" {
|
|
|
|
|
filename = filepath.Base(localPath)
|
|
|
|
|
}
|
|
|
|
|
contentType := strings.TrimSpace(meta.ContentType)
|
|
|
|
|
if contentType == "" {
|
|
|
|
|
contentType = "application/octet-stream"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dispositionType := "attachment"
|
|
|
|
|
if picoAllowsInlineDisplay(filename, contentType) {
|
|
|
|
|
dispositionType = "inline"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cd := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); cd != "" {
|
|
|
|
|
w.Header().Set("Content-Disposition", cd)
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
|
|
|
http.ServeContent(w, r, filename, info.ModTime(), file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 11:15:35 +00:00
|
|
|
// broadcast routes through broadcastFn when set (tests), else broadcastToSession.
|
|
|
|
|
func (c *PicoChannel) broadcast(chatID string, msg PicoMessage) error {
|
|
|
|
|
if c.broadcastFn != nil {
|
|
|
|
|
return c.broadcastFn(chatID, msg)
|
|
|
|
|
}
|
|
|
|
|
return c.broadcastToSession(chatID, msg)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
// broadcastToSession sends a message to all connections with a matching session.
|
|
|
|
|
func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
|
|
|
|
|
// chatID format: "pico:<sessionID>"
|
|
|
|
|
sessionID := strings.TrimPrefix(chatID, "pico:")
|
|
|
|
|
msg.SessionID = sessionID
|
|
|
|
|
|
|
|
|
|
var sent bool
|
2026-03-24 15:25:27 +00:00
|
|
|
for _, pc := range c.sessionConnectionsSnapshot(sessionID) {
|
|
|
|
|
if err := pc.writeJSON(msg); err != nil {
|
|
|
|
|
logger.DebugCF("pico", "Write to connection failed", map[string]any{
|
|
|
|
|
"conn_id": pc.id,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
sent = true
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
2026-03-24 15:25:27 +00:00
|
|
|
}
|
2026-02-22 20:55:15 +00:00
|
|
|
|
|
|
|
|
if !sent {
|
|
|
|
|
return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle.
|
|
|
|
|
func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !c.IsRunning() {
|
|
|
|
|
http.Error(w, "channel not running", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Authenticate
|
|
|
|
|
if !c.authenticate(r) {
|
|
|
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check connection limit
|
|
|
|
|
maxConns := c.config.MaxConnections
|
|
|
|
|
if maxConns <= 0 {
|
|
|
|
|
maxConns = 100
|
|
|
|
|
}
|
2026-03-24 15:25:27 +00:00
|
|
|
if c.currentConnCount() >= maxConns {
|
2026-02-22 20:55:15 +00:00
|
|
|
http.Error(w, "too many connections", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 01:58:37 +00:00
|
|
|
// Echo the matched subprotocol back so the browser accepts the upgrade.
|
|
|
|
|
var responseHeader http.Header
|
|
|
|
|
if proto := c.matchedSubprotocol(r); proto != "" {
|
|
|
|
|
responseHeader = http.Header{"Sec-WebSocket-Protocol": {proto}}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
conn, err := c.upgrader.Upgrade(w, r, responseHeader)
|
2026-02-22 20:55:15 +00:00
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine session ID from query param or generate one
|
|
|
|
|
sessionID := r.URL.Query().Get("session_id")
|
|
|
|
|
if sessionID == "" {
|
|
|
|
|
sessionID = uuid.New().String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 15:25:27 +00:00
|
|
|
pc, err := c.createAndAddConnection(conn, sessionID, maxConns)
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = conn.WriteControl(
|
|
|
|
|
websocket.CloseMessage,
|
|
|
|
|
websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "too many connections"),
|
|
|
|
|
time.Now().Add(2*time.Second),
|
|
|
|
|
)
|
|
|
|
|
_ = conn.Close()
|
|
|
|
|
return
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.InfoCF("pico", "WebSocket client connected", map[string]any{
|
|
|
|
|
"conn_id": pc.id,
|
|
|
|
|
"session_id": sessionID,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
go c.readLoop(pc)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 01:58:37 +00:00
|
|
|
// authenticate checks the request for a valid token:
|
|
|
|
|
// 1. Authorization: Bearer <token> header
|
|
|
|
|
// 2. Sec-WebSocket-Protocol "token.<value>" (for browsers that can't set headers)
|
|
|
|
|
// 3. Query parameter "token" (only when AllowTokenQuery is on)
|
2026-02-22 20:55:15 +00:00
|
|
|
func (c *PicoChannel) authenticate(r *http.Request) bool {
|
2026-03-27 16:03:34 +00:00
|
|
|
token := c.config.Token.String()
|
2026-02-22 20:55:15 +00:00
|
|
|
if token == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check Authorization header
|
|
|
|
|
auth := r.Header.Get("Authorization")
|
2026-02-28 04:21:54 +00:00
|
|
|
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
|
|
|
|
if after == token {
|
2026-02-22 20:55:15 +00:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 01:58:37 +00:00
|
|
|
// Check Sec-WebSocket-Protocol subprotocol ("token.<value>")
|
|
|
|
|
if c.matchedSubprotocol(r) != "" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:03:23 +00:00
|
|
|
// Check query parameter only when explicitly allowed
|
|
|
|
|
if c.config.AllowTokenQuery {
|
|
|
|
|
if r.URL.Query().Get("token") == token {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 01:58:37 +00:00
|
|
|
// matchedSubprotocol returns the "token.<value>" subprotocol that matches
|
|
|
|
|
// the configured token, or "" if none do.
|
|
|
|
|
func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
|
2026-03-27 16:03:34 +00:00
|
|
|
token := c.config.Token.String()
|
2026-03-16 01:58:37 +00:00
|
|
|
for _, proto := range websocket.Subprotocols(r) {
|
|
|
|
|
if after, ok := strings.CutPrefix(proto, "token."); ok && after == token {
|
|
|
|
|
return proto
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
// readLoop reads messages from a WebSocket connection.
|
|
|
|
|
func (c *PicoChannel) readLoop(pc *picoConn) {
|
|
|
|
|
defer func() {
|
|
|
|
|
pc.close()
|
2026-03-24 15:25:27 +00:00
|
|
|
if removed := c.removeConnection(pc.id); removed != nil {
|
|
|
|
|
logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{
|
|
|
|
|
"conn_id": removed.id,
|
|
|
|
|
"session_id": removed.sessionID,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-22 20:55:15 +00:00
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
readTimeout := time.Duration(c.config.ReadTimeout) * time.Second
|
|
|
|
|
if readTimeout <= 0 {
|
|
|
|
|
readTimeout = 60 * time.Second
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
|
|
|
|
|
pc.conn.SetPongHandler(func(appData string) error {
|
|
|
|
|
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Start ping ticker
|
|
|
|
|
pingInterval := time.Duration(c.config.PingInterval) * time.Second
|
|
|
|
|
if pingInterval <= 0 {
|
|
|
|
|
pingInterval = 30 * time.Second
|
|
|
|
|
}
|
|
|
|
|
go c.pingLoop(pc, pingInterval)
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-c.ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, rawMsg, err := pc.conn.ReadMessage()
|
|
|
|
|
if err != nil {
|
|
|
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
|
|
|
|
logger.DebugCF("pico", "WebSocket read error", map[string]any{
|
|
|
|
|
"conn_id": pc.id,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
|
|
|
|
|
|
|
|
|
|
var msg PicoMessage
|
|
|
|
|
if err := json.Unmarshal(rawMsg, &msg); err != nil {
|
|
|
|
|
errMsg := newError("invalid_message", "failed to parse message")
|
|
|
|
|
pc.writeJSON(errMsg)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.handleMessage(pc, msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pingLoop sends periodic ping frames to keep the connection alive.
|
|
|
|
|
func (c *PicoChannel) pingLoop(pc *picoConn, interval time.Duration) {
|
|
|
|
|
ticker := time.NewTicker(interval)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-c.ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
if pc.closed.Load() {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
pc.writeMu.Lock()
|
|
|
|
|
err := pc.conn.WriteMessage(websocket.PingMessage, nil)
|
|
|
|
|
pc.writeMu.Unlock()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleMessage processes an inbound Pico Protocol message.
|
|
|
|
|
func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {
|
|
|
|
|
switch msg.Type {
|
|
|
|
|
case TypePing:
|
|
|
|
|
pong := newMessage(TypePong, nil)
|
|
|
|
|
pong.ID = msg.ID
|
|
|
|
|
pc.writeJSON(pong)
|
|
|
|
|
|
|
|
|
|
case TypeMessageSend:
|
|
|
|
|
c.handleMessageSend(pc, msg)
|
|
|
|
|
|
2026-04-07 13:19:11 +00:00
|
|
|
case TypeMediaSend:
|
|
|
|
|
c.handleMessageSend(pc, msg)
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
default:
|
|
|
|
|
errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
|
|
|
|
|
pc.writeJSON(errMsg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleMessageSend processes an inbound message.send from a client.
|
|
|
|
|
func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
|
|
|
|
|
content, _ := msg.Payload["content"].(string)
|
2026-04-07 13:19:11 +00:00
|
|
|
media, err := parseInlineImageMedia(msg.Payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
errMsg := newErrorWithPayload("invalid_media", err.Error(), map[string]any{
|
|
|
|
|
"request_id": msg.ID,
|
|
|
|
|
})
|
|
|
|
|
pc.writeJSON(errMsg)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(content) == "" && len(media) == 0 {
|
|
|
|
|
errMsg := newErrorWithPayload("empty_content", "message content is empty", map[string]any{
|
|
|
|
|
"request_id": msg.ID,
|
|
|
|
|
})
|
2026-02-22 20:55:15 +00:00
|
|
|
pc.writeJSON(errMsg)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sessionID := msg.SessionID
|
|
|
|
|
if sessionID == "" {
|
|
|
|
|
sessionID = pc.sessionID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatID := "pico:" + sessionID
|
|
|
|
|
senderID := "pico-user"
|
|
|
|
|
|
|
|
|
|
metadata := map[string]string{
|
|
|
|
|
"platform": "pico",
|
|
|
|
|
"session_id": sessionID,
|
|
|
|
|
"conn_id": pc.id,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.DebugCF("pico", "Received message", map[string]any{
|
|
|
|
|
"session_id": sessionID,
|
|
|
|
|
"preview": truncate(content, 50),
|
2026-04-07 13:19:11 +00:00
|
|
|
"media": len(media),
|
2026-02-22 20:55:15 +00:00
|
|
|
})
|
|
|
|
|
|
2026-02-22 22:56:48 +00:00
|
|
|
sender := bus.SenderInfo{
|
|
|
|
|
Platform: "pico",
|
|
|
|
|
PlatformID: senderID,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("pico", senderID),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !c.IsAllowedSender(sender) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
inboundCtx := bus.InboundContext{
|
|
|
|
|
Channel: "pico",
|
|
|
|
|
ChatID: chatID,
|
|
|
|
|
ChatType: "direct",
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
MessageID: msg.ID,
|
|
|
|
|
Raw: metadata,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 13:19:11 +00:00
|
|
|
c.HandleInboundContext(c.ctx, chatID, content, media, inboundCtx, sender)
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// truncate truncates a string to maxLen runes.
|
|
|
|
|
func truncate(s string, maxLen int) string {
|
|
|
|
|
runes := []rune(s)
|
|
|
|
|
if len(runes) <= maxLen {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
return string(runes[:maxLen]) + "..."
|
|
|
|
|
}
|
2026-04-07 13:19:11 +00:00
|
|
|
|
|
|
|
|
func parseInlineImageMedia(payload map[string]any) ([]string, error) {
|
|
|
|
|
if len(payload) == 0 {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 07:49:07 +00:00
|
|
|
media, err := parseInlineImageValues(payload["media"])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-04-07 13:19:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 07:49:07 +00:00
|
|
|
attachments, err := parseInlineImageAttachments(payload["attachments"])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
media = append(media, attachments...)
|
|
|
|
|
|
|
|
|
|
return media, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseInlineImageValues(raw any) ([]string, error) {
|
|
|
|
|
if raw == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
2026-04-07 13:19:11 +00:00
|
|
|
switch values := raw.(type) {
|
|
|
|
|
case []any:
|
|
|
|
|
media := make([]string, 0, len(values))
|
|
|
|
|
for i, item := range values {
|
|
|
|
|
value, err := inlineImageValue(item)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("media[%d]: %w", i, err)
|
|
|
|
|
}
|
|
|
|
|
if err := validateInlineImageDataURL(value); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("media[%d]: %w", i, err)
|
|
|
|
|
}
|
|
|
|
|
media = append(media, value)
|
|
|
|
|
}
|
|
|
|
|
return media, nil
|
|
|
|
|
case []string:
|
|
|
|
|
media := make([]string, 0, len(values))
|
|
|
|
|
for i, value := range values {
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
if err := validateInlineImageDataURL(value); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("media[%d]: %w", i, err)
|
|
|
|
|
}
|
|
|
|
|
media = append(media, value)
|
|
|
|
|
}
|
|
|
|
|
return media, nil
|
|
|
|
|
case string:
|
|
|
|
|
value := strings.TrimSpace(values)
|
|
|
|
|
if err := validateInlineImageDataURL(value); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return []string{value}, nil
|
|
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("media must be a string or array of strings")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 07:49:07 +00:00
|
|
|
func parseInlineImageAttachments(raw any) ([]string, error) {
|
|
|
|
|
if raw == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
values, ok := raw.([]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("attachments must be an array")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
media := make([]string, 0, len(values))
|
|
|
|
|
for i, item := range values {
|
|
|
|
|
attachment, ok := item.(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("attachments[%d]: attachment must be an object", i)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
attachmentType, _ := attachment["type"].(string)
|
|
|
|
|
attachmentType = strings.ToLower(strings.TrimSpace(attachmentType))
|
|
|
|
|
if attachmentType != "" && attachmentType != "image" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value, err := inlineImageValue(attachment)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if attachmentType == "image" {
|
|
|
|
|
return nil, fmt.Errorf("attachments[%d]: %w", i, err)
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(value, "data:") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err := validateInlineImageDataURL(value); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("attachments[%d]: %w", i, err)
|
|
|
|
|
}
|
|
|
|
|
media = append(media, value)
|
|
|
|
|
}
|
|
|
|
|
return media, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 13:19:11 +00:00
|
|
|
func inlineImageValue(item any) (string, error) {
|
|
|
|
|
switch value := item.(type) {
|
|
|
|
|
case string:
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
if value == "" {
|
|
|
|
|
return "", fmt.Errorf("image payload is empty")
|
|
|
|
|
}
|
|
|
|
|
return value, nil
|
|
|
|
|
case map[string]any:
|
|
|
|
|
for _, key := range []string{"url", "data_url"} {
|
|
|
|
|
if raw, ok := value[key].(string); ok && strings.TrimSpace(raw) != "" {
|
|
|
|
|
return strings.TrimSpace(raw), nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return "", fmt.Errorf("image payload must include url or data_url")
|
|
|
|
|
default:
|
|
|
|
|
return "", fmt.Errorf("image payload must be a string or object")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateInlineImageDataURL(mediaURL string) error {
|
|
|
|
|
if mediaURL == "" {
|
|
|
|
|
return fmt.Errorf("image payload is empty")
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(mediaURL, "data:image/") {
|
|
|
|
|
return fmt.Errorf("only inline image data URLs are supported")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
header, data, found := strings.Cut(mediaURL, ",")
|
|
|
|
|
if !found || strings.TrimSpace(data) == "" {
|
|
|
|
|
return fmt.Errorf("image data URL is malformed")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(header, ";base64") {
|
|
|
|
|
return fmt.Errorf("image data URL must be base64 encoded")
|
|
|
|
|
}
|
|
|
|
|
mimeType, _, _ := strings.Cut(strings.TrimPrefix(header, "data:"), ";")
|
|
|
|
|
if _, ok := allowedInlineImageMIMETypes[mimeType]; !ok {
|
|
|
|
|
return fmt.Errorf("unsupported image format: %s", mimeType)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data = strings.TrimSpace(data)
|
|
|
|
|
if base64.StdEncoding.DecodedLen(len(data)) > config.DefaultMaxMediaSize {
|
|
|
|
|
return fmt.Errorf("image exceeds %d byte limit", config.DefaultMaxMediaSize)
|
|
|
|
|
}
|
|
|
|
|
if _, err := base64.StdEncoding.DecodeString(data); err != nil {
|
|
|
|
|
return fmt.Errorf("invalid base64 image data")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-04-21 08:30:02 +00:00
|
|
|
|
|
|
|
|
// setContextUsagePayload adds context window usage stats to a pico payload.
|
|
|
|
|
func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
|
|
|
|
|
if u == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload["context_usage"] = map[string]any{
|
2026-06-02 07:43:24 +00:00
|
|
|
"used_tokens": u.UsedTokens,
|
|
|
|
|
"total_tokens": u.TotalTokens,
|
2026-06-05 16:28:32 +00:00
|
|
|
"history_tokens": u.HistoryTokens,
|
2026-06-02 07:43:24 +00:00
|
|
|
"compress_at_tokens": u.CompressAtTokens,
|
|
|
|
|
"summarize_at_tokens": u.SummarizeAtTokens,
|
|
|
|
|
"used_percent": u.UsedPercent,
|
2026-04-21 08:30:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
|
2026-06-22 11:07:48 +00:00
|
|
|
// setTurnUsagePayload attaches real per-turn LLM token usage to the payload.
|
|
|
|
|
// Input and output are kept separate (billed at different rates); total is a
|
|
|
|
|
// convenience sum. Omitted entirely when both counts are zero.
|
|
|
|
|
func setTurnUsagePayload(payload map[string]any, inputTokens, outputTokens int) {
|
|
|
|
|
if inputTokens <= 0 && outputTokens <= 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload[PayloadKeyUsage] = map[string]any{
|
|
|
|
|
"input_tokens": inputTokens,
|
|
|
|
|
"output_tokens": outputTokens,
|
|
|
|
|
"total_tokens": inputTokens + outputTokens,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 15:43:10 +00:00
|
|
|
func picoToolCallsPayload(msg bus.OutboundMessage) ([]utils.VisibleToolCall, bool) {
|
|
|
|
|
raw := strings.TrimSpace(msg.Context.Raw[PayloadKeyToolCalls])
|
|
|
|
|
if raw == "" {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var toolCalls []utils.VisibleToolCall
|
|
|
|
|
if err := json.Unmarshal([]byte(raw), &toolCalls); err != nil || len(toolCalls) == 0 {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return toolCalls, true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
func (c *PicoChannel) editMessage(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
chatID string,
|
|
|
|
|
messageID string,
|
|
|
|
|
content string,
|
|
|
|
|
contextUsage *bus.ContextUsage,
|
|
|
|
|
) error {
|
2026-05-20 05:42:21 +00:00
|
|
|
return c.editMessagePayload(ctx, chatID, messageID, map[string]any{
|
|
|
|
|
PayloadKeyContent: content,
|
|
|
|
|
}, contextUsage)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *PicoChannel) editMessagePayload(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
chatID string,
|
|
|
|
|
messageID string,
|
|
|
|
|
payload map[string]any,
|
|
|
|
|
contextUsage *bus.ContextUsage,
|
|
|
|
|
) error {
|
|
|
|
|
if payload == nil {
|
|
|
|
|
payload = map[string]any{}
|
|
|
|
|
}
|
|
|
|
|
normalized := make(map[string]any, len(payload)+1)
|
|
|
|
|
for key, value := range payload {
|
|
|
|
|
normalized[key] = value
|
|
|
|
|
}
|
|
|
|
|
if _, ok := normalized[PayloadKeyContent]; !ok {
|
|
|
|
|
normalized[PayloadKeyContent] = ""
|
2026-04-23 02:35:50 +00:00
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
normalized["message_id"] = messageID
|
|
|
|
|
setContextUsagePayload(normalized, contextUsage)
|
|
|
|
|
outMsg := newMessage(TypeMessageUpdate, normalized)
|
2026-04-23 02:35:50 +00:00
|
|
|
return c.broadcastToSession(chatID, outMsg)
|
|
|
|
|
}
|