From 28fab9507a6a539f6e7091f188fb3e95f7fdd51f Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 14 Mar 2026 18:13:52 +0700 Subject: [PATCH] feat(storage): add lazy folder loading, SSE size endpoint, and enhanced file viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: depth-limited WalkDir (max 3 levels default) with on-demand subtree loading - Backend: new GET /v1/storage/size SSE endpoint with 60min in-memory cache - Backend: raw binary file serving (?raw=true) with MIME detection and download support - Frontend: lazy tree expansion with loading spinners for deep folders - Frontend: streaming size display with cache info tooltip - Frontend: image viewer (blob URL), unsupported file UI, download button, colored size badges - Frontend: file-type icons for 13 categories (md, json, yaml, images, video, etc.) - Fix sidebar connection status text overflow on collapse - Apply go fix modernization (interface{} → any) across http handlers --- internal/http/agents.go | 6 +- internal/http/agents_instances.go | 4 +- internal/http/agents_sharing.go | 6 +- internal/http/auth.go | 10 +- internal/http/builtin_tools.go | 4 +- internal/http/channel_instances.go | 24 +-- internal/http/chat_completions.go | 30 +-- internal/http/custom_tools.go | 4 +- internal/http/delegations.go | 2 +- internal/http/knowledge_graph_handlers.go | 6 +- internal/http/mcp.go | 4 +- internal/http/mcp_grants.go | 4 +- internal/http/mcp_requests.go | 2 +- internal/http/mcp_tools.go | 8 +- internal/http/media_upload.go | 2 +- internal/http/memory_handlers.go | 2 +- internal/http/oauth_test.go | 4 +- internal/http/pending_messages.go | 14 +- internal/http/provider_models.go | 9 +- internal/http/provider_verify.go | 30 +-- internal/http/providers.go | 20 +- internal/http/responses.go | 22 +- internal/http/skills.go | 16 +- internal/http/skills_grants.go | 2 +- internal/http/skills_upload.go | 2 +- internal/http/skills_versions.go | 8 +- internal/http/storage.go | 194 +++++++++++++----- internal/http/summoner_regenerate.go | 4 +- internal/http/tools_invoke.go | 30 +-- internal/http/traces.go | 6 +- ui/web/src/api/http-client.ts | 35 +++- .../components/layout/connection-status.tsx | 10 +- ui/web/src/components/layout/sidebar.tsx | 4 +- ui/web/src/components/shared/file-browser.tsx | 45 +++- ui/web/src/components/shared/file-tree.tsx | 77 ++++++- ui/web/src/components/shared/file-viewers.tsx | 151 ++++++++++++-- ui/web/src/components/shared/page-header.tsx | 2 +- ui/web/src/i18n/locales/en/storage.json | 3 + ui/web/src/i18n/locales/vi/storage.json | 3 + ui/web/src/i18n/locales/zh/storage.json | 3 + ui/web/src/lib/file-helpers.ts | 65 +++++- ui/web/src/pages/storage/hooks/use-storage.ts | 87 +++++++- ui/web/src/pages/storage/storage-page.tsx | 118 +++++++++-- 43 files changed, 825 insertions(+), 257 deletions(-) diff --git a/internal/http/agents.go b/internal/http/agents.go index 245c89a3..9b7710c0 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -20,8 +20,8 @@ import ( type AgentsHandler struct { agents store.AgentStore token string - msgBus *bus.MessageBus // for cache invalidation events (nil = no events) - summoner *AgentSummoner // LLM-based agent setup (nil = disabled) + msgBus *bus.MessageBus // for cache invalidation events (nil = no events) + summoner *AgentSummoner // LLM-based agent setup (nil = disabled) isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) } @@ -105,7 +105,7 @@ func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, map[string]interface{}{"agents": agents}) + writeJSON(w, http.StatusOK, map[string]any{"agents": agents}) } func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/agents_instances.go b/internal/http/agents_instances.go index feed0f71..717cbacb 100644 --- a/internal/http/agents_instances.go +++ b/internal/http/agents_instances.go @@ -38,7 +38,7 @@ func (h *AgentsHandler) handleListInstances(w http.ResponseWriter, r *http.Reque return } - writeJSON(w, http.StatusOK, map[string]interface{}{"instances": instances}) + writeJSON(w, http.StatusOK, map[string]any{"instances": instances}) } // handleGetInstanceFiles returns user context files for a specific instance. @@ -72,7 +72,7 @@ func (h *AgentsHandler) handleGetInstanceFiles(w http.ResponseWriter, r *http.Re return } - writeJSON(w, http.StatusOK, map[string]interface{}{"files": files}) + writeJSON(w, http.StatusOK, map[string]any{"files": files}) } // handleSetInstanceFile updates a user context file for a specific instance. diff --git a/internal/http/agents_sharing.go b/internal/http/agents_sharing.go index 3c9e1a94..f9cd8755 100644 --- a/internal/http/agents_sharing.go +++ b/internal/http/agents_sharing.go @@ -36,7 +36,7 @@ func (h *AgentsHandler) handleListShares(w http.ResponseWriter, r *http.Request) return } - writeJSON(w, http.StatusOK, map[string]interface{}{"shares": shares}) + writeJSON(w, http.StatusOK, map[string]any{"shares": shares}) } func (h *AgentsHandler) handleShare(w http.ResponseWriter, r *http.Request) { @@ -225,7 +225,7 @@ func extractDescription(raw json.RawMessage) string { if len(raw) == 0 { return "" } - var cfg map[string]interface{} + var cfg map[string]any if json.Unmarshal(raw, &cfg) != nil { return "" } @@ -233,7 +233,7 @@ func extractDescription(raw json.RawMessage) string { return desc } -func writeJSON(w http.ResponseWriter, status int, data interface{}) { +func writeJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) diff --git a/internal/http/auth.go b/internal/http/auth.go index 38df3c5d..a7a46b5b 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -50,11 +50,11 @@ func extractUserID(r *http.Request) string { // Checks model field, headers, and falls back to "default". func extractAgentID(r *http.Request, model string) string { // From model field: "goclaw:" or "agent:" - if strings.HasPrefix(model, "goclaw:") { - return strings.TrimPrefix(model, "goclaw:") + if after, ok := strings.CutPrefix(model, "goclaw:"); ok { + return after } - if strings.HasPrefix(model, "agent:") { - return strings.TrimPrefix(model, "agent:") + if after, ok := strings.CutPrefix(model, "agent:"); ok { + return after } // From headers @@ -76,7 +76,7 @@ func extractLocale(r *http.Request) string { return i18n.DefaultLocale } // Simple parser: take the first language tag before comma or semicolon - for _, part := range strings.Split(accept, ",") { + for part := range strings.SplitSeq(accept, ",") { tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) locale := i18n.Normalize(tag) if locale != i18n.DefaultLocale || strings.HasPrefix(tag, "en") { diff --git a/internal/http/builtin_tools.go b/internal/http/builtin_tools.go index 487ecd97..3f06b088 100644 --- a/internal/http/builtin_tools.go +++ b/internal/http/builtin_tools.go @@ -62,7 +62,7 @@ func (h *BuiltinToolsHandler) handleList(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")}) return } - writeJSON(w, http.StatusOK, map[string]interface{}{"tools": result}) + writeJSON(w, http.StatusOK, map[string]any{"tools": result}) } func (h *BuiltinToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) { @@ -90,7 +90,7 @@ func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Reques return } - var updates map[string]interface{} + var updates map[string]any if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index ee40fe7c..4c15a058 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -111,12 +111,12 @@ func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Requ total, _ := h.store.CountInstances(r.Context(), opts) - result := make([]map[string]interface{}, 0, len(instances)) + result := make([]map[string]any, 0, len(instances)) for _, inst := range instances { result = append(result, maskInstanceHTTP(inst)) } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "instances": result, "total": total, "limit": opts.Limit, @@ -210,7 +210,7 @@ func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Re return } - var updates map[string]interface{} + var updates map[string]any if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return @@ -258,8 +258,8 @@ func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Re } // maskInstanceHTTP returns a map with credentials masked for HTTP responses. -func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]interface{} { - result := map[string]interface{}{ +func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]any { + result := map[string]any{ "id": inst.ID, "name": inst.Name, "display_name": inst.DisplayName, @@ -275,9 +275,9 @@ func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]interface{} { } if len(inst.Credentials) > 0 { - var raw map[string]interface{} + var raw map[string]any if json.Unmarshal(inst.Credentials, &raw) == nil { - masked := make(map[string]interface{}, len(raw)) + masked := make(map[string]any, len(raw)) for k := range raw { masked[k] = "***" } @@ -325,7 +325,7 @@ func (h *ChannelInstancesHandler) handleWriterGroups(w http.ResponseWriter, r *h if groups == nil { groups = []store.GroupWriterGroupInfo{} } - writeJSON(w, http.StatusOK, map[string]interface{}{"groups": groups}) + writeJSON(w, http.StatusOK, map[string]any{"groups": groups}) } func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *http.Request) { @@ -348,7 +348,7 @@ func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *ht if writers == nil { writers = []store.GroupFileWriterData{} } - writeJSON(w, http.StatusOK, map[string]interface{}{"writers": writers}) + writeJSON(w, http.StatusOK, map[string]any{"writers": writers}) } func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http.Request) { @@ -443,7 +443,7 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h slog.Warn("contacts.count", "error", countErr) } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "contacts": contacts, "total": total, "limit": opts.Limit, @@ -454,7 +454,7 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r *http.Request) { idsParam := r.URL.Query().Get("ids") if idsParam == "" { - writeJSON(w, http.StatusOK, map[string]interface{}{"contacts": map[string]any{}}) + writeJSON(w, http.StatusOK, map[string]any{"contacts": map[string]any{}}) return } @@ -471,7 +471,7 @@ func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r return } - writeJSON(w, http.StatusOK, map[string]interface{}{"contacts": result}) + writeJSON(w, http.StatusOK, map[string]any{"contacts": result}) } // isValidChannelType checks if the channel type is supported. diff --git a/internal/http/chat_completions.go b/internal/http/chat_completions.go index 15bb8dbc..b8657527 100644 --- a/internal/http/chat_completions.go +++ b/internal/http/chat_completions.go @@ -19,9 +19,9 @@ import ( type ChatCompletionsHandler struct { agents *agent.Router sessions store.SessionStore - token string // expected bearer token (empty = no auth) + token string // expected bearer token (empty = no auth) isManaged bool - rateLimiter func(string) bool // rate limit check: key → allowed (nil = no limit) + rateLimiter func(string) bool // rate limit check: key → allowed (nil = no limit) } // NewChatCompletionsHandler creates a handler for the chat completions endpoint. @@ -40,10 +40,10 @@ func (h *ChatCompletionsHandler) SetRateLimiter(fn func(string) bool) { } type chatCompletionsRequest struct { - Model string `json:"model"` - Messages []chatMessage `json:"messages"` - Stream bool `json:"stream"` - User string `json:"user,omitempty"` + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Stream bool `json:"stream"` + User string `json:"user,omitempty"` } type chatMessage struct { @@ -53,12 +53,12 @@ type chatMessage struct { } type chatCompletionsResponse struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - Model string `json:"model"` - Choices []chatChoice `json:"choices"` - Usage *chatUsage `json:"usage,omitempty"` + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []chatChoice `json:"choices"` + Usage *chatUsage `json:"usage,omitempty"` } type chatChoice struct { @@ -247,12 +247,12 @@ func (h *ChatCompletionsHandler) handleStream(w http.ResponseWriter, r *http.Req } func writeSSEChunk(w http.ResponseWriter, flusher http.Flusher, id, model string, delta *chatMessage, finishReason string) { - chunk := map[string]interface{}{ + chunk := map[string]any{ "id": id, "object": "chat.completion.chunk", "created": time.Now().Unix(), "model": model, - "choices": []map[string]interface{}{{ + "choices": []map[string]any{{ "index": 0, "delta": delta, "finish_reason": nilIfEmpty(finishReason), @@ -264,7 +264,7 @@ func writeSSEChunk(w http.ResponseWriter, flusher http.Flusher, id, model string flusher.Flush() } -func nilIfEmpty(s string) interface{} { +func nilIfEmpty(s string) any { if s == "" { return nil } diff --git a/internal/http/custom_tools.go b/internal/http/custom_tools.go index d3900aaf..b463cbb2 100644 --- a/internal/http/custom_tools.go +++ b/internal/http/custom_tools.go @@ -104,7 +104,7 @@ func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request) total, _ := h.store.CountTools(r.Context(), opts) - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "tools": result, "total": total, "limit": opts.Limit, @@ -182,7 +182,7 @@ func (h *CustomToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request return } - var updates map[string]interface{} + var updates map[string]any if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return diff --git a/internal/http/delegations.go b/internal/http/delegations.go index 49560dc2..8351fb97 100644 --- a/internal/http/delegations.go +++ b/internal/http/delegations.go @@ -85,7 +85,7 @@ func (h *DelegationsHandler) handleList(w http.ResponseWriter, r *http.Request) return } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "records": records, "total": total, }) diff --git a/internal/http/knowledge_graph_handlers.go b/internal/http/knowledge_graph_handlers.go index 6d3e6aaa..63472969 100644 --- a/internal/http/knowledge_graph_handlers.go +++ b/internal/http/knowledge_graph_handlers.go @@ -73,7 +73,7 @@ func (h *KnowledgeGraphHandler) handleGetEntity(w http.ResponseWriter, r *http.R relations = []store.Relation{} } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "entity": entity, "relations": relations, }) @@ -208,7 +208,7 @@ func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Req return } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "entities": len(result.Entities), "relations": len(result.Relations), }) @@ -257,7 +257,7 @@ func (h *KnowledgeGraphHandler) handleGraph(w http.ResponseWriter, r *http.Reque relations = []store.Relation{} } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "entities": entities, "relations": relations, }) diff --git a/internal/http/mcp.go b/internal/http/mcp.go index 49b548fa..7fd958a2 100644 --- a/internal/http/mcp.go +++ b/internal/http/mcp.go @@ -115,7 +115,7 @@ func (h *MCPHandler) handleListServers(w http.ResponseWriter, r *http.Request) { result[i] = mcpServerWithCounts{MCPServerData: srv, AgentCount: counts[srv.ID]} } - writeJSON(w, http.StatusOK, map[string]interface{}{"servers": result}) + writeJSON(w, http.StatusOK, map[string]any{"servers": result}) } func (h *MCPHandler) handleCreateServer(w http.ResponseWriter, r *http.Request) { @@ -176,7 +176,7 @@ func (h *MCPHandler) handleUpdateServer(w http.ResponseWriter, r *http.Request) return } - var updates map[string]interface{} + var updates map[string]any if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return diff --git a/internal/http/mcp_grants.go b/internal/http/mcp_grants.go index 76a15b84..aebe0c91 100644 --- a/internal/http/mcp_grants.go +++ b/internal/http/mcp_grants.go @@ -95,7 +95,7 @@ func (h *MCPHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Reques return } - writeJSON(w, http.StatusOK, map[string]interface{}{"grants": grants}) + writeJSON(w, http.StatusOK, map[string]any{"grants": grants}) } func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Request) { @@ -113,7 +113,7 @@ func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Reque return } - writeJSON(w, http.StatusOK, map[string]interface{}{"grants": grants}) + writeJSON(w, http.StatusOK, map[string]any{"grants": grants}) } func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/mcp_requests.go b/internal/http/mcp_requests.go index 579e9858..fc6c49a8 100644 --- a/internal/http/mcp_requests.go +++ b/internal/http/mcp_requests.go @@ -49,7 +49,7 @@ func (h *MCPHandler) handleListPendingRequests(w http.ResponseWriter, r *http.Re return } - writeJSON(w, http.StatusOK, map[string]interface{}{"requests": requests}) + writeJSON(w, http.StatusOK, map[string]any{"requests": requests}) } func (h *MCPHandler) handleReviewRequest(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/mcp_tools.go b/internal/http/mcp_tools.go index e9f92f21..bcaa33dc 100644 --- a/internal/http/mcp_tools.go +++ b/internal/http/mcp_tools.go @@ -8,8 +8,8 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/i18n" - "github.com/nextlevelbuilder/goclaw/internal/store" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // handleTestConnection tests an MCP server connection without saving it. @@ -34,14 +34,14 @@ func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request tools, err := mcpbridge.DiscoverTools(r.Context(), req.Transport, req.Command, req.Args, req.Env, req.URL, req.Headers) if err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "success": false, "error": err.Error(), }) return } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "success": true, "tool_count": len(tools), }) @@ -92,5 +92,5 @@ func (h *MCPHandler) handleListServerTools(w http.ResponseWriter, r *http.Reques if tools == nil { tools = []mcpbridge.ToolInfo{} } - writeJSON(w, http.StatusOK, map[string]interface{}{"tools": tools}) + writeJSON(w, http.StatusOK, map[string]any{"tools": tools}) } diff --git a/internal/http/media_upload.go b/internal/http/media_upload.go index 32483dd8..56d940aa 100644 --- a/internal/http/media_upload.go +++ b/internal/http/media_upload.go @@ -94,7 +94,7 @@ func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request mimeType := media.DetectMIMEType(origName) - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "path": tmpPath, "mime_type": mimeType, "filename": origName, diff --git a/internal/http/memory_handlers.go b/internal/http/memory_handlers.go index ee2c287a..d35f630f 100644 --- a/internal/http/memory_handlers.go +++ b/internal/http/memory_handlers.go @@ -189,7 +189,7 @@ func (h *MemoryHandler) handleSearch(w http.ResponseWriter, r *http.Request) { if results == nil { results = []store.MemorySearchResult{} } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "results": results, "count": len(results), }) diff --git a/internal/http/oauth_test.go b/internal/http/oauth_test.go index 11cadd55..0eb4d746 100644 --- a/internal/http/oauth_test.go +++ b/internal/http/oauth_test.go @@ -133,7 +133,7 @@ func TestOAuthHandlerStatusNoToken(t *testing.T) { t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK) } - var result map[string]interface{} + var result map[string]any json.NewDecoder(w.Body).Decode(&result) if result["authenticated"] != false { @@ -228,7 +228,7 @@ func TestOAuthHandlerStartReturnsAuthURL(t *testing.T) { t.Fatalf("status code = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) } - var result map[string]interface{} + var result map[string]any json.NewDecoder(w.Body).Decode(&result) _, hasURL := result["auth_url"] diff --git a/internal/http/pending_messages.go b/internal/http/pending_messages.go index d504a383..602a7887 100644 --- a/internal/http/pending_messages.go +++ b/internal/http/pending_messages.go @@ -19,10 +19,10 @@ type PendingMessagesHandler struct { agentStore store.AgentStore token string providerReg *providers.Registry - keepRecent int // global keepRecent from config (0 = use default 15) - maxTokens int // max output tokens for LLM summarization (0 = use default) - cfgProvider string // config-level provider override (empty = resolve from agent) - cfgModel string // config-level model override (empty = resolve from agent) + keepRecent int // global keepRecent from config (0 = use default 15) + maxTokens int // max output tokens for LLM summarization (0 = use default) + cfgProvider string // config-level provider override (empty = resolve from agent) + cfgModel string // config-level model override (empty = resolve from agent) } func NewPendingMessagesHandler(s store.PendingMessageStore, agentStore store.AgentStore, token string, providerReg *providers.Registry) *PendingMessagesHandler { @@ -81,7 +81,7 @@ func (h *PendingMessagesHandler) handleListGroups(w http.ResponseWriter, r *http } } - writeJSON(w, http.StatusOK, map[string]interface{}{"groups": groups}) + writeJSON(w, http.StatusOK, map[string]any{"groups": groups}) } // GET /v1/pending-messages/messages?channel=X&key=Y — list messages for a group @@ -99,7 +99,7 @@ func (h *PendingMessagesHandler) handleListMessages(w http.ResponseWriter, r *ht writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - writeJSON(w, http.StatusOK, map[string]interface{}{"messages": msgs}) + writeJSON(w, http.StatusOK, map[string]any{"messages": msgs}) } // DELETE /v1/pending-messages?channel=X&key=Y — clear a group @@ -169,7 +169,7 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re } }() - writeJSON(w, http.StatusAccepted, map[string]interface{}{"status": "accepted", "method": "summarizing"}) + writeJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "method": "summarizing"}) } // resolveProviderAndModel resolves the LLM provider+model for pending message compaction. diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index 2199c867..be5682c6 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -41,13 +41,13 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht // Claude CLI doesn't need an API key — return hardcoded models if p.ProviderType == store.ProviderClaudeCLI { - writeJSON(w, http.StatusOK, map[string]interface{}{"models": claudeCLIModels()}) + writeJSON(w, http.StatusOK, map[string]any{"models": claudeCLIModels()}) return } // ACP agents don't need an API key — return hardcoded models if p.ProviderType == store.ProviderACP { - writeJSON(w, http.StatusOK, map[string]interface{}{"models": acpModels()}) + writeJSON(w, http.StatusOK, map[string]any{"models": acpModels()}) return } @@ -86,11 +86,11 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht if err != nil { slog.Warn("providers.models", "provider", p.Name, "error", err) // Return empty list instead of error — provider may not support /models - writeJSON(w, http.StatusOK, map[string]interface{}{"models": []ModelInfo{}}) + writeJSON(w, http.StatusOK, map[string]any{"models": []ModelInfo{}}) return } - writeJSON(w, http.StatusOK, map[string]interface{}{"models": models}) + writeJSON(w, http.StatusOK, map[string]any{"models": models}) } // fetchAnthropicModels calls the Anthropic models API. @@ -289,4 +289,3 @@ func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo } return models, nil } - diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go index 3d254aa9..7aecfa1e 100644 --- a/internal/http/provider_verify.go +++ b/internal/http/provider_verify.go @@ -55,13 +55,13 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R } // Validate binary against known allowlist (same check as registerACPFromDB) if binary != "claude" && binary != "codex" && binary != "gemini" && !filepath.IsAbs(binary) { - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "invalid binary path"}) + writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "invalid binary path"}) return } if _, err := exec.LookPath(binary); err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "binary not found: " + binary}) + writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "binary not found: " + binary}) } else { - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true}) + writeJSON(w, http.StatusOK, map[string]any{"valid": true}) } return } @@ -70,28 +70,28 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R if p.ProviderType == "claude_cli" { validModels := map[string]bool{"sonnet": true, "opus": true, "haiku": true} if validModels[req.Model] { - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true}) + writeJSON(w, http.StatusOK, map[string]any{"valid": true}) } else { - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "Invalid model. Use: sonnet, opus, or haiku"}) + writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "Invalid model. Use: sonnet, opus, or haiku"}) } return } if h.providerReg == nil { - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "no provider registry available"}) + writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "no provider registry available"}) return } provider, err := h.providerReg.Get(p.Name) if err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "provider not registered: " + p.Name}) + writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "provider not registered: " + p.Name}) return } // Non-chat models (image/video generation) can't be verified via Chat API. // Accept them if the provider is reachable (already validated above). if isNonChatModel(req.Model) { - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true, "note": "generation model accepted (not verifiable via chat)"}) + writeJSON(w, http.StatusOK, map[string]any{"valid": true, "note": "generation model accepted (not verifiable via chat)"}) return } @@ -103,16 +103,16 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R {Role: "user", Content: "hi"}, }, Model: req.Model, - Options: map[string]interface{}{ + Options: map[string]any{ "max_tokens": 1, }, }) if err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": friendlyVerifyError(err)}) + writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": friendlyVerifyError(err)}) return } - writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true}) + writeJSON(w, http.StatusOK, map[string]any{"valid": true}) } // handleClaudeCLIAuthStatus checks whether the Claude CLI is authenticated on the server. @@ -137,14 +137,14 @@ func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *h status, err := providers.CheckClaudeAuthStatus(ctx, cliPath) if err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "logged_in": false, "error": err.Error(), }) return } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "logged_in": status.LoggedIn, "email": status.Email, "subscription_type": status.SubscriptionType, @@ -183,8 +183,8 @@ func friendlyVerifyError(err error) string { rest = strings.TrimLeft(rest[start+1:], " ") if len(rest) > 0 && rest[0] == '"' { rest = rest[1:] - if end := strings.Index(rest, `"`); end >= 0 { - extracted := rest[:end] + if before, _, ok := strings.Cut(rest, `"`); ok { + extracted := before if extracted != "" { return extracted } diff --git a/internal/http/providers.go b/internal/http/providers.go index e8b7056f..f43aa4b2 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -18,14 +18,14 @@ import ( // ProvidersHandler handles LLM provider CRUD endpoints. type ProvidersHandler struct { - store store.ProviderStore - secretStore store.ConfigSecretsStore - token string - providerReg *providers.Registry - gatewayAddr string // for injecting MCP bridge into Claude CLI providers - mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers - cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates - msgBus *bus.MessageBus + store store.ProviderStore + secretStore store.ConfigSecretsStore + token string + providerReg *providers.Registry + gatewayAddr string // for injecting MCP bridge into Claude CLI providers + mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers + cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates + msgBus *bus.MessageBus } // NewProvidersHandler creates a handler for provider management endpoints. @@ -165,7 +165,7 @@ func (h *ProvidersHandler) handleListProviders(w http.ResponseWriter, r *http.Re maskAPIKey(&providers[i]) } - writeJSON(w, http.StatusOK, map[string]interface{}{"providers": providers}) + writeJSON(w, http.StatusOK, map[string]any{"providers": providers}) } func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.Request) { @@ -247,7 +247,7 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R return } - var updates map[string]interface{} + var updates map[string]any if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return diff --git a/internal/http/responses.go b/internal/http/responses.go index b9948652..552df103 100644 --- a/internal/http/responses.go +++ b/internal/http/responses.go @@ -124,10 +124,10 @@ func (h *ResponsesHandler) handleNonStream(w http.ResponseWriter, r *http.Reques return } - resp := map[string]interface{}{ + resp := map[string]any{ "id": responseID, "status": "completed", - "output": []map[string]interface{}{{ + "output": []map[string]any{{ "type": "message", "role": "assistant", "content": []map[string]string{{"type": "text", "text": result.Content}}, @@ -159,9 +159,9 @@ func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request, w.WriteHeader(http.StatusOK) // response.started - writeResponseEvent(w, flusher, map[string]interface{}{ + writeResponseEvent(w, flusher, map[string]any{ "type": "response.started", - "response": map[string]interface{}{ + "response": map[string]any{ "id": responseID, "status": "in_progress", "created_at": time.Now().Unix(), @@ -180,9 +180,9 @@ func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request, if err != nil { // response.done with error - writeResponseEvent(w, flusher, map[string]interface{}{ + writeResponseEvent(w, flusher, map[string]any{ "type": "response.done", - "response": map[string]interface{}{ + "response": map[string]any{ "id": responseID, "status": "failed", "error": err.Error(), @@ -192,16 +192,16 @@ func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request, } // response.delta - writeResponseEvent(w, flusher, map[string]interface{}{ + writeResponseEvent(w, flusher, map[string]any{ "type": "response.delta", - "delta": map[string]interface{}{ + "delta": map[string]any{ "type": "content", "content": result.Content, }, }) // response.done - doneResp := map[string]interface{}{ + doneResp := map[string]any{ "id": responseID, "status": "completed", } @@ -213,13 +213,13 @@ func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request, } } - writeResponseEvent(w, flusher, map[string]interface{}{ + writeResponseEvent(w, flusher, map[string]any{ "type": "response.done", "response": doneResp, }) } -func writeResponseEvent(w http.ResponseWriter, flusher http.Flusher, data interface{}) { +func writeResponseEvent(w http.ResponseWriter, flusher http.Flusher, data any) { jsonData, _ := json.Marshal(data) fmt.Fprintf(w, "data: %s\n\n", jsonData) flusher.Flush() diff --git a/internal/http/skills.go b/internal/http/skills.go index eb1cc6e2..86a6572a 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -83,7 +83,7 @@ func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) { skills := h.skills.ListSkills() - writeJSON(w, http.StatusOK, map[string]interface{}{"skills": skills}) + writeJSON(w, http.StatusOK, map[string]any{"skills": skills}) } func (h *SkillsHandler) handleGet(w http.ResponseWriter, r *http.Request) { @@ -106,7 +106,7 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { return } - var updates map[string]interface{} + var updates map[string]any if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) return @@ -168,7 +168,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request if h.msgBus != nil { h.msgBus.Broadcast(bus.Event{ Name: protocol.EventSkillDepsInstalling, - Payload: map[string]interface{}{"count": len(missing)}, + Payload: map[string]any{"count": len(missing)}, }) } @@ -208,7 +208,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request if h.msgBus != nil { h.msgBus.Broadcast(bus.Event{ Name: protocol.EventSkillDepsChecked, - Payload: map[string]interface{}{ + Payload: map[string]any{ "slug": sk.Slug, "status": status, "missing": miss, @@ -241,14 +241,14 @@ func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request) if h.msgBus != nil { h.msgBus.Broadcast(bus.Event{ Name: protocol.EventSkillDepItemInstalling, - Payload: map[string]interface{}{"dep": body.Dep}, + Payload: map[string]any{"dep": body.Dep}, }) } ok, errMsg := skills.InstallSingleDep(r.Context(), body.Dep) if h.msgBus != nil { - payload := map[string]interface{}{"dep": body.Dep, "ok": ok} + payload := map[string]any{"dep": body.Dep, "ok": ok} if errMsg != "" { payload["error"] = errMsg } @@ -262,7 +262,7 @@ func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request) h.rescanAndUpdate() } - writeJSON(w, http.StatusOK, map[string]interface{}{"ok": ok, "error": errMsg}) + writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "error": errMsg}) } type depResult struct { @@ -315,7 +315,7 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { // handleRescanDeps re-checks dependencies for all skills (including archived) and updates their status. func (h *SkillsHandler) handleRescanDeps(w http.ResponseWriter, r *http.Request) { updated, results := h.rescanAndUpdate() - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "updated": updated, "results": results, }) diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go index 353bf792..cfd5864c 100644 --- a/internal/http/skills_grants.go +++ b/internal/http/skills_grants.go @@ -30,7 +30,7 @@ func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Req return } - writeJSON(w, http.StatusOK, map[string]interface{}{"skills": skills}) + writeJSON(w, http.StatusOK, map[string]any{"skills": skills}) } func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go index 3f4b1bb8..384763d3 100644 --- a/internal/http/skills_upload.go +++ b/internal/http/skills_upload.go @@ -189,7 +189,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { slog.Info("skill uploaded", "id", id, "slug", slug, "version", version, "size", header.Size) // Scan and check dependencies - response := map[string]interface{}{ + response := map[string]any{ "id": id, "slug": slug, "version": version, diff --git a/internal/http/skills_versions.go b/internal/http/skills_versions.go index 15d51682..71579bc2 100644 --- a/internal/http/skills_versions.go +++ b/internal/http/skills_versions.go @@ -35,7 +35,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques slugDir := filepath.Join(h.baseDir, slug) entries, err := os.ReadDir(slugDir) if err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "versions": []int{currentVersion}, "current": currentVersion, }) @@ -58,7 +58,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques versions = []int{currentVersion} } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "versions": versions, "current": currentVersion, }) @@ -139,7 +139,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) if files == nil { files = []fileEntry{} } - writeJSON(w, http.StatusOK, map[string]interface{}{"files": files}) + writeJSON(w, http.StatusOK, map[string]any{"files": files}) } // handleReadFile reads a single file from a skill version directory. @@ -212,7 +212,7 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "content": string(data), "path": relPath, "size": info.Size(), diff --git a/internal/http/storage.go b/internal/http/storage.go index e2dc56e5..3767f23f 100644 --- a/internal/http/storage.go +++ b/internal/http/storage.go @@ -1,11 +1,17 @@ package http import ( + "encoding/json" + "fmt" "log/slog" + "mime" "net/http" "os" "path/filepath" + "strconv" "strings" + "sync" + "time" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/skills" @@ -17,6 +23,14 @@ import ( type StorageHandler struct { baseDir string // resolved absolute path to ~/.goclaw/ token string + + // sizeCache caches the total storage size for 60 minutes. + sizeCache struct { + mu sync.Mutex + total int64 + files int + cachedAt time.Time + } } // NewStorageHandler creates a handler for workspace storage management. @@ -29,6 +43,7 @@ func (h *StorageHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/storage/files", h.auth(h.handleList)) mux.HandleFunc("GET /v1/storage/files/{path...}", h.auth(h.handleRead)) mux.HandleFunc("DELETE /v1/storage/files/{path...}", h.auth(h.handleDelete)) + mux.HandleFunc("GET /v1/storage/size", h.auth(h.handleSize)) } func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -51,8 +66,8 @@ var protectedDirs = []string{"skills", "skills-store"} func isProtectedPath(rel string) bool { top := rel - if i := strings.IndexByte(rel, filepath.Separator); i >= 0 { - top = rel[:i] + if before, _, ok := strings.Cut(rel, "/"); ok { + top = before } // Also handle forward slash on all platforms if i := strings.IndexByte(top, '/'); i >= 0 { @@ -66,8 +81,10 @@ func isProtectedPath(rel string) bool { return false } -// handleList lists all files and directories under ~/.goclaw/. -// Optional query param ?path= scopes the listing to a subtree. +// handleList lists files and directories under ~/.goclaw/ with depth limiting. +// Query params: +// - ?path= scopes the listing to a subtree +// - ?depth= max depth to walk (default 3, max 20) func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) subPath := r.URL.Query().Get("path") @@ -77,6 +94,13 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { return } + maxDepth := 3 + if d := r.URL.Query().Get("depth"); d != "" { + if v, err := strconv.Atoi(d); err == nil && v >= 1 && v <= 20 { + maxDepth = v + } + } + rootDir := h.baseDir if subPath != "" { rootDir = filepath.Join(h.baseDir, filepath.Clean(subPath)) @@ -88,18 +112,15 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { } type fileEntry struct { - Path string `json:"path"` - Name string `json:"name"` - IsDir bool `json:"isDir"` - Size int64 `json:"size"` - TotalSize int64 `json:"totalSize"` // recursive size for directories - Protected bool `json:"protected"` // true if deletion is blocked + Path string `json:"path"` + Name string `json:"name"` + IsDir bool `json:"isDir"` + Size int64 `json:"size"` + HasChildren bool `json:"hasChildren,omitempty"` + Protected bool `json:"protected"` } - // Compute directory sizes via a two-pass approach: - // 1. Walk and collect entries + accumulate sizes per dir path var entries []fileEntry - dirSizes := make(map[string]int64) filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error { if err != nil { @@ -123,6 +144,15 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { return nil } + // Calculate depth relative to rootDir + relToRoot, _ := filepath.Rel(rootDir, path) + depth := strings.Count(relToRoot, string(filepath.Separator)) + 1 + + // At depth boundary: record dir but don't descend + if d.IsDir() && depth > maxDepth { + return filepath.SkipDir + } + entry := fileEntry{ Path: rel, Name: d.Name(), @@ -132,16 +162,13 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { if !d.IsDir() { if info, err := d.Info(); err == nil { entry.Size = info.Size() - // Accumulate file size into all parent directories - parent := filepath.Dir(rel) - for parent != "." && parent != "" { - dirSizes[parent] += info.Size() - parent = filepath.Dir(parent) - } - // Also accumulate to root if listing from base - if subPath == "" { - dirSizes["."] += info.Size() - } + } + } + + // For directories at max depth, check if they have children + if d.IsDir() && depth == maxDepth { + if dirEntries, err := os.ReadDir(path); err == nil && len(dirEntries) > 0 { + entry.HasChildren = true } } @@ -150,37 +177,92 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { return nil }) - // 2. Assign totalSize to directory entries - for i := range entries { - if entries[i].IsDir { - entries[i].TotalSize = dirSizes[entries[i].Path] - } - } - if entries == nil { entries = []fileEntry{} } - // Calculate total size of the root being listed - var totalSize int64 - if subPath == "" { - totalSize = dirSizes["."] - } else { - rel, _ := filepath.Rel(h.baseDir, rootDir) - totalSize = dirSizes[rel] - // If rootDir is the listed subtree, also sum direct files - for _, e := range entries { - if !e.IsDir && filepath.Dir(e.Path) == rel { - totalSize += e.Size - } - } + writeJSON(w, http.StatusOK, map[string]any{ + "files": entries, + "baseDir": h.baseDir, + }) +} + +// sizeCacheTTL is how long storage size calculations are cached. +const sizeCacheTTL = 60 * time.Minute + +// handleSize streams the total storage size via SSE. +// Cached for 60 minutes; returns cached result immediately if valid. +func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + locale := extractLocale(r) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgStreamingNotSupported)}) + return } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "files": entries, - "totalSize": totalSize, - "baseDir": h.baseDir, + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + + // Check cache + h.sizeCache.mu.Lock() + if !h.sizeCache.cachedAt.IsZero() && time.Since(h.sizeCache.cachedAt) < sizeCacheTTL { + total := h.sizeCache.total + files := h.sizeCache.files + h.sizeCache.mu.Unlock() + writeSizeEvent(w, flusher, map[string]any{"total": total, "files": files, "done": true, "cached": true}) + return + } + h.sizeCache.mu.Unlock() + + // Walk and stream progress + var total int64 + var fileCount int + lastFlush := time.Now() + + filepath.WalkDir(h.baseDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + // Check client disconnect + if r.Context().Err() != nil { + return filepath.SkipAll + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + rel, _ := filepath.Rel(h.baseDir, path) + if skills.IsSystemArtifact(rel) { + return nil + } + if info, err := d.Info(); err == nil { + total += info.Size() + fileCount++ + } + // Emit progress every 50 files or 200ms + if fileCount%50 == 0 || time.Since(lastFlush) > 200*time.Millisecond { + writeSizeEvent(w, flusher, map[string]any{"current": total, "files": fileCount}) + lastFlush = time.Now() + } + return nil }) + + // Update cache + h.sizeCache.mu.Lock() + h.sizeCache.total = total + h.sizeCache.files = fileCount + h.sizeCache.cachedAt = time.Now() + h.sizeCache.mu.Unlock() + + // Send final event + writeSizeEvent(w, flusher, map[string]any{"total": total, "files": fileCount, "done": true, "cached": false}) +} + +func writeSizeEvent(w http.ResponseWriter, flusher http.Flusher, data map[string]any) { + jsonData, _ := json.Marshal(data) + fmt.Fprintf(w, "data: %s\n\n", jsonData) + flusher.Flush() } // handleRead reads a single file's content by relative path. @@ -221,7 +303,23 @@ func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, map[string]interface{}{ + // Raw mode: serve the file with its native content type (for images, downloads, etc.) + if r.URL.Query().Get("raw") == "true" { + ct := mime.TypeByExtension(filepath.Ext(absPath)) + if ct == "" { + ct = http.DetectContentType(data) + } + w.Header().Set("Content-Type", ct) + w.Header().Set("Cache-Control", "private, max-age=300") + if r.URL.Query().Get("download") == "true" { + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(absPath))) + } + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + w.Write(data) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ "content": string(data), "path": relPath, "size": info.Size(), diff --git a/internal/http/summoner_regenerate.go b/internal/http/summoner_regenerate.go index da9a08f8..937a78cb 100644 --- a/internal/http/summoner_regenerate.go +++ b/internal/http/summoner_regenerate.go @@ -115,7 +115,7 @@ func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, {Role: "user", Content: prompt}, }, Model: model, - Options: map[string]interface{}{ + Options: map[string]any{ "max_tokens": 8192, "temperature": 0.7, providers.OptSessionKey: summonSessionKey, @@ -201,7 +201,7 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg if s.msgBus == nil { return } - payload := map[string]interface{}{ + payload := map[string]any{ "type": eventType, "agent_id": agentID.String(), } diff --git a/internal/http/tools_invoke.go b/internal/http/tools_invoke.go index daaa7de9..f0db3a04 100644 --- a/internal/http/tools_invoke.go +++ b/internal/http/tools_invoke.go @@ -27,15 +27,15 @@ func NewToolsInvokeHandler(registry *tools.Registry, token string, agentStore st } type toolsInvokeRequest struct { - Tool string `json:"tool"` - Action string `json:"action,omitempty"` - Args map[string]interface{} `json:"args"` - SessionKey string `json:"sessionKey,omitempty"` - AgentID string `json:"agentId,omitempty"` - DryRun bool `json:"dryRun,omitempty"` - Channel string `json:"channel,omitempty"` // tool context: channel name - ChatID string `json:"chatId,omitempty"` // tool context: chat ID - PeerKind string `json:"peerKind,omitempty"` // tool context: "direct" or "group" + Tool string `json:"tool"` + Action string `json:"action,omitempty"` + Args map[string]any `json:"args"` + SessionKey string `json:"sessionKey,omitempty"` + AgentID string `json:"agentId,omitempty"` + DryRun bool `json:"dryRun,omitempty"` + Channel string `json:"channel,omitempty"` // tool context: channel name + ChatID string `json:"chatId,omitempty"` // tool context: chat ID + PeerKind string `json:"peerKind,omitempty"` // tool context: "direct" or "group" } func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -75,7 +75,7 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "tool": req.Tool, "description": tool.Description(), "parameters": tool.Parameters(), @@ -116,7 +116,7 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Execute the tool args := req.Args if args == nil { - args = make(map[string]interface{}) + args = make(map[string]any) } // If action is specified, add it to args @@ -132,11 +132,11 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "result": map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ + "result": map[string]any{ "output": result.ForLLM, "forUser": result.ForUser, - "metadata": map[string]interface{}{}, + "metadata": map[string]any{}, }, }) } @@ -144,7 +144,7 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func writeToolError(w http.ResponseWriter, status int, code, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "error": map[string]string{ "code": code, "message": message, diff --git a/internal/http/traces.go b/internal/http/traces.go index ce65fff9..0ecb6be2 100644 --- a/internal/http/traces.go +++ b/internal/http/traces.go @@ -93,7 +93,7 @@ func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) { total, _ := h.tracing.CountTraces(r.Context(), opts) - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "traces": traces, "total": total, "limit": opts.Limit, @@ -122,7 +122,7 @@ func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, map[string]interface{}{ + writeJSON(w, http.StatusOK, map[string]any{ "trace": trace, "spans": spans, }) @@ -179,7 +179,7 @@ func (h *TracesHandler) handleExport(w http.ResponseWriter, r *http.Request) { } payload := struct { - ExportedAt time.Time `json:"exported_at"` + ExportedAt time.Time `json:"exported_at"` traceExportEntry }{ ExportedAt: time.Now().UTC(), diff --git a/ui/web/src/api/http-client.ts b/ui/web/src/api/http-client.ts index 6a0d123a..dc5a7785 100644 --- a/ui/web/src/api/http-client.ts +++ b/ui/web/src/api/http-client.ts @@ -43,6 +43,30 @@ export class HttpClient { return res.blob(); } + /** Fetch a streaming response (SSE). Returns the raw Response for manual reading. */ + async streamFetch(path: string, signal?: AbortSignal): Promise { + const res = await fetch(this.buildUrl(path), { + method: "GET", + headers: this.authHeaders(), + signal, + }); + if (!res.ok) throw new ApiError("HTTP_ERROR", res.statusText); + return res; + } + + /** Build a full URL with auth token as query param (for src, download links). */ + rawUrl(path: string, params?: Record): string { + return this.buildUrl(path, params); + } + + /** Fetch raw blob with auth headers (for images, binary files). */ + async fetchBlob(path: string, params?: Record): Promise { + const url = this.buildUrl(path, params); + const res = await fetch(url, { method: "GET", headers: this.authHeaders() }); + if (!res.ok) throw new ApiError("HTTP_ERROR", res.statusText); + return res.blob(); + } + async upload(path: string, formData: FormData): Promise { const headers: Record = {}; const token = this.getToken(); @@ -77,10 +101,9 @@ export class HttpClient { return url.toString(); } - private headers(): Record { - const h: Record = { - "Content-Type": "application/json", - }; + /** Auth-only headers (no Content-Type), for SSE / blob requests. */ + private authHeaders(): Record { + const h: Record = {}; const token = this.getToken(); if (token) h["Authorization"] = `Bearer ${token}`; const userId = this.getUserId(); @@ -88,6 +111,10 @@ export class HttpClient { return h; } + private headers(): Record { + return { "Content-Type": "application/json", ...this.authHeaders() }; + } + private async request(url: string, init: RequestInit): Promise { let res: Response; try { diff --git a/ui/web/src/components/layout/connection-status.tsx b/ui/web/src/components/layout/connection-status.tsx index d5447014..6c6d1f05 100644 --- a/ui/web/src/components/layout/connection-status.tsx +++ b/ui/web/src/components/layout/connection-status.tsx @@ -2,19 +2,21 @@ import { useTranslation } from "react-i18next"; import { useAuthStore } from "@/stores/use-auth-store"; import { cn } from "@/lib/utils"; -export function ConnectionStatus() { +export function ConnectionStatus({ collapsed }: { collapsed?: boolean }) { const { t } = useTranslation("common"); const connected = useAuthStore((s) => s.connected); return ( -
+
- {connected ? t("connected") : t("disconnected")} + {!collapsed && ( + {connected ? t("connected") : t("disconnected")} + )}
); } diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index 3e1d4274..1d0f20cf 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -117,8 +117,8 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { {/* Footer: connection status */} -
- +
+
); diff --git a/ui/web/src/components/shared/file-browser.tsx b/ui/web/src/components/shared/file-browser.tsx index cf3d2b62..b9d95957 100644 --- a/ui/web/src/components/shared/file-browser.tsx +++ b/ui/web/src/components/shared/file-browser.tsx @@ -1,6 +1,9 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { formatSize, type TreeNode } from "@/lib/file-helpers"; +import { Download } from "lucide-react"; +import { formatSize, sizeBadgeVariant, type TreeNode } from "@/lib/file-helpers"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { FileTreePanel } from "@/components/shared/file-tree"; import { FileContentPanel } from "@/components/shared/file-viewers"; @@ -14,6 +17,28 @@ function useIsMobile(breakpoint = 640) { return mobile; } +/** File size badge + download button row. */ +function FileActions({ + size, + onDownload, +}: { + size: number; + onDownload?: () => void; +}) { + return ( +
+ + {formatSize(size)} + + {onDownload && ( + + )} +
+ ); +} + export function FileBrowser({ tree, filesLoading, @@ -22,6 +47,9 @@ export function FileBrowser({ contentLoading, fileContent, onDelete, + onLoadMore, + onDownload, + fetchBlob, showSize, }: { tree: TreeNode[]; @@ -31,6 +59,9 @@ export function FileBrowser({ contentLoading: boolean; fileContent: { content: string; path: string; size: number } | null; onDelete?: (path: string, isDir: boolean) => void; + onLoadMore?: (path: string) => void; + onDownload?: (path: string) => void; + fetchBlob?: (path: string) => Promise; showSize?: boolean; }) { const isMobile = useIsMobile(); @@ -74,7 +105,7 @@ export function FileBrowser({
{mobileShowTree ? (
- +
) : (
@@ -89,12 +120,12 @@ export function FileBrowser({ {fileContent && ( <> {fileContent.path} - {formatSize(fileContent.size)} + onDownload(fileContent.path) : undefined} /> )}
- +
)} @@ -106,7 +137,7 @@ export function FileBrowser({ return (
- +
{fileContent.path} - {formatSize(fileContent.size)} + onDownload(fileContent.path) : undefined} />
)}
- +
diff --git a/ui/web/src/components/shared/file-tree.tsx b/ui/web/src/components/shared/file-tree.tsx index 8c1607a7..d84661df 100644 --- a/ui/web/src/components/shared/file-tree.tsx +++ b/ui/web/src/components/shared/file-tree.tsx @@ -6,17 +6,52 @@ import { FileText, FileCode2, File, + FileImage, + FileJson2, + FileSpreadsheet, + FileTerminal, + FileArchive, + FileVideo, + FileAudio, + FileCog, + FileType, + FileLock, ChevronRight, Loader2, Trash2, } from "lucide-react"; -import { extOf, CODE_EXTENSIONS, formatSize, type TreeNode } from "@/lib/file-helpers"; +import { extOf, CODE_EXTENSIONS, IMAGE_EXTENSIONS, formatSize, type TreeNode } from "@/lib/file-helpers"; + +const cls = "h-4 w-4 shrink-0"; function FileIcon({ name }: { name: string }) { const ext = extOf(name); - if (ext === "md") return ; - if (CODE_EXTENSIONS.has(ext)) return ; - return ; + + // Markdown + if (ext === "md" || ext === "mdx") return ; + // JSON / YAML config + if (ext === "json" || ext === "json5") return ; + if (ext === "yaml" || ext === "yml" || ext === "toml") return ; + // Spreadsheet / CSV + if (ext === "csv") return ; + // Shell / terminal + if (ext === "sh" || ext === "bash" || ext === "zsh") return ; + // Images + if (IMAGE_EXTENSIONS.has(ext)) return ; + // Video + if (ext === "mp4" || ext === "webm" || ext === "mov" || ext === "avi" || ext === "mkv") return ; + // Audio + if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac" || ext === "m4a") return ; + // Archive + if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z" || ext === "bz2") return ; + // Font + if (ext === "ttf" || ext === "otf" || ext === "woff" || ext === "woff2") return ; + // Env / secrets + if (ext === "env" || ext === "pem" || ext === "key" || ext === "crt") return ; + // Code (generic) + if (CODE_EXTENSIONS.has(ext)) return ; + // Default + return ; } export function TreeItem({ @@ -25,6 +60,7 @@ export function TreeItem({ activePath, onSelect, onDelete, + onLoadMore, showSize, }: { node: TreeNode; @@ -32,11 +68,21 @@ export function TreeItem({ activePath: string | null; onSelect: (path: string) => void; onDelete?: (path: string, isDir: boolean) => void; + onLoadMore?: (path: string) => void; showSize?: boolean; }) { const [expanded, setExpanded] = useState(depth === 0); const isActive = activePath === node.path; + const handleToggle = () => { + const willExpand = !expanded; + setExpanded(willExpand); + // Lazy load: if expanding a folder with hasChildren but no loaded children + if (willExpand && node.isDir && node.hasChildren && node.children.length === 0 && !node.loading) { + onLoadMore?.(node.path); + } + }; + const deleteBtn = onDelete && !node.protected && (
); } @@ -111,6 +170,7 @@ export function FileTreePanel({ activePath, onSelect, onDelete, + onLoadMore, showSize, }: { tree: TreeNode[]; @@ -118,6 +178,7 @@ export function FileTreePanel({ activePath: string | null; onSelect: (path: string) => void; onDelete?: (path: string, isDir: boolean) => void; + onLoadMore?: (path: string) => void; showSize?: boolean; }) { const { t } = useTranslation("common"); @@ -134,7 +195,7 @@ export function FileTreePanel({ return ( <> {tree.map((node) => ( - + ))} ); diff --git a/ui/web/src/components/shared/file-viewers.tsx b/ui/web/src/components/shared/file-viewers.tsx index b8150e9f..b892cf42 100644 --- a/ui/web/src/components/shared/file-viewers.tsx +++ b/ui/web/src/components/shared/file-viewers.tsx @@ -1,7 +1,9 @@ -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Check, Copy, Loader2 } from "lucide-react"; +import { Check, Copy, Download, FileQuestion, Loader2 } from "lucide-react"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { useClipboard } from "@/hooks/use-clipboard"; import hljs from "highlight.js/lib/core"; import typescript from "highlight.js/lib/languages/typescript"; @@ -20,7 +22,16 @@ import java from "highlight.js/lib/languages/java"; import c from "highlight.js/lib/languages/c"; import cpp from "highlight.js/lib/languages/cpp"; import lua from "highlight.js/lib/languages/lua"; -import { extOf, langFor, stripFrontmatter, CODE_EXTENSIONS } from "@/lib/file-helpers"; +import { + extOf, + langFor, + stripFrontmatter, + formatSize, + sizeBadgeVariant, + isImageFile, + isTextFile, + CODE_EXTENSIONS, +} from "@/lib/file-helpers"; hljs.registerLanguage("typescript", typescript); hljs.registerLanguage("tsx", typescript); @@ -142,26 +153,132 @@ function CsvViewer({ content }: { content: string }) { ); } -export function FileContentBody({ path, content }: { path: string; content: string }) { - const ext = extOf(path); - const displayContent = ext === "md" ? stripFrontmatter(content) : content; +function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise }) { + const [src, setSrc] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let objectUrl: string | null = null; + setLoading(true); + setError(false); + + fetchBlob(path) + .then((blob) => { + objectUrl = URL.createObjectURL(blob); + setSrc(objectUrl); + }) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + + return () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [path, fetchBlob]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !src) { + return ( +
+ Failed to load image +
+ ); + } - if (ext === "md") return ; - if (ext === "csv") return ; - if (CODE_EXTENSIONS.has(ext)) return ; return ( -
-      {displayContent}
-    
+
+ {path.split("/").pop() +
); } +function UnsupportedFileViewer({ + path, + size, + onDownload, +}: { + path: string; + size: number; + onDownload?: () => void; +}) { + const { t } = useTranslation("storage"); + const fileName = path.split("/").pop() ?? path; + + return ( +
+ +

{t("unsupportedFile")}

+ {onDownload && ( + + )} +
+ ); +} + +export function FileContentBody({ + path, + content, + size, + fetchBlob, + onDownload, +}: { + path: string; + content: string; + size?: number; + fetchBlob?: (path: string) => Promise; + onDownload?: () => void; +}) { + const ext = extOf(path); + + // Image files + if (isImageFile(path) && fetchBlob) { + return ; + } + + // Text-based files + if (isTextFile(path) || ext === "md" || ext === "csv" || CODE_EXTENSIONS.has(ext)) { + const displayContent = ext === "md" ? stripFrontmatter(content) : content; + if (ext === "md") return ; + if (ext === "csv") return ; + if (CODE_EXTENSIONS.has(ext)) return ; + return ( +
+        {displayContent}
+      
+ ); + } + + // Unsupported files + return ; +} + export function FileContentPanel({ fileContent, contentLoading, + fetchBlob, + onDownload, }: { fileContent: { content: string; path: string; size: number } | null; contentLoading: boolean; + fetchBlob?: (path: string) => Promise; + onDownload?: (path: string) => void; }) { const { t } = useTranslation("common"); if (contentLoading) { @@ -172,7 +289,15 @@ export function FileContentPanel({ ); } if (fileContent) { - return ; + return ( + onDownload(fileContent.path) : undefined} + /> + ); } return (
diff --git a/ui/web/src/components/shared/page-header.tsx b/ui/web/src/components/shared/page-header.tsx index 28637151..db2bd226 100644 --- a/ui/web/src/components/shared/page-header.tsx +++ b/ui/web/src/components/shared/page-header.tsx @@ -1,6 +1,6 @@ interface PageHeaderProps { title: string; - description?: string; + description?: React.ReactNode; actions?: React.ReactNode; } diff --git a/ui/web/src/i18n/locales/en/storage.json b/ui/web/src/i18n/locales/en/storage.json index 5df503ba..0d2c9609 100644 --- a/ui/web/src/i18n/locales/en/storage.json +++ b/ui/web/src/i18n/locales/en/storage.json @@ -2,6 +2,9 @@ "title": "Storage", "description": "Workspace file browser", "descriptionWithPath": "{{path}} — {{size}}", + "sizeCacheInfo": "Storage size is cached for 60 minutes", + "unsupportedFile": "This file type is not supported for preview", + "download": "Download", "delete": { "fileTitle": "Delete file", "folderTitle": "Delete folder", diff --git a/ui/web/src/i18n/locales/vi/storage.json b/ui/web/src/i18n/locales/vi/storage.json index 7d8f2f51..f950ee6d 100644 --- a/ui/web/src/i18n/locales/vi/storage.json +++ b/ui/web/src/i18n/locales/vi/storage.json @@ -2,6 +2,9 @@ "title": "Lưu trữ", "description": "Trình duyệt tệp workspace", "descriptionWithPath": "{{path}} — {{size}}", + "sizeCacheInfo": "Dung lượng lưu trữ được cache trong 60 phút", + "unsupportedFile": "Loại tệp này chưa được hỗ trợ xem trước", + "download": "Tải về", "delete": { "fileTitle": "Xóa tệp", "folderTitle": "Xóa thư mục", diff --git a/ui/web/src/i18n/locales/zh/storage.json b/ui/web/src/i18n/locales/zh/storage.json index 5d83f8d1..52f3b92d 100644 --- a/ui/web/src/i18n/locales/zh/storage.json +++ b/ui/web/src/i18n/locales/zh/storage.json @@ -2,6 +2,9 @@ "title": "存储", "description": "工作区文件浏览器", "descriptionWithPath": "{{path}} — {{size}}", + "sizeCacheInfo": "存储大小缓存60分钟", + "unsupportedFile": "此文件类型不支持预览", + "download": "下载", "delete": { "fileTitle": "删除文件", "folderTitle": "删除文件夹", diff --git a/ui/web/src/lib/file-helpers.ts b/ui/web/src/lib/file-helpers.ts index 2a450466..77228621 100644 --- a/ui/web/src/lib/file-helpers.ts +++ b/ui/web/src/lib/file-helpers.ts @@ -3,7 +3,8 @@ export interface TreeNode { path: string; isDir: boolean; size: number; - totalSize?: number; + hasChildren?: boolean; + loading?: boolean; protected?: boolean; children: TreeNode[]; } @@ -14,11 +15,27 @@ export const CODE_EXTENSIONS = new Set([ "c", "cpp", "h", "xml", "graphql", "proto", "lua", "zig", "env", ]); +export const IMAGE_EXTENSIONS = new Set([ + "jpg", "jpeg", "png", "gif", "webp", "svg", "ico", "bmp", +]); + +const TEXT_EXTENSIONS = new Set([ + ...CODE_EXTENSIONS, ...["md", "csv", "txt", "log", "cfg", "ini", "conf"], +]); + export function extOf(name: string): string { const dot = name.lastIndexOf("."); return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ""; } +export function isImageFile(name: string): boolean { + return IMAGE_EXTENSIONS.has(extOf(name)); +} + +export function isTextFile(name: string): boolean { + return TEXT_EXTENSIONS.has(extOf(name)); +} + export function langFor(ext: string): string { const map: Record = { ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", @@ -32,7 +49,22 @@ export function langFor(ext: string): string { return map[ext] ?? ext; } -interface TreeInput { path: string; name: string; isDir: boolean; size: number; totalSize?: number; protected?: boolean } +/** Returns badge variant based on file size. */ +export function sizeBadgeVariant(bytes: number): "outline" | "info" | "warning" | "destructive" { + if (bytes < 100 * 1024) return "outline"; + if (bytes < 1024 * 1024) return "info"; + if (bytes < 10 * 1024 * 1024) return "warning"; + return "destructive"; +} + +interface TreeInput { + path: string; + name: string; + isDir: boolean; + size: number; + hasChildren?: boolean; + protected?: boolean; +} export function buildTree(files: TreeInput[]): TreeNode[] { const root: TreeNode[] = []; @@ -49,7 +81,7 @@ export function buildTree(files: TreeInput[]): TreeNode[] { path: f.path, isDir: f.isDir, size: f.size, - totalSize: f.totalSize, + hasChildren: f.hasChildren, protected: f.protected, children: [], }; @@ -82,6 +114,33 @@ export function buildTree(files: TreeInput[]): TreeNode[] { return root; } +/** Merges a loaded subtree into an existing tree, replacing the target folder's children. */ +export function mergeSubtree(tree: TreeNode[], parentPath: string, newChildren: TreeInput[]): TreeNode[] { + const childTree = buildTree(newChildren); + + const merge = (nodes: TreeNode[]): TreeNode[] => + nodes.map((node) => { + if (node.path === parentPath && node.isDir) { + return { ...node, children: childTree, hasChildren: false, loading: false }; + } + if (node.children.length > 0) { + return { ...node, children: merge(node.children) }; + } + return node; + }); + + return merge(tree); +} + +/** Marks a node as loading in the tree (immutable update). */ +export function setNodeLoading(tree: TreeNode[], path: string, loading: boolean): TreeNode[] { + return tree.map((node) => { + if (node.path === path) return { ...node, loading }; + if (node.children.length > 0) return { ...node, children: setNodeLoading(node.children, path, loading) }; + return node; + }); +} + export function stripFrontmatter(content: string): string { if (!content.startsWith("---")) return content; const end = content.indexOf("---", 3); diff --git a/ui/web/src/pages/storage/hooks/use-storage.ts b/ui/web/src/pages/storage/hooks/use-storage.ts index c1a5324b..7f11c91d 100644 --- a/ui/web/src/pages/storage/hooks/use-storage.ts +++ b/ui/web/src/pages/storage/hooks/use-storage.ts @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { useHttp } from "@/hooks/use-ws"; export interface StorageFile { @@ -6,13 +6,12 @@ export interface StorageFile { name: string; isDir: boolean; size: number; - totalSize: number; + hasChildren?: boolean; protected: boolean; } interface StorageListResponse { files: StorageFile[]; - totalSize: number; baseDir: string; } @@ -25,7 +24,6 @@ interface StorageFileContent { export function useStorage() { const http = useHttp(); const [files, setFiles] = useState([]); - const [totalSize, setTotalSize] = useState(0); const [baseDir, setBaseDir] = useState(""); const [loading, setLoading] = useState(false); @@ -34,13 +32,17 @@ export function useStorage() { try { const res = await http.get("/v1/storage/files"); setFiles(res.files ?? []); - setTotalSize(res.totalSize ?? 0); setBaseDir(res.baseDir ?? ""); } finally { setLoading(false); } }, [http]); + const loadSubtree = useCallback(async (path: string): Promise => { + const res = await http.get("/v1/storage/files", { path }); + return res.files ?? []; + }, [http]); + const readFile = useCallback( async (path: string) => { return http.get( @@ -59,5 +61,78 @@ export function useStorage() { [http], ); - return { files, totalSize, baseDir, loading, listFiles, readFile, deleteFile }; + /** Fetch raw file as blob (for images, downloads). */ + const fetchRawBlob = useCallback( + (path: string, download?: boolean) => { + const params: Record = { raw: "true" }; + if (download) params.download = "true"; + return http.fetchBlob(`/v1/storage/files/${encodeURIComponent(path)}`, params); + }, + [http], + ); + + return { files, baseDir, loading, listFiles, loadSubtree, readFile, deleteFile, fetchRawBlob }; +} + +interface SizeState { + totalSize: number; + fileCount: number; + loading: boolean; + cached: boolean; +} + +export function useStorageSize() { + const http = useHttp(); + const [state, setState] = useState({ totalSize: 0, fileCount: 0, loading: false, cached: false }); + const abortRef = useRef(null); + + const fetchSize = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setState((s) => ({ ...s, loading: true, totalSize: 0, fileCount: 0 })); + + try { + const res = await http.streamFetch("/v1/storage/size", controller.signal); + if (!res.body) { + setState((s) => ({ ...s, loading: false })); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const data = JSON.parse(line.slice(6)); + if (data.done) { + setState({ totalSize: data.total, fileCount: data.files, loading: false, cached: !!data.cached }); + } else { + setState((s) => ({ ...s, totalSize: data.current, fileCount: data.files })); + } + } catch { /* skip malformed */ } + } + } + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + setState((s) => ({ ...s, loading: false })); + } + }, [http]); + + useEffect(() => { + return () => abortRef.current?.abort(); + }, []); + + return { ...state, refreshSize: fetchSize }; } diff --git a/ui/web/src/pages/storage/storage-page.tsx b/ui/web/src/pages/storage/storage-page.tsx index 51a17c1c..7cedcb60 100644 --- a/ui/web/src/pages/storage/storage-page.tsx +++ b/ui/web/src/pages/storage/storage-page.tsx @@ -1,8 +1,14 @@ import { useState, useEffect, useCallback, useMemo } from "react"; -import { RefreshCw } from "lucide-react"; +import { Info, RefreshCw } from "lucide-react"; import { useTranslation } from "react-i18next"; import { PageHeader } from "@/components/shared/page-header"; import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Dialog, DialogContent, @@ -11,36 +17,65 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { buildTree, formatSize } from "@/lib/file-helpers"; +import { buildTree, mergeSubtree, setNodeLoading, formatSize, isTextFile } from "@/lib/file-helpers"; import { FileBrowser } from "@/components/shared/file-browser"; -import { useStorage } from "./hooks/use-storage"; +import { useStorage, useStorageSize } from "./hooks/use-storage"; export function StoragePage() { const { t } = useTranslation("storage"); - const { files, totalSize, baseDir, loading, listFiles, readFile, deleteFile } = useStorage(); + const { files, baseDir, loading, listFiles, loadSubtree, readFile, deleteFile, fetchRawBlob } = useStorage(); + const { totalSize, loading: sizeLoading, refreshSize } = useStorageSize(); + const [tree, setTree] = useState(buildTree(files)); const [activePath, setActivePath] = useState(null); const [fileContent, setFileContent] = useState<{ content: string; path: string; size: number } | null>(null); const [contentLoading, setContentLoading] = useState(false); const [deleteTarget, setDeleteTarget] = useState<{ path: string; isDir: boolean } | null>(null); const [deleting, setDeleting] = useState(false); - useEffect(() => { listFiles(); }, [listFiles]); + // Rebuild tree when files change from initial load or refresh + useEffect(() => { setTree(buildTree(files)); }, [files]); - const tree = useMemo(() => buildTree(files), [files]); + // Load file list + size on mount + useEffect(() => { listFiles(); refreshSize(); }, [listFiles, refreshSize]); + + const handleLoadMore = useCallback(async (path: string) => { + // Mark node as loading + setTree((prev) => setNodeLoading(prev, path, true)); + try { + const children = await loadSubtree(path); + setTree((prev) => mergeSubtree(prev, path, children)); + } catch { + setTree((prev) => setNodeLoading(prev, path, false)); + } + }, [loadSubtree]); + + /** Find a file node's size from the flat files list. */ + const fileSizeMap = useMemo(() => { + const m = new Map(); + for (const f of files) if (!f.isDir) m.set(f.path, f.size); + return m; + }, [files]); const handleSelect = useCallback(async (path: string) => { setActivePath(path); - setContentLoading(true); - try { - const res = await readFile(path); - setFileContent(res); - } catch { - setFileContent(null); - } finally { - setContentLoading(false); + if (isTextFile(path)) { + setContentLoading(true); + try { + const res = await readFile(path); + setFileContent(res); + } catch { + setFileContent(null); + } finally { + setContentLoading(false); + } + } else { + // For non-text files (images, binaries): don't fetch content — just set metadata. + // ImageViewer will fetch the blob separately; UnsupportedViewer just shows size. + const size = fileSizeMap.get(path) ?? 0; + setFileContent({ content: "", path, size }); } - }, [readFile]); + }, [readFile, fileSizeMap]); const handleDeleteRequest = useCallback((path: string, isDir: boolean) => { setDeleteTarget({ path, isDir }); @@ -51,7 +86,6 @@ export function StoragePage() { setDeleting(true); try { await deleteFile(deleteTarget.path); - // Clear content if the deleted file was being viewed if (activePath === deleteTarget.path || (deleteTarget.isDir && activePath?.startsWith(deleteTarget.path + "/"))) { setActivePath(null); setFileContent(null); @@ -63,15 +97,60 @@ export function StoragePage() { } }, [deleteTarget, deleteFile, listFiles, activePath]); + const handleDownload = useCallback(async (path: string) => { + try { + const blob = await fetchRawBlob(path, true); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = path.split("/").pop() ?? "download"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { /* silent fail */ } + }, [fetchRawBlob]); + + /** Fetch raw blob for image rendering (no download header). */ + const handleFetchBlob = useCallback(async (path: string) => { + return fetchRawBlob(path, false); + }, [fetchRawBlob]); + + const handleRefresh = useCallback(() => { + listFiles(); + refreshSize(); + }, [listFiles, refreshSize]); + const deleteName = deleteTarget?.path.split("/").pop() ?? ""; + // Size description with cache tooltip + const sizeDescription = useMemo(() => { + if (!baseDir) return t("description"); + const sizeStr = sizeLoading ? `${formatSize(totalSize)}...` : formatSize(totalSize); + return t("descriptionWithPath", { path: baseDir, size: sizeStr }); + }, [baseDir, totalSize, sizeLoading, t]); + return (
+ {sizeDescription} + + + + + + +

{t("sizeCacheInfo")}

+
+
+
+ + } actions={ - @@ -87,6 +166,9 @@ export function StoragePage() { contentLoading={contentLoading} fileContent={fileContent} onDelete={handleDeleteRequest} + onLoadMore={handleLoadMore} + onDownload={handleDownload} + fetchBlob={handleFetchBlob} showSize />