diff --git a/cmd/gateway.go b/cmd/gateway.go index 0b5a75f5..5a3cac7e 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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) diff --git a/internal/gateway/server.go b/internal/gateway/server.go index be1d7a89..e9487790 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -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 diff --git a/internal/http/storage.go b/internal/http/storage.go index f4da48f0..73f1333b 100644 --- a/internal/http/storage.go +++ b/internal/http/storage.go @@ -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, + }) +} diff --git a/internal/http/workspace_upload.go b/internal/http/workspace_upload.go new file mode 100644 index 00000000..b9fbf90e --- /dev/null +++ b/internal/http/workspace_upload.go @@ -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, + }) +} diff --git a/internal/tools/workspace_dir.go b/internal/tools/workspace_dir.go index 6bb27243..a6fb89db 100644 --- a/internal/tools/workspace_dir.go +++ b/internal/tools/workspace_dir.go @@ -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] +} diff --git a/ui/web/src/components/shared/file-upload-dialog.tsx b/ui/web/src/components/shared/file-upload-dialog.tsx new file mode 100644 index 00000000..05a7d53b --- /dev/null +++ b/ui/web/src/components/shared/file-upload-dialog.tsx @@ -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; + title?: string; + description?: string; +} + +export function FileUploadDialog({ + open, onOpenChange, onUpload, title, description, +}: FileUploadDialogProps) { + const { t } = useTranslation("common"); + const [entries, setEntries] = useState([]); + const [uploading, setUploading] = useState(false); + const [done, setDone] = useState(false); + const [dragging, setDragging] = useState(false); + const inputRef = useRef(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) => { + 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 ( + + + + {title ?? t("upload.title")} + {description && {description}} + + + {/* Drop zone */} + {!uploading && !done && ( +
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} + > + +

+ {dragging ? t("upload.dropHere") : t("upload.dropOrClick")} +

+

{t("upload.maxSize")}

+ +
+ )} + + {/* File list */} + {entries.length > 0 && ( +
+ {entries.map((entry) => ( +
+ +
+
+ {entry.file.name} + + {(entry.file.size / 1024).toFixed(1)} KB + +
+ {(entry.status === "error") && ( +

{entry.error}

+ )} +
+ {!uploading && entry.status !== "uploading" && entry.status !== "success" && ( + + )} +
+ ))} +
+ )} + + {/* Summary */} + {entries.length > 0 && !done && !uploading && ( +

+ {t("upload.readyCount", { ready: readyCount, total: entries.length })} +

+ )} + {done && ( +

+ {t("upload.successCount", { success: successCount, total: entries.length })} +

+ )} + + + + {done ? ( + + ) : ( + + )} + +
+
+ ); +} + +function StatusIcon({ status }: { status: FileStatus }) { + switch (status) { + case "checking": + case "uploading": + return ; + case "ready": + return ; + case "success": + return ; + case "error": + return ; + } +} diff --git a/ui/web/src/i18n/locales/en/common.json b/ui/web/src/i18n/locales/en/common.json index 33863608..ac4ce8de 100644 --- a/ui/web/src/i18n/locales/en/common.json +++ b/ui/web/src/i18n/locales/en/common.json @@ -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." } diff --git a/ui/web/src/i18n/locales/en/storage.json b/ui/web/src/i18n/locales/en/storage.json index a06ec030..845ecd3f 100644 --- a/ui/web/src/i18n/locales/en/storage.json +++ b/ui/web/src/i18n/locales/en/storage.json @@ -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" diff --git a/ui/web/src/i18n/locales/en/teams.json b/ui/web/src/i18n/locales/en/teams.json index 3ea5e02f..11761010 100644 --- a/ui/web/src/i18n/locales/en/teams.json +++ b/ui/web/src/i18n/locales/en/teams.json @@ -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": { diff --git a/ui/web/src/i18n/locales/vi/common.json b/ui/web/src/i18n/locales/vi/common.json index 999552a3..c2321f12 100644 --- a/ui/web/src/i18n/locales/vi/common.json +++ b/ui/web/src/i18n/locales/vi/common.json @@ -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." } diff --git a/ui/web/src/i18n/locales/vi/storage.json b/ui/web/src/i18n/locales/vi/storage.json index 86d9af84..fc164ce9 100644 --- a/ui/web/src/i18n/locales/vi/storage.json +++ b/ui/web/src/i18n/locales/vi/storage.json @@ -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" diff --git a/ui/web/src/i18n/locales/vi/teams.json b/ui/web/src/i18n/locales/vi/teams.json index c97231eb..30075d2d 100644 --- a/ui/web/src/i18n/locales/vi/teams.json +++ b/ui/web/src/i18n/locales/vi/teams.json @@ -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": { diff --git a/ui/web/src/i18n/locales/zh/common.json b/ui/web/src/i18n/locales/zh/common.json index 74ead283..efdf956c 100644 --- a/ui/web/src/i18n/locales/zh/common.json +++ b/ui/web/src/i18n/locales/zh/common.json @@ -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": "出了点问题,请稍后重试。" } diff --git a/ui/web/src/i18n/locales/zh/storage.json b/ui/web/src/i18n/locales/zh/storage.json index 1a09dd0d..c8acd622 100644 --- a/ui/web/src/i18n/locales/zh/storage.json +++ b/ui/web/src/i18n/locales/zh/storage.json @@ -14,6 +14,10 @@ "deleting": "删除中...", "confirmLabel": "删除" }, + "upload": { + "title": "上传到存储", + "description": "上传文件到数据目录" + }, "toast": { "deleted": "文件已删除", "deleteFailed": "删除文件失败" diff --git a/ui/web/src/i18n/locales/zh/teams.json b/ui/web/src/i18n/locales/zh/teams.json index f82a21b2..7bcc75f8 100644 --- a/ui/web/src/i18n/locales/zh/teams.json +++ b/ui/web/src/i18n/locales/zh/teams.json @@ -158,6 +158,10 @@ "detail": { "title": "文件内容", "empty": "文件为空或为二进制文件" + }, + "upload": { + "title": "上传到工作区", + "description": "上传文件供 Agent 在协作中读取" } }, "auditLogs": { diff --git a/ui/web/src/pages/storage/storage-page.tsx b/ui/web/src/pages/storage/storage-page.tsx index 7cedcb60..c0cfadee 100644 --- a/ui/web/src/pages/storage/storage-page.tsx +++ b/ui/web/src/pages/storage/storage-page.tsx @@ -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 = {}; + 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() { } actions={ - +
+ + +
} /> @@ -173,6 +206,14 @@ export function StoragePage() { /> + + {/* Delete confirmation dialog */} { if (!open) setDeleteTarget(null); }}> diff --git a/ui/web/src/pages/teams/board/team-workspace-dialog.tsx b/ui/web/src/pages/teams/board/team-workspace-dialog.tsx index c06ba083..e5b7c28e 100644 --- a/ui/web/src/pages/teams/board/team-workspace-dialog.tsx +++ b/ui/web/src/pages/teams/board/team-workspace-dialog.tsx @@ -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(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 = {}; + 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 ( @@ -169,6 +186,9 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team )} + @@ -188,6 +208,14 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team fetchBlob={fetchBlobByName} showSize /> + + );