feat(ui): add file upload to Team Workspace and Storage pages

Allow admins/operators to upload files via the web UI so agents can
read them with read_file during collaboration.

Backend:
- POST /v1/teams/{teamId}/workspace/upload — multipart upload with
  team membership check, tenant isolation, blocked extensions, 10MB
  limit, 100-file quota, symlink escape prevention, and real-time
  EventWorkspaceFileChanged broadcast.
- POST /v1/storage/files — admin-only storage upload with protected
  dir rejection and size cache invalidation.
- Export MaxFileSizeBytes, MaxFilesPerScope, IsBlockedExtension from
  tools package for reuse in HTTP handlers.

Frontend:
- Reusable FileUploadDialog component (drop zone, multi-file,
  per-file status, client-side validation).
- Upload button added to Team Workspace dialog (scope-aware) and
  Storage page (folder-aware).
- i18n strings for en/vi/zh.
This commit is contained in:
viettranx committed 2026-03-24 22:48:11 +07:00
1 parent b221ed2fb6
commit 9ee82be219
17 files changed
+688 -10

No files matched your search

+1
View File
@@ -372,6 +372,7 @@ func runGateway() {
}
if pgStores != nil && pgStores.Teams != nil {
server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(pgStores.Teams, workspace))
server.SetWorkspaceUploadHandler(httpapi.NewWorkspaceUploadHandler(pgStores.Teams, workspace, msgBus))
}
if builtinToolsH != nil {
server.SetBuiltinToolsHandler(builtinToolsH)
+11
View File
@@ -47,6 +47,7 @@ type Server struct {
providersHandler *httpapi.ProvidersHandler // provider CRUD API
teamEventsHandler *httpapi.TeamEventsHandler // team event history API
teamAttachmentsHandler *httpapi.TeamAttachmentsHandler // team attachment download API
workspaceUploadHandler *httpapi.WorkspaceUploadHandler // team workspace file upload API
builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API
pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API
secureCLIHandler *httpapi.SecureCLIHandler // secure CLI credential CRUD API
@@ -240,6 +241,11 @@ func (s *Server) BuildMux() *http.ServeMux {
s.teamAttachmentsHandler.RegisterRoutes(mux)
}
// Team workspace file upload API
if s.workspaceUploadHandler != nil {
s.workspaceUploadHandler.RegisterRoutes(mux)
}
// Builtin tool management API
if s.builtinToolsHandler != nil {
s.builtinToolsHandler.RegisterRoutes(mux)
@@ -521,6 +527,11 @@ func (s *Server) SetTeamAttachmentsHandler(h *httpapi.TeamAttachmentsHandler) {
s.teamAttachmentsHandler = h
}
// SetWorkspaceUploadHandler sets the team workspace file upload handler.
func (s *Server) SetWorkspaceUploadHandler(h *httpapi.WorkspaceUploadHandler) {
s.workspaceUploadHandler = h
}
// SetPendingMessagesHandler sets the pending messages handler.
func (s *Server) SetPendingMessagesHandler(h *httpapi.PendingMessagesHandler) {
s.pendingMessagesHandler = h
+117
View File
@@ -3,6 +3,7 @@ package http
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"mime"
"net/http"
@@ -15,8 +16,10 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// StorageHandler provides HTTP endpoints for browsing and managing
@@ -47,6 +50,7 @@ func (h *StorageHandler) RegisterRoutes(mux *http.ServeMux) {
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))
mux.HandleFunc("POST /v1/storage/files", requireAuth(permissions.RoleAdmin, h.handleUpload))
}
func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc {
@@ -386,3 +390,116 @@ func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
slog.Info("storage.deleted", "path", relPath)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// handleUpload uploads a file into the storage data directory.
// Admin-only. Rejects uploads into protected directories (skills, skills-store).
func (h *StorageHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
subPath := r.URL.Query().Get("path")
if strings.Contains(subPath, "..") {
slog.Warn("security.storage_upload_traversal", "path", subPath)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Reject upload into protected directories.
if subPath != "" && isProtectedPath(subPath) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteSkillsDir)})
return
}
// Enforce file size limit.
r.Body = http.MaxBytesReader(w, r.Body, tools.MaxFileSizeBytes)
if err := r.ParseMultipartForm(tools.MaxFileSizeBytes); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgFileTooLarge)})
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMissingFileField)})
return
}
defer file.Close()
// Sanitize filename.
origName := filepath.Base(header.Filename)
if origName == "." || origName == "/" || strings.Contains(origName, "..") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidFilename)})
return
}
// Check blocked extensions.
ext := strings.ToLower(filepath.Ext(origName))
if tools.IsBlockedExtension(ext) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("file type %s is not allowed", ext)})
return
}
// Resolve target directory within tenant-scoped data dir.
base := h.tenantBaseDir(r)
targetDir := base
if subPath != "" {
targetDir = filepath.Join(base, filepath.Clean(subPath))
if !strings.HasPrefix(targetDir, base) {
slog.Warn("security.storage_upload_escape", "resolved", targetDir, "root", base)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
}
if err := os.MkdirAll(targetDir, 0750); err != nil {
slog.Error("storage.upload_mkdir_failed", "dir", targetDir, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create directory")})
return
}
diskPath := filepath.Join(targetDir, origName)
// Symlink escape check on resolved path.
realTarget, _ := filepath.EvalSymlinks(targetDir)
if realTarget == "" {
realTarget = targetDir
}
realBase, _ := filepath.EvalSymlinks(base)
if realBase == "" {
realBase = base
}
if !strings.HasPrefix(realTarget, realBase) {
slog.Warn("security.storage_upload_symlink_escape", "target", realTarget, "base", realBase)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Write file.
out, err := os.Create(diskPath)
if err != nil {
slog.Error("storage.upload_create_failed", "path", diskPath, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
defer out.Close()
written, err := io.Copy(out, file)
if err != nil {
os.Remove(diskPath)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
// Invalidate size cache for this tenant.
h.sizeCache.Delete(base)
relPath := origName
if subPath != "" {
relPath = filepath.Join(subPath, origName)
}
slog.Info("storage.uploaded", "path", relPath, "size", written)
writeJSON(w, http.StatusOK, map[string]any{
"path": relPath,
"filename": origName,
"size": written,
})
}
+180
View File
@@ -0,0 +1,180 @@
package http
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// WorkspaceUploadHandler handles file uploads to team workspaces.
type WorkspaceUploadHandler struct {
teamStore store.TeamStore
dataDir string
msgBus *bus.MessageBus
}
// NewWorkspaceUploadHandler creates a new workspace upload handler.
func NewWorkspaceUploadHandler(teamStore store.TeamStore, dataDir string, msgBus *bus.MessageBus) *WorkspaceUploadHandler {
return &WorkspaceUploadHandler{teamStore: teamStore, dataDir: dataDir, msgBus: msgBus}
}
// RegisterRoutes registers the workspace upload endpoint.
func (h *WorkspaceUploadHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/teams/{teamId}/workspace/upload", requireAuth("", h.handleUpload))
}
func (h *WorkspaceUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
ctx := r.Context()
if h.teamStore == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgTeamsNotConfigured)})
return
}
// Parse team ID from path.
teamID, err := uuid.Parse(r.PathValue("teamId"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid team_id"})
return
}
// Team membership check.
userID := store.UserIDFromContext(ctx)
if has, err := h.teamStore.HasTeamAccess(ctx, teamID, userID); err != nil || !has {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "team workspace")})
return
}
// Determine workspace mode (shared vs isolated).
chatID := r.URL.Query().Get("chat_id")
team, err := h.teamStore.GetTeam(ctx, teamID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "team not found"})
return
}
shared := tools.IsSharedWorkspace(team.Settings)
if !shared && chatID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "chat_id")})
return
}
if shared {
chatID = "" // shared mode ignores chat_id
}
// Enforce file size limit at HTTP level.
r.Body = http.MaxBytesReader(w, r.Body, tools.MaxFileSizeBytes)
if err := r.ParseMultipartForm(tools.MaxFileSizeBytes); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgFileTooLarge)})
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMissingFileField)})
return
}
defer file.Close()
// Sanitize filename.
origName := filepath.Base(header.Filename)
if origName == "." || origName == "/" || strings.Contains(origName, "..") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidFilename)})
return
}
// Check blocked extensions.
ext := strings.ToLower(filepath.Ext(origName))
if tools.IsBlockedExtension(ext) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("file type %s is not allowed", ext)})
return
}
// Resolve tenant-scoped workspace directory.
tenantID := store.TenantIDFromContext(ctx)
slug := store.TenantSlugFromContext(ctx)
scopeDir := config.TenantTeamDir(h.dataDir, tenantID, slug, teamID)
if chatID != "" {
scopeDir = filepath.Join(scopeDir, chatID)
}
if err := os.MkdirAll(scopeDir, 0750); err != nil {
slog.Error("workspace_upload: mkdir failed", "dir", scopeDir, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create directory")})
return
}
// File quota check.
entries, _ := os.ReadDir(scopeDir)
fileCount := 0
for _, e := range entries {
if !e.IsDir() {
fileCount++
}
}
if fileCount >= tools.MaxFilesPerScope {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("workspace file limit reached (%d files)", tools.MaxFilesPerScope)})
return
}
// Path boundary check (symlink escape prevention).
diskPath := filepath.Clean(filepath.Join(scopeDir, origName))
scopeReal, _ := filepath.EvalSymlinks(filepath.Clean(scopeDir))
if scopeReal == "" {
scopeReal = filepath.Clean(scopeDir)
}
if !strings.HasPrefix(diskPath, scopeReal+string(filepath.Separator)) && diskPath != scopeReal {
slog.Warn("security.workspace_upload_escape", "path", origName, "scope", scopeReal)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidFilename)})
return
}
// Write file to disk.
out, err := os.Create(diskPath)
if err != nil {
slog.Error("workspace_upload: create file failed", "path", diskPath, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
defer out.Close()
written, err := io.Copy(out, file)
if err != nil {
os.Remove(diskPath)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
// Broadcast workspace file change event for real-time UI updates.
if h.msgBus != nil {
bus.BroadcastForTenant(h.msgBus, protocol.EventWorkspaceFileChanged, tenantID, map[string]string{
"team_id": teamID.String(),
"chat_id": chatID,
"file_name": origName,
"action": "created",
})
}
mimeType := media.DetectMIMEType(origName)
slog.Info("workspace_upload: file uploaded", "team", teamID, "chat_id", chatID, "file", origName, "size", written)
writeJSON(w, http.StatusOK, map[string]any{
"path": diskPath,
"filename": origName,
"size": written,
"mime_type": mimeType,
})
}
+11 -1
View File
@@ -9,10 +9,15 @@ import (
"github.com/google/uuid"
)
// Workspace limits shared across workspace interceptor.
// Workspace limits shared across workspace interceptor and HTTP upload handlers.
const (
maxFileSizeBytes = 10 * 1024 * 1024 // 10MB
maxFilesPerScope = 100
// MaxFileSizeBytes is the exported form of maxFileSizeBytes for HTTP handlers.
MaxFileSizeBytes int64 = maxFileSizeBytes
// MaxFilesPerScope is the exported form of maxFilesPerScope for HTTP handlers.
MaxFilesPerScope = maxFilesPerScope
)
// WorkspaceDir returns the disk directory for a team workspace scope.
@@ -51,3 +56,8 @@ var blockedExtensions = map[string]bool{
".exe": true, ".sh": true, ".bat": true, ".cmd": true,
".ps1": true, ".com": true, ".msi": true, ".scr": true,
}
// IsBlockedExtension returns true if the file extension is blocked for upload.
func IsBlockedExtension(ext string) bool {
return blockedExtensions[ext]
}
@@ -0,0 +1,224 @@
import { useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Upload, CheckCircle2, XCircle, Loader2, X } from "lucide-react";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { uniqueId } from "@/lib/utils";
/** Blocked extensions matching backend tools.blockedExtensions. */
const BLOCKED_EXTENSIONS = new Set([
".exe", ".sh", ".bat", ".cmd", ".ps1", ".com", ".msi", ".scr",
]);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB — matches backend tools.MaxFileSizeBytes
type FileStatus = "checking" | "ready" | "uploading" | "success" | "error";
interface FileEntry {
id: string;
file: File;
status: FileStatus;
error?: string;
}
interface FileUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUpload: (file: File) => Promise<void>;
title?: string;
description?: string;
}
export function FileUploadDialog({
open, onOpenChange, onUpload, title, description,
}: FileUploadDialogProps) {
const { t } = useTranslation("common");
const [entries, setEntries] = useState<FileEntry[]>([]);
const [uploading, setUploading] = useState(false);
const [done, setDone] = useState(false);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const addFiles = (fileList: FileList) => {
const existingNames = new Set(entries.map((e) => e.file.name));
const fresh = Array.from(fileList).filter((f) => !existingNames.has(f.name));
if (fresh.length === 0) return;
const newEntries: FileEntry[] = fresh.map((f) => {
const ext = "." + f.name.split(".").pop()?.toLowerCase();
if (BLOCKED_EXTENSIONS.has(ext)) {
return { id: uniqueId(), file: f, status: "error" as const, error: t("upload.blockedType", { ext }) };
}
if (f.size > MAX_FILE_SIZE) {
return { id: uniqueId(), file: f, status: "error" as const, error: t("upload.tooLarge") };
}
return { id: uniqueId(), file: f, status: "ready" as const };
});
setEntries((prev) => [...prev, ...newEntries]);
};
const removeEntry = (id: string) => {
setEntries((prev) => prev.filter((e) => e.id !== id));
};
const handleSubmit = async () => {
const readyEntries = entries.filter((e) => e.status === "ready");
if (readyEntries.length === 0) return;
setUploading(true);
for (const entry of readyEntries) {
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: "uploading" } : e)));
try {
await onUpload(entry.file);
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: "success" } : e)));
} catch (err) {
setEntries((prev) =>
prev.map((e) =>
e.id === entry.id
? { ...e, status: "error", error: err instanceof Error ? err.message : t("upload.failed") }
: e,
),
);
}
}
setUploading(false);
setDone(true);
};
const handleClose = (v: boolean) => {
if (uploading) return;
setEntries([]);
setDragging(false);
setDone(false);
onOpenChange(v);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) addFiles(e.target.files);
if (inputRef.current) inputRef.current.value = "";
};
const readyCount = entries.filter((e) => e.status === "ready").length;
const successCount = entries.filter((e) => e.status === "success").length;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-h-[80dvh] flex flex-col">
<DialogHeader>
<DialogTitle>{title ?? t("upload.title")}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
{/* Drop zone */}
{!uploading && !done && (
<div
role="button"
tabIndex={0}
className={`flex cursor-pointer flex-col items-center gap-2 rounded-md border-2 border-dashed p-6 text-center transition-colors ${
dragging ? "border-primary bg-primary/5" : "hover:border-primary/50"
}`}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); inputRef.current?.click(); } }}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragEnter={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{dragging ? t("upload.dropHere") : t("upload.dropOrClick")}
</p>
<p className="text-xs text-muted-foreground/60">{t("upload.maxSize")}</p>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={handleInputChange}
/>
</div>
)}
{/* File list */}
{entries.length > 0 && (
<div className="flex flex-col gap-1 overflow-y-auto max-h-[40dvh]">
{entries.map((entry) => (
<div key={entry.id} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<StatusIcon status={entry.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{entry.file.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{(entry.file.size / 1024).toFixed(1)} KB
</span>
</div>
{(entry.status === "error") && (
<p className="text-xs text-destructive truncate">{entry.error}</p>
)}
</div>
{!uploading && entry.status !== "uploading" && entry.status !== "success" && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeEntry(entry.id); }}
className="shrink-0 rounded-sm p-1 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
)}
{/* Summary */}
{entries.length > 0 && !done && !uploading && (
<p className="text-xs text-muted-foreground">
{t("upload.readyCount", { ready: readyCount, total: entries.length })}
</p>
)}
{done && (
<p className="text-sm font-medium text-muted-foreground">
{t("upload.successCount", { success: successCount, total: entries.length })}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={uploading}>
{t("cancel")}
</Button>
{done ? (
<Button onClick={() => handleClose(false)}>{t("done", "Done")}</Button>
) : (
<Button onClick={handleSubmit} disabled={readyCount === 0 || uploading}>
{uploading
? t("upload.uploading")
: t("upload.uploadCount", { count: readyCount })}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function StatusIcon({ status }: { status: FileStatus }) {
switch (status) {
case "checking":
case "uploading":
return <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />;
case "ready":
return <CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />;
case "success":
return <CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />;
case "error":
return <XCircle className="h-4 w-4 shrink-0 text-destructive" />;
}
}
+15 -1
View File
@@ -12,7 +12,7 @@
"search": "Search",
"retry": "Retry",
"manage": "Manage",
"upload": "Upload",
"uploadLabel": "Upload",
"close": "Close",
"back": "Back",
"update": "Update",
@@ -119,6 +119,20 @@
"loadMore": "Load more...",
"viewCard": "Card view",
"viewList": "List view",
"done": "Done",
"upload": {
"title": "Upload Files",
"dropOrClick": "Drop files here or click to browse",
"dropHere": "Drop files here",
"maxSize": "Max 10 MB per file",
"blockedType": "File type {{ext}} is not allowed",
"tooLarge": "File exceeds 10 MB limit",
"failed": "Upload failed",
"readyCount": "{{ready}} of {{total}} files ready",
"successCount": "{{success}} of {{total}} files uploaded",
"uploading": "Uploading...",
"uploadCount": "Upload {{count}} file(s)"
},
"errors": {
"serverError": "Something went wrong. Please try again later."
}
+4
View File
@@ -14,6 +14,10 @@
"deleting": "Deleting...",
"confirmLabel": "Delete"
},
"upload": {
"title": "Upload to Storage",
"description": "Upload files to the data directory"
},
"toast": {
"deleted": "File deleted",
"deleteFailed": "Failed to delete file"
+4
View File
@@ -158,6 +158,10 @@
"detail": {
"title": "File Content",
"empty": "File is empty or binary"
},
"upload": {
"title": "Upload to Workspace",
"description": "Upload files for agents to read during collaboration"
}
},
"auditLogs": {
+15 -1
View File
@@ -103,7 +103,7 @@
"unknown": "Không xác định",
"unsavedChanges": "Có thay đổi chưa lưu",
"update": "Cập nhật",
"upload": "Tải lên",
"uploadLabel": "Tải lên",
"usingGlobalDefaults": "Đang dùng giá trị mặc định toàn cục từ config.json",
"verificationFailed": "Xác minh thất bại",
"view": "Xem",
@@ -119,6 +119,20 @@
"loadMore": "Tải thêm...",
"viewCard": "Dạng thẻ",
"viewList": "Dạng danh sách",
"done": "Xong",
"upload": {
"title": "Tải tệp lên",
"dropOrClick": "Kéo thả tệp hoặc nhấp để chọn",
"dropHere": "Thả tệp vào đây",
"maxSize": "Tối đa 10 MB mỗi tệp",
"blockedType": "Loại tệp {{ext}} không được phép",
"tooLarge": "Tệp vượt quá giới hạn 10 MB",
"failed": "Tải lên thất bại",
"readyCount": "{{ready}} trong {{total}} tệp sẵn sàng",
"successCount": "{{success}} trong {{total}} tệp đã tải lên",
"uploading": "Đang tải lên...",
"uploadCount": "Tải lên {{count}} tệp"
},
"errors": {
"serverError": "Đã xảy ra lỗi. Vui lòng thử lại sau."
}
+4
View File
@@ -14,6 +14,10 @@
"deleting": "Đang xóa...",
"confirmLabel": "Xóa"
},
"upload": {
"title": "Tải lên Lưu trữ",
"description": "Tải tệp lên thư mục dữ liệu"
},
"toast": {
"deleted": "Đã xóa file",
"deleteFailed": "Không thể xóa file"
+4
View File
@@ -158,6 +158,10 @@
"detail": {
"title": "Nội dung file",
"empty": "File trống hoặc là file nhị phân"
},
"upload": {
"title": "Tải lên Workspace",
"description": "Tải tệp lên để agent đọc trong quá trình cộng tác"
}
},
"auditLogs": {
+15 -1
View File
@@ -103,7 +103,7 @@
"unknown": "未知",
"unsavedChanges": "有未保存的更改",
"update": "更新",
"upload": "上传",
"uploadLabel": "上传",
"usingGlobalDefaults": "使用 config.json 中的全局默认值",
"verificationFailed": "验证失败",
"view": "查看",
@@ -119,6 +119,20 @@
"loadMore": "加载更多...",
"viewCard": "卡片视图",
"viewList": "列表视图",
"done": "完成",
"upload": {
"title": "上传文件",
"dropOrClick": "拖放文件或点击浏览",
"dropHere": "将文件拖放到这里",
"maxSize": "每个文件最大 10 MB",
"blockedType": "不允许 {{ext}} 文件类型",
"tooLarge": "文件超过 10 MB 限制",
"failed": "上传失败",
"readyCount": "{{total}} 个文件中有 {{ready}} 个就绪",
"successCount": "{{total}} 个文件中已上传 {{success}} 个",
"uploading": "上传中...",
"uploadCount": "上传 {{count}} 个文件"
},
"errors": {
"serverError": "出了点问题,请稍后重试。"
}
+4
View File
@@ -14,6 +14,10 @@
"deleting": "删除中...",
"confirmLabel": "删除"
},
"upload": {
"title": "上传到存储",
"description": "上传文件到数据目录"
},
"toast": {
"deleted": "文件已删除",
"deleteFailed": "删除文件失败"
+4
View File
@@ -158,6 +158,10 @@
"detail": {
"title": "文件内容",
"empty": "文件为空或为二进制文件"
},
"upload": {
"title": "上传到工作区",
"description": "上传文件供 Agent 在协作中读取"
}
},
"auditLogs": {
+46 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Info, RefreshCw } from "lucide-react";
import { Info, RefreshCw, Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@/components/shared/page-header";
import { Button } from "@/components/ui/button";
@@ -19,10 +19,13 @@ import {
} from "@/components/ui/dialog";
import { buildTree, mergeSubtree, setNodeLoading, formatSize, isTextFile } from "@/lib/file-helpers";
import { FileBrowser } from "@/components/shared/file-browser";
import { FileUploadDialog } from "@/components/shared/file-upload-dialog";
import { useStorage, useStorageSize } from "./hooks/use-storage";
import { useHttp } from "@/hooks/use-ws";
export function StoragePage() {
const { t } = useTranslation("storage");
const http = useHttp();
const { files, baseDir, loading, listFiles, loadSubtree, readFile, deleteFile, fetchRawBlob } = useStorage();
const { totalSize, loading: sizeLoading, refreshSize } = useStorageSize();
@@ -32,6 +35,7 @@ export function StoragePage() {
const [contentLoading, setContentLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ path: string; isDir: boolean } | null>(null);
const [deleting, setDeleting] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
// Rebuild tree when files change from initial load or refresh
useEffect(() => { setTree(buildTree(files)); }, [files]);
@@ -121,6 +125,29 @@ export function StoragePage() {
refreshSize();
}, [listFiles, refreshSize]);
/** Derive current folder path from activePath for scoped uploads. */
const activeFolder = useMemo(() => {
if (!activePath) return "";
// If activePath is a folder, use it; otherwise use its parent.
const node = files.find((f) => f.path === activePath);
if (node?.isDir) return activePath;
const idx = activePath.lastIndexOf("/");
return idx > 0 ? activePath.slice(0, idx) : "";
}, [activePath, files]);
const handleUploadFile = useCallback(async (file: File) => {
const params: Record<string, string> = {};
if (activeFolder) params["path"] = activeFolder;
const fd = new FormData();
fd.append("file", file);
await http.upload(`/v1/storage/files?` + new URLSearchParams(params).toString(), fd);
}, [http, activeFolder]);
const handleUploadClose = useCallback((v: boolean) => {
setUploadOpen(v);
if (!v) handleRefresh();
}, [handleRefresh]);
const deleteName = deleteTarget?.path.split("/").pop() ?? "";
// Size description with cache tooltip
@@ -150,10 +177,16 @@ export function StoragePage() {
</span>
}
actions={
<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>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setUploadOpen(true)}>
<Upload className="h-4 w-4 mr-1.5" />
{t("common:uploadLabel", "Upload")}
</Button>
<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>
</div>
}
/>
@@ -173,6 +206,14 @@ export function StoragePage() {
/>
</div>
<FileUploadDialog
open={uploadOpen}
onOpenChange={handleUploadClose}
onUpload={handleUploadFile}
title={t("upload.title")}
description={t("upload.description")}
/>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent>
@@ -6,9 +6,10 @@ import { Button } from "@/components/ui/button";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { RefreshCw } from "lucide-react";
import { RefreshCw, Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FileBrowser } from "@/components/shared/file-browser";
import { FileUploadDialog } from "@/components/shared/file-upload-dialog";
import { buildTree, isTextFile } from "@/lib/file-helpers";
import { useTeamWorkspace } from "../hooks/use-team-workspace";
import { useHttp } from "@/hooks/use-ws";
@@ -35,6 +36,7 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
const [fileContent, setFileContent] = useState<{ content: string; path: string; size: number } | null>(null);
const [contentLoading, setContentLoading] = useState(false);
const [activePath, setActivePath] = useState<string | null>(null);
const [uploadOpen, setUploadOpen] = useState(false);
const scopeValue = selectedScope === "__all__" ? "" : selectedScope;
// Stable scope list: populated once from "all" listing, not recalculated on filter.
@@ -147,6 +149,21 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
} catch { /* silent */ }
}, [fetchBlobByName]);
const handleUploadFile = useCallback(async (file: File) => {
const params: Record<string, string> = {};
if (scopeValue) params["chat_id"] = scopeValue;
await http.upload(`/v1/teams/${teamId}/workspace/upload?` + new URLSearchParams(params).toString(), (() => {
const fd = new FormData();
fd.append("file", file);
return fd;
})());
}, [http, teamId, scopeValue]);
const handleUploadClose = useCallback((v: boolean) => {
setUploadOpen(v);
if (!v) load(); // refresh file list after upload dialog closes
}, [load]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[90vh] w-[95vw] overflow-hidden sm:max-w-6xl flex flex-col">
@@ -169,6 +186,9 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
</SelectContent>
</Select>
)}
<Button variant="outline" size="sm" onClick={() => setUploadOpen(true)} className="gap-1">
<Upload className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (loading ? " animate-spin" : "")} />
</Button>
@@ -188,6 +208,14 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
fetchBlob={fetchBlobByName}
showSize
/>
<FileUploadDialog
open={uploadOpen}
onOpenChange={handleUploadClose}
onUpload={handleUploadFile}
title={t("workspace.upload.title")}
description={t("workspace.upload.description")}
/>
</DialogContent>
</Dialog>
);