feat(web): download files on frontend (#2563)

* feat(web): download attachments in frontend

* fix: proxy pico media and force svg downloads

* feat(web): hide ephemeral media refs from persisted session history
This commit is contained in:
Mauro
2026-04-22 05:28:04 +02:00
committed by GitHub
parent 023ca2e4c1
commit 3316ee6923
18 changed files with 909 additions and 73 deletions
+73 -31
View File
@@ -24,6 +24,8 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
// This allows the frontend to connect via the same port as the web UI,
// avoiding the need to expose extra ports for WebSocket communication.
mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy())
mux.HandleFunc("GET /pico/media/{id}", h.handlePicoMediaProxy())
mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
}
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
@@ -55,6 +57,53 @@ func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *h
return wsProxy
}
func (h *Handler) createPicoHTTPProxy(token string) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
target := h.gatewayProxyURL()
r.SetURL(target)
r.Out.Header.Set("Authorization", "Bearer "+token)
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
logger.Errorf("Failed to proxy Pico HTTP request: %v", err)
http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
},
}
}
func (h *Handler) gatewayAvailableForProxy() bool {
gateway.mu.Lock()
ensurePicoTokenCachedLocked(h.configPath)
cachedPID := gateway.pidData
trackedCmd := gateway.cmd
gateway.mu.Unlock()
if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
gateway.mu.Lock()
gateway.pidData = pidData
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
return true
}
if cachedPID == nil {
return false
}
if isCmdProcessAliveLocked(trackedCmd) {
return true
}
gateway.mu.Lock()
if gateway.cmd == trackedCmd {
gateway.pidData = nil
setGatewayRuntimeStatusLocked("stopped")
}
available := gateway.pidData != nil
gateway.mu.Unlock()
return available
}
func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) {
if cfg == nil {
return config.PicoSettings{}, false
@@ -101,37 +150,7 @@ func (h *Handler) writePicoInfoResponse(
// on the upstream gateway request.
func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
ensurePicoTokenCachedLocked(h.configPath)
cachedPID := gateway.pidData
trackedCmd := gateway.cmd
gateway.mu.Unlock()
gatewayAvailable := false
// Prefer fresh PID file data when available.
if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
gateway.mu.Lock()
gateway.pidData = pidData
setGatewayRuntimeStatusLocked("running")
gatewayAvailable = true
gateway.mu.Unlock()
} else if cachedPID != nil {
// No PID file now: keep availability only while tracked process is
// still alive (covers short PID-file races at startup/restart).
if isCmdProcessAliveLocked(trackedCmd) {
gatewayAvailable = true
} else {
gateway.mu.Lock()
if gateway.cmd == trackedCmd {
gateway.pidData = nil
setGatewayRuntimeStatusLocked("stopped")
}
gatewayAvailable = gateway.pidData != nil
gateway.mu.Unlock()
}
}
if !gatewayAvailable {
if !h.gatewayAvailableForProxy() {
logger.Warnf("Gateway not available for WebSocket proxy")
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
return
@@ -153,6 +172,29 @@ func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
}
}
func (h *Handler) handlePicoMediaProxy() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !h.gatewayAvailableForProxy() {
logger.Warnf("Gateway not available for Pico media proxy")
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
return
}
gateway.mu.Lock()
uiToken := gateway.picoToken
gateway.mu.Unlock()
token := tokenPrefix + uiToken
if token == "" {
logger.Warnf("Missing Pico token for media proxy")
http.Error(w, "Invalid Pico token", http.StatusForbidden)
return
}
h.createPicoHTTPProxy(token).ServeHTTP(w, r)
}
}
// handleGetPicoInfo returns non-secret Pico connection info for the launcher UI.
//
// GET /api/pico/info
+55
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
@@ -649,6 +650,54 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
}
}
func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = 18790
bc := cfg.Channels["pico"]
bc.Enabled = true
decoded, err := bc.GetDecoded()
if err != nil {
t.Fatalf("GetDecoded() error = %v", err)
}
decoded.(*config.PicoSettings).SetToken("ui-token")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
proxy := h.createPicoHTTPProxy(tokenPrefix + "test-token" + "ui-token")
var capturedPath string
var capturedAuth string
proxy.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
capturedPath = req.URL.Path
capturedAuth = req.Header.Get("Authorization")
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("proxied")),
Request: req,
}, nil
})
req := httptest.NewRequest(http.MethodGet, "/pico/media/attachment-1", nil)
rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if capturedPath != "/pico/media/attachment-1" {
t.Fatalf("capturedPath = %q, want %q", capturedPath, "/pico/media/attachment-1")
}
expected := "Bearer " + tokenPrefix + "test-token" + "ui-token"
if capturedAuth != expected {
t.Fatalf("Authorization = %q, want %q", capturedAuth, expected)
}
}
func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
@@ -797,3 +846,9 @@ func mustGatewayTestPort(t *testing.T, rawURL string) int {
return port
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
+133 -25
View File
@@ -46,9 +46,17 @@ type sessionListItem struct {
}
type sessionChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
Role string `json:"role"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
}
type sessionChatAttachment struct {
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
ContentType string `json:"content_type,omitempty"`
}
// legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL
@@ -398,10 +406,12 @@ func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessio
}
func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem {
transcript := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
preview := ""
for _, msg := range sess.Messages {
for _, msg := range transcript {
if msg.Role == "user" {
preview = sessionMessagePreview(msg)
preview = sessionChatMessagePreview(msg)
}
if preview != "" {
break
@@ -414,13 +424,11 @@ func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArg
}
title := preview
validMessageCount := len(visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength))
return sessionListItem{
ID: sessionID,
Title: title,
Preview: preview,
MessageCount: validMessageCount,
MessageCount: len(transcript),
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
}
@@ -441,16 +449,25 @@ func truncateRunes(s string, maxLen int) string {
return string(runes[:maxLen]) + "..."
}
func sessionMessageVisible(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0
func sessionChatMessageVisible(msg sessionChatMessage) bool {
return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 || len(msg.Attachments) > 0
}
func sessionMessagePreview(msg providers.Message) string {
func sessionChatMessagePreview(msg sessionChatMessage) string {
if content := strings.TrimSpace(msg.Content); content != "" {
return content
}
if len(msg.Attachments) > 0 {
if strings.EqualFold(strings.TrimSpace(msg.Attachments[0].Type), "image") {
return "[image]"
}
return "[attachment]"
}
if len(msg.Media) > 0 {
return "[image]"
if strings.HasPrefix(strings.TrimSpace(msg.Media[0]), "data:image/") {
return "[image]"
}
return "[attachment]"
}
return ""
}
@@ -459,17 +476,21 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
transcript := make([]sessionChatMessage, 0, len(messages))
for _, msg := range messages {
attachments := sessionAttachments(msg)
switch msg.Role {
case "tool":
continue
case "user":
if sessionMessageVisible(msg) {
transcript = append(transcript, sessionChatMessage{
Role: "user",
Content: msg.Content,
Media: append([]string(nil), msg.Media...),
})
chatMsg := sessionChatMessage{
Role: "user",
Content: msg.Content,
Media: append([]string(nil), msg.Media...),
Attachments: attachments,
}
if sessionChatMessageVisible(chatMsg) {
transcript = append(transcript, chatMsg)
}
case "assistant":
@@ -492,15 +513,25 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
// Pico web chat can persist both visible `message` tool output and a
// later plain assistant reply in the same turn. Hide only the fixed
// internal summary that marks handled tool delivery.
if !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
content := msg.Content
if assistantMessageInternalOnly(msg) {
if len(attachments) == 0 {
continue
}
content = ""
}
chatMsg := sessionChatMessage{
Role: "assistant",
Content: content,
Media: append([]string(nil), msg.Media...),
Attachments: attachments,
}
if !sessionChatMessageVisible(chatMsg) {
continue
}
transcript = append(transcript, sessionChatMessage{
Role: "assistant",
Content: msg.Content,
Media: append([]string(nil), msg.Media...),
})
transcript = append(transcript, chatMsg)
}
}
@@ -518,11 +549,88 @@ func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessa
return filtered
}
func sessionAttachments(msg providers.Message) []sessionChatAttachment {
if len(msg.Attachments) == 0 {
return nil
}
attachments := make([]sessionChatAttachment, 0, len(msg.Attachments))
for _, attachment := range msg.Attachments {
urlValue, ok := sessionAttachmentURL(attachment)
if !ok {
continue
}
attachmentType := strings.TrimSpace(attachment.Type)
if attachmentType == "" {
attachmentType = sessionAttachmentType(attachment)
}
attachments = append(attachments, sessionChatAttachment{
Type: attachmentType,
URL: urlValue,
Filename: strings.TrimSpace(attachment.Filename),
ContentType: strings.TrimSpace(attachment.ContentType),
})
}
if len(attachments) == 0 {
return nil
}
return attachments
}
func sessionAttachmentURL(attachment providers.Attachment) (string, bool) {
if rawURL := strings.TrimSpace(attachment.URL); rawURL != "" {
return rawURL, true
}
ref := strings.TrimSpace(attachment.Ref)
if ref == "" {
return "", false
}
if strings.HasPrefix(ref, "media://") {
// Persisted session history must only expose durable attachment locations.
// media:// refs depend on the live in-memory MediaStore and may stop
// resolving after a restart or cleanup, so omit them from reopened history.
return "", false
}
return ref, true
}
func sessionAttachmentType(attachment providers.Attachment) string {
contentType := strings.ToLower(strings.TrimSpace(attachment.ContentType))
filename := strings.ToLower(strings.TrimSpace(attachment.Filename))
rawRef := strings.ToLower(strings.TrimSpace(attachment.Ref))
rawURL := strings.ToLower(strings.TrimSpace(attachment.URL))
switch {
case strings.HasPrefix(contentType, "image/"),
strings.HasPrefix(rawRef, "data:image/"),
strings.HasPrefix(rawURL, "data: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 assistantMessageTransientThought(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == "" &&
strings.TrimSpace(msg.ReasoningContent) != "" &&
len(msg.ToolCalls) == 0 &&
len(msg.Media) == 0
len(msg.Media) == 0 &&
len(msg.Attachments) == 0
}
func assistantMessageInternalOnly(msg providers.Message) bool {
+130
View File
@@ -218,6 +218,136 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
}
}
func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := legacyPicoSessionPrefix + "attachment-history"
for _, msg := range []providers.Message{
{Role: "user", Content: "send me the report"},
{
Role: "assistant",
Content: handledToolResponseSummaryText,
Attachments: []providers.Attachment{{
Type: "file",
Ref: "media://attachment-1",
Filename: "report.txt",
ContentType: "text/plain",
}},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []sessionChatMessage `json:"messages"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 1 {
t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
}
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "send me the report" {
t.Fatalf("message = %#v, want only user request", resp.Messages[0])
}
}
func TestHandleGetSession_ExposesHandledToolAttachmentsWithDurableURL(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := legacyPicoSessionPrefix + "attachment-history-durable"
for _, msg := range []providers.Message{
{Role: "user", Content: "send me the report"},
{
Role: "assistant",
Content: handledToolResponseSummaryText,
Attachments: []providers.Attachment{{
Type: "file",
URL: "https://example.com/report.txt",
Filename: "report.txt",
ContentType: "text/plain",
}},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history-durable", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []sessionChatMessage `json:"messages"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 2 {
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
}
assistant := resp.Messages[1]
if assistant.Role != "assistant" {
t.Fatalf("assistant role = %q, want assistant", assistant.Role)
}
if assistant.Content != "" {
t.Fatalf("assistant content = %q, want empty string", assistant.Content)
}
if len(assistant.Attachments) != 1 {
t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments))
}
if assistant.Attachments[0].URL != "https://example.com/report.txt" {
t.Fatalf(
"attachment url = %q, want %q",
assistant.Attachments[0].URL,
"https://example.com/report.txt",
)
}
if assistant.Attachments[0].Filename != "report.txt" {
t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt")
}
}
func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()