feat(storage): add lazy folder loading, SSE size endpoint, and enhanced file viewer

- 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
This commit is contained in:
viettranx committed 2026-03-14 18:13:52 +07:00
1 parent 4ec1c5e71a
commit 28fab9507a
43 files changed
+825 -257

No files matched your search

+3 -3
View File
@@ -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) {
+2 -2
View File
@@ -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.
+3 -3
View File
@@ -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)
+5 -5
View File
@@ -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:<agentId>" or "agent:<agentId>"
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") {
+2 -2
View File
@@ -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
+12 -12
View File
@@ -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.
+15 -15
View File
@@ -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
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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,
})
+3 -3
View File
@@ -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,
})
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+4 -4
View File
@@ -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})
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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),
})
+2 -2
View File
@@ -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"]
+7 -7
View File
@@ -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.
+4 -5
View File
@@ -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
}
+15 -15
View File
@@ -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
}
+10 -10
View File
@@ -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
+11 -11
View File
@@ -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()
+8 -8
View File
@@ -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,
})
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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(),
+146 -48
View File
@@ -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(),
+2 -2
View File
@@ -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(),
}
+15 -15
View File
@@ -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,
+3 -3
View File
@@ -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(),
+31 -4
View File
@@ -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<Response> {
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 <img> src, download links). */
rawUrl(path: string, params?: Record<string, string>): string {
return this.buildUrl(path, params);
}
/** Fetch raw blob with auth headers (for images, binary files). */
async fetchBlob(path: string, params?: Record<string, string>): Promise<Blob> {
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<T>(path: string, formData: FormData): Promise<T> {
const headers: Record<string, string> = {};
const token = this.getToken();
@@ -77,10 +101,9 @@ export class HttpClient {
return url.toString();
}
private headers(): Record<string, string> {
const h: Record<string, string> = {
"Content-Type": "application/json",
};
/** Auth-only headers (no Content-Type), for SSE / blob requests. */
private authHeaders(): Record<string, string> {
const h: Record<string, string> = {};
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<string, string> {
return { "Content-Type": "application/json", ...this.authHeaders() };
}
private async request<T>(url: string, init: RequestInit): Promise<T> {
let res: Response;
try {
@@ -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 (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-xs text-muted-foreground overflow-hidden">
<span
className={cn(
"h-2 w-2 rounded-full",
"h-2 w-2 shrink-0 rounded-full",
connected ? "bg-green-500" : "bg-red-500",
)}
/>
<span>{connected ? t("connected") : t("disconnected")}</span>
{!collapsed && (
<span className="truncate">{connected ? t("connected") : t("disconnected")}</span>
)}
</div>
);
}
+2 -2
View File
@@ -117,8 +117,8 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
</nav>
{/* Footer: connection status */}
<div className="border-t px-4 py-3">
<ConnectionStatus />
<div className={cn("border-t py-3", collapsed ? "px-2 flex justify-center" : "px-4")}>
<ConnectionStatus collapsed={collapsed} />
</div>
</aside>
);
+38 -7
View File
@@ -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 (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<Badge variant={sizeBadgeVariant(size)} className="text-[10px] px-1.5 py-0">
{formatSize(size)}
</Badge>
{onDownload && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDownload} title="Download">
<Download className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
}
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<Blob>;
showSize?: boolean;
}) {
const isMobile = useIsMobile();
@@ -74,7 +105,7 @@ export function FileBrowser({
<div className="flex-1 flex flex-col border rounded-md overflow-hidden min-h-0">
{mobileShowTree ? (
<div className="flex-1 overflow-y-auto bg-muted/20 py-1">
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} showSize={showSize} />
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} onLoadMore={onLoadMore} showSize={showSize} />
</div>
) : (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -89,12 +120,12 @@ export function FileBrowser({
{fileContent && (
<>
<span className="font-mono truncate">{fileContent.path}</span>
<span className="shrink-0 ml-auto">{formatSize(fileContent.size)}</span>
<FileActions size={fileContent.size} onDownload={onDownload ? () => onDownload(fileContent.path) : undefined} />
</>
)}
</div>
<div className="flex-1 overflow-auto p-3 min-h-0">
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} fetchBlob={fetchBlob} onDownload={onDownload} />
</div>
</div>
)}
@@ -106,7 +137,7 @@ export function FileBrowser({
return (
<div ref={containerRef} className="flex-1 flex border rounded-md overflow-hidden min-h-0">
<div className="overflow-y-auto bg-muted/20 py-1 shrink-0" style={{ width: treeWidth }}>
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} showSize={showSize} />
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} onDelete={onDelete} onLoadMore={onLoadMore} showSize={showSize} />
</div>
<div
@@ -118,11 +149,11 @@ export function FileBrowser({
{fileContent && (
<div className="flex items-center justify-between text-xs text-muted-foreground border-b px-3 py-2 shrink-0">
<span className="font-mono truncate">{fileContent.path}</span>
<span className="shrink-0 ml-2">{formatSize(fileContent.size)}</span>
<FileActions size={fileContent.size} onDownload={onDownload ? () => onDownload(fileContent.path) : undefined} />
</div>
)}
<div className="flex-1 overflow-auto p-3 min-h-0">
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} fetchBlob={fetchBlob} onDownload={onDownload} />
</div>
</div>
</div>
+69 -8
View File
@@ -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 <FileText className="h-4 w-4 shrink-0 text-blue-500" />;
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className="h-4 w-4 shrink-0 text-orange-500" />;
return <File className="h-4 w-4 shrink-0 text-muted-foreground" />;
// Markdown
if (ext === "md" || ext === "mdx") return <FileText className={`${cls} text-blue-500`} />;
// JSON / YAML config
if (ext === "json" || ext === "json5") return <FileJson2 className={`${cls} text-yellow-600`} />;
if (ext === "yaml" || ext === "yml" || ext === "toml") return <FileCog className={`${cls} text-purple-500`} />;
// Spreadsheet / CSV
if (ext === "csv") return <FileSpreadsheet className={`${cls} text-green-600`} />;
// Shell / terminal
if (ext === "sh" || ext === "bash" || ext === "zsh") return <FileTerminal className={`${cls} text-lime-600`} />;
// Images
if (IMAGE_EXTENSIONS.has(ext)) return <FileImage className={`${cls} text-emerald-500`} />;
// Video
if (ext === "mp4" || ext === "webm" || ext === "mov" || ext === "avi" || ext === "mkv") return <FileVideo className={`${cls} text-pink-500`} />;
// Audio
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac" || ext === "m4a") return <FileAudio className={`${cls} text-violet-500`} />;
// Archive
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z" || ext === "bz2") return <FileArchive className={`${cls} text-amber-600`} />;
// Font
if (ext === "ttf" || ext === "otf" || ext === "woff" || ext === "woff2") return <FileType className={`${cls} text-slate-500`} />;
// Env / secrets
if (ext === "env" || ext === "pem" || ext === "key" || ext === "crt") return <FileLock className={`${cls} text-red-500`} />;
// Code (generic)
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className={`${cls} text-orange-500`} />;
// Default
return <File className={`${cls} text-muted-foreground`} />;
}
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 && (
<button
type="button"
@@ -48,9 +94,9 @@ export function TreeItem({
</button>
);
const sizeLabel = showSize && (node.isDir ? (node.totalSize ?? 0) : node.size) > 0 && (
const sizeLabel = showSize && (node.isDir ? 0 : node.size) > 0 && (
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground tabular-nums">
{formatSize(node.isDir ? (node.totalSize ?? 0) : node.size)}
{formatSize(node.size)}
</span>
);
@@ -60,7 +106,7 @@ export function TreeItem({
<div
className="group/tree-item flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm hover:bg-accent cursor-pointer"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => setExpanded(!expanded)}
onClick={handleToggle}
>
<ChevronRight
className={`h-3 w-3 shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`}
@@ -71,6 +117,7 @@ export function TreeItem({
<Folder className="h-4 w-4 shrink-0 text-yellow-600" />
)}
<span className="truncate">{node.name}</span>
{node.loading && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground ml-1" />}
{sizeLabel}
{deleteBtn}
</div>
@@ -82,9 +129,21 @@ export function TreeItem({
activePath={activePath}
onSelect={onSelect}
onDelete={onDelete}
onLoadMore={onLoadMore}
showSize={showSize}
/>
))}
{/* Show chevron hint for loadable but not-yet-expanded empty folders */}
{expanded && node.hasChildren && node.children.length === 0 && !node.loading && (
<div
className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
style={{ paddingLeft: `${(depth + 1) * 16 + 20}px` }}
onClick={() => onLoadMore?.(node.path)}
>
<Loader2 className="h-3 w-3" />
<span>Load more...</span>
</div>
)}
</div>
);
}
@@ -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) => (
<TreeItem key={node.path} node={node} depth={0} activePath={activePath} onSelect={onSelect} onDelete={onDelete} showSize={showSize} />
<TreeItem key={node.path} node={node} depth={0} activePath={activePath} onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore} showSize={showSize} />
))}
</>
);
+138 -13
View File
@@ -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<Blob> }) {
const [src, setSrc] = useState<string | null>(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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !src) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Failed to load image
</div>
);
}
if (ext === "md") return <MarkdownRenderer content={displayContent} />;
if (ext === "csv") return <CsvViewer content={displayContent} />;
if (CODE_EXTENSIONS.has(ext)) return <CodeViewer content={displayContent} language={langFor(ext)} />;
return (
<pre className="whitespace-pre-wrap rounded-md border bg-muted/30 p-4 text-sm">
{displayContent}
</pre>
<div className="flex items-center justify-center p-4">
<img
src={src}
alt={path.split("/").pop() ?? ""}
className="max-w-full max-h-[70vh] object-contain rounded-lg border border-border/40"
/>
</div>
);
}
function UnsupportedFileViewer({
path,
size,
onDownload,
}: {
path: string;
size: number;
onDownload?: () => void;
}) {
const { t } = useTranslation("storage");
const fileName = path.split("/").pop() ?? path;
return (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<FileQuestion className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t("unsupportedFile")}</p>
{onDownload && (
<Button variant="outline" size="sm" onClick={onDownload}>
<Download className="h-3.5 w-3.5 mr-1.5" />
{fileName}
<Badge variant={sizeBadgeVariant(size)} className="text-[10px] ml-1.5">
{formatSize(size)}
</Badge>
</Button>
)}
</div>
);
}
export function FileContentBody({
path,
content,
size,
fetchBlob,
onDownload,
}: {
path: string;
content: string;
size?: number;
fetchBlob?: (path: string) => Promise<Blob>;
onDownload?: () => void;
}) {
const ext = extOf(path);
// Image files
if (isImageFile(path) && fetchBlob) {
return <ImageViewer path={path} fetchBlob={fetchBlob} />;
}
// 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 <MarkdownRenderer content={displayContent} />;
if (ext === "csv") return <CsvViewer content={displayContent} />;
if (CODE_EXTENSIONS.has(ext)) return <CodeViewer content={displayContent} language={langFor(ext)} />;
return (
<pre className="whitespace-pre-wrap rounded-md border bg-muted/30 p-4 text-sm">
{displayContent}
</pre>
);
}
// Unsupported files
return <UnsupportedFileViewer path={path} size={size ?? 0} onDownload={onDownload} />;
}
export function FileContentPanel({
fileContent,
contentLoading,
fetchBlob,
onDownload,
}: {
fileContent: { content: string; path: string; size: number } | null;
contentLoading: boolean;
fetchBlob?: (path: string) => Promise<Blob>;
onDownload?: (path: string) => void;
}) {
const { t } = useTranslation("common");
if (contentLoading) {
@@ -172,7 +289,15 @@ export function FileContentPanel({
);
}
if (fileContent) {
return <FileContentBody path={fileContent.path} content={fileContent.content} />;
return (
<FileContentBody
path={fileContent.path}
content={fileContent.content}
size={fileContent.size}
fetchBlob={fetchBlob}
onDownload={onDownload ? () => onDownload(fileContent.path) : undefined}
/>
);
}
return (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
+1 -1
View File
@@ -1,6 +1,6 @@
interface PageHeaderProps {
title: string;
description?: string;
description?: React.ReactNode;
actions?: React.ReactNode;
}
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -2,6 +2,9 @@
"title": "存储",
"description": "工作区文件浏览器",
"descriptionWithPath": "{{path}} — {{size}}",
"sizeCacheInfo": "存储大小缓存60分钟",
"unsupportedFile": "此文件类型不支持预览",
"download": "下载",
"delete": {
"fileTitle": "删除文件",
"folderTitle": "删除文件夹",
+62 -3
View File
@@ -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<string, string> = {
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);
+81 -6
View File
@@ -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<StorageFile[]>([]);
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<StorageListResponse>("/v1/storage/files");
setFiles(res.files ?? []);
setTotalSize(res.totalSize ?? 0);
setBaseDir(res.baseDir ?? "");
} finally {
setLoading(false);
}
}, [http]);
const loadSubtree = useCallback(async (path: string): Promise<StorageFile[]> => {
const res = await http.get<StorageListResponse>("/v1/storage/files", { path });
return res.files ?? [];
}, [http]);
const readFile = useCallback(
async (path: string) => {
return http.get<StorageFileContent>(
@@ -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<string, string> = { 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<SizeState>({ totalSize: 0, fileCount: 0, loading: false, cached: false });
const abortRef = useRef<AbortController | null>(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 };
}
+100 -18
View File
@@ -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<string | null>(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<string, number>();
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 (
<div className="flex flex-col h-full p-4 sm:p-6">
<PageHeader
title={t("title")}
description={baseDir ? t("descriptionWithPath", { path: baseDir, size: formatSize(totalSize) }) : t("description")}
description={
<span className="inline-flex items-center gap-1">
{sizeDescription}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("sizeCacheInfo")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
}
actions={
<Button variant="outline" size="sm" onClick={listFiles} disabled={loading}>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1.5 ${loading ? "animate-spin" : ""}`} />
{t("common:refresh", "Refresh")}
</Button>
@@ -87,6 +166,9 @@ export function StoragePage() {
contentLoading={contentLoading}
fileContent={fileContent}
onDelete={handleDeleteRequest}
onLoadMore={handleLoadMore}
onDownload={handleDownload}
fetchBlob={handleFetchBlob}
showSize
/>
</div>