mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 22:15:00 +00:00
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:
1 parent
b221ed2fb6
commit
9ee82be219
17 files changed
+688
-10
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "出了点问题,请稍后重试。"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"deleting": "删除中...",
|
||||
"confirmLabel": "删除"
|
||||
},
|
||||
"upload": {
|
||||
"title": "上传到存储",
|
||||
"description": "上传文件到数据目录"
|
||||
},
|
||||
"toast": {
|
||||
"deleted": "文件已删除",
|
||||
"deleteFailed": "删除文件失败"
|
||||
|
||||
@@ -158,6 +158,10 @@
|
||||
"detail": {
|
||||
"title": "文件内容",
|
||||
"empty": "文件为空或为二进制文件"
|
||||
},
|
||||
"upload": {
|
||||
"title": "上传到工作区",
|
||||
"description": "上传文件供 Agent 在协作中读取"
|
||||
}
|
||||
},
|
||||
"auditLogs": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user