diff --git a/internal/http/vault_handler_documents.go b/internal/http/vault_handler_documents.go new file mode 100644 index 00000000..1a589f1e --- /dev/null +++ b/internal/http/vault_handler_documents.go @@ -0,0 +1,273 @@ +package http + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// handleListAllDocuments lists vault documents across all agents in tenant. +// Optional query param agent_id to filter by specific agent. +func (h *VaultHandler) handleListAllDocuments(w http.ResponseWriter, r *http.Request) { + tenantID := store.TenantIDFromContext(r.Context()) + agentID := r.URL.Query().Get("agent_id") + opts := h.parseListOpts(r) + + // Validate team membership if specific team requested. + if opts.TeamID != nil && *opts.TeamID != "" { + if !h.validateTeamMembership(r.Context(), w, *opts.TeamID) { + return + } + } + // Non-owner without team_id filter: show personal + user's teams. + if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { + h.applyNonOwnerTeamScope(r.Context(), &opts) + } + + docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) + if err != nil { + slog.Warn("vault.list_all failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if docs == nil { + docs = []store.VaultDocument{} + } + total, cntErr := h.store.CountDocuments(r.Context(), tenantID.String(), agentID, opts) + if cntErr != nil { + slog.Warn("vault.count failed", "error", cntErr) + } + writeJSON(w, http.StatusOK, vaultDocListResponse{Documents: docs, Total: total}) +} + +// handleListDocuments lists vault documents for a specific agent. +func (h *VaultHandler) handleListDocuments(w http.ResponseWriter, r *http.Request) { + tenantID := store.TenantIDFromContext(r.Context()) + agentID := r.PathValue("agentID") + opts := h.parseListOpts(r) + + if opts.TeamID != nil && *opts.TeamID != "" { + if !h.validateTeamMembership(r.Context(), w, *opts.TeamID) { + return + } + } + if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { + h.applyNonOwnerTeamScope(r.Context(), &opts) + } + + docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) + if err != nil { + slog.Warn("vault.list failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if docs == nil { + docs = []store.VaultDocument{} + } + total, cntErr := h.store.CountDocuments(r.Context(), tenantID.String(), agentID, opts) + if cntErr != nil { + slog.Warn("vault.count failed", "error", cntErr) + } + writeJSON(w, http.StatusOK, vaultDocListResponse{Documents: docs, Total: total}) +} + +// handleGetDocument returns a single vault document by ID, scoped to the agent. +func (h *VaultHandler) handleGetDocument(w http.ResponseWriter, r *http.Request) { + tenantID := store.TenantIDFromContext(r.Context()) + agentID := r.PathValue("agentID") + docID := r.PathValue("docID") + + doc, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) + if err != nil { + slog.Warn("vault.get failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if doc == nil || (agentID != "" && doc.AgentID != nil && *doc.AgentID != agentID) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) + return + } + // Verify team boundary — non-owner must be team member to view team docs. + if doc.TeamID != nil && *doc.TeamID != "" && !store.IsOwnerRole(r.Context()) { + if !h.validateTeamMembership(r.Context(), w, *doc.TeamID) { + return + } + } + writeJSON(w, http.StatusOK, doc) +} + +// handleCreateDocument creates a new vault document. +func (h *VaultHandler) handleCreateDocument(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + tenantID := store.TenantIDFromContext(r.Context()) + agentID := r.PathValue("agentID") + + var body struct { + Path string `json:"path"` + Title string `json:"title"` + DocType string `json:"doc_type"` + Scope string `json:"scope"` + TeamID string `json:"team_id"` + Metadata map[string]any `json:"metadata"` + } + if !bindJSON(w, r, locale, &body) { + return + } + if body.Path == "" || body.Title == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path and title are required"}) + return + } + if strings.Contains(body.Path, "..") || strings.HasPrefix(body.Path, "/") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + if body.DocType == "" { + body.DocType = "note" + } + if body.Scope == "" { + body.Scope = "personal" + } + if !validDocType(body.DocType) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid doc_type"}) + return + } + if !validScope(body.Scope) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid scope"}) + return + } + + doc := &store.VaultDocument{ + TenantID: tenantID.String(), + Path: body.Path, + Title: body.Title, + DocType: body.DocType, + Scope: body.Scope, + Metadata: body.Metadata, + } + if agentID != "" { + doc.AgentID = &agentID + } else if doc.Scope == "personal" { + doc.Scope = "shared" // no agent → shared scope + } + if body.TeamID != "" { + if !h.validateTeamMembership(r.Context(), w, body.TeamID) { + return + } + doc.TeamID = &body.TeamID + if body.Scope == "personal" { + doc.Scope = "team" + } + } + if err := h.store.UpsertDocument(r.Context(), doc); err != nil { + slog.Warn("vault.create failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + // Re-fetch by ID (set via RETURNING) — unambiguous even when same path exists across teams. + created, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), doc.ID) + if created != nil { + writeJSON(w, http.StatusCreated, created) + } else { + writeJSON(w, http.StatusCreated, doc) + } +} + +// handleUpdateDocument updates an existing vault document. +func (h *VaultHandler) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + tenantID := store.TenantIDFromContext(r.Context()) + agentID := r.PathValue("agentID") + docID := r.PathValue("docID") + + existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) + if err != nil || existing == nil || (agentID != "" && existing.AgentID != nil && *existing.AgentID != agentID) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) + return + } + + var body struct { + Title *string `json:"title"` + DocType *string `json:"doc_type"` + Scope *string `json:"scope"` + TeamID *string `json:"team_id"` // nil=no change, ""=clear, "uuid"=set + Metadata map[string]any `json:"metadata"` + } + if !bindJSON(w, r, locale, &body) { + return + } + + if body.Title != nil { + existing.Title = *body.Title + } + if body.DocType != nil { + existing.DocType = *body.DocType + } + if body.Scope != nil { + existing.Scope = *body.Scope + } + if body.TeamID != nil { + // Only owner/admin can change team assignment. + if !store.IsOwnerRole(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can change document team assignment"}) + return + } + if *body.TeamID == "" { + existing.TeamID = nil + existing.Scope = "personal" + } else { + existing.TeamID = body.TeamID + existing.Scope = "team" + } + } + if body.Metadata != nil { + existing.Metadata = body.Metadata + } + + if err := h.store.UpsertDocument(r.Context(), existing); err != nil { + slog.Warn("vault.update failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + updated, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) + if updated != nil { + writeJSON(w, http.StatusOK, updated) + } else { + writeJSON(w, http.StatusOK, existing) + } +} + +// handleDeleteDocument deletes a vault document by ID. +func (h *VaultHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { + tenantID := store.TenantIDFromContext(r.Context()) + agentID := r.PathValue("agentID") + docID := r.PathValue("docID") + + existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) + if err != nil || existing == nil || (agentID != "" && existing.AgentID != nil && *existing.AgentID != agentID) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) + return + } + + // Verify team boundary before deletion. + if existing.TeamID != nil && *existing.TeamID != "" && !store.IsOwnerRole(r.Context()) { + if !h.validateTeamMembership(r.Context(), w, *existing.TeamID) { + return + } + } + + // DeleteDocument without RunContext applies no team_id filter (broad match on tenant+agent+path). + // This is safe because we pre-validated team membership above and use server-derived existing.Path. + // Use the doc's actual agent_id (may be empty for team/shared docs). + deleteAgentID := "" + if existing.AgentID != nil { + deleteAgentID = *existing.AgentID + } + if err := h.store.DeleteDocument(r.Context(), tenantID.String(), deleteAgentID, existing.Path); err != nil { + slog.Warn("vault.delete failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/http/vault_handler_links.go b/internal/http/vault_handler_links.go new file mode 100644 index 00000000..1ddf0bf3 --- /dev/null +++ b/internal/http/vault_handler_links.go @@ -0,0 +1,255 @@ +package http + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// handleSearchAll runs tenant-wide search (agent_id optional in body). +func (h *VaultHandler) handleSearchAll(w http.ResponseWriter, r *http.Request) { + h.doSearch(w, r, "") +} + +// handleSearch runs hybrid FTS+vector search scoped to a specific agent. +func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) { + h.doSearch(w, r, r.PathValue("agentID")) +} + +// doSearch is the shared search implementation for both per-agent and tenant-wide endpoints. +func (h *VaultHandler) doSearch(w http.ResponseWriter, r *http.Request, agentID string) { + locale := extractLocale(r) + tenantID := store.TenantIDFromContext(r.Context()) + + var body struct { + Query string `json:"query"` + AgentID string `json:"agent_id"` + Scope string `json:"scope"` + DocTypes []string `json:"doc_types"` + MaxResults int `json:"max_results"` + TeamID string `json:"team_id"` + } + if !bindJSON(w, r, locale, &body) { + return + } + if body.Query == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "query is required"}) + return + } + if body.MaxResults <= 0 { + body.MaxResults = 10 + } + // Body agent_id only used when path doesn't provide one (tenant-wide endpoint). + if agentID == "" { + agentID = body.AgentID + } + + searchOpts := store.VaultSearchOptions{ + Query: body.Query, + AgentID: agentID, + TenantID: tenantID.String(), + Scope: body.Scope, + DocTypes: body.DocTypes, + MaxResults: body.MaxResults, + } + if body.TeamID != "" { + if !h.validateTeamMembership(r.Context(), w, body.TeamID) { + return + } + searchOpts.TeamID = &body.TeamID + } else if !store.IsOwnerRole(r.Context()) { + if ids := h.userAccessibleTeamIDs(r.Context()); len(ids) > 0 { + searchOpts.TeamIDs = ids + } else { + empty := "" + searchOpts.TeamID = &empty + } + } + + results, err := h.store.Search(r.Context(), searchOpts) + if err != nil { + slog.Warn("vault.search failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if results == nil { + results = []store.VaultSearchResult{} + } + writeJSON(w, http.StatusOK, results) +} + +// handleGetLinks returns outgoing links and backlinks for a vault document. +func (h *VaultHandler) handleGetLinks(w http.ResponseWriter, r *http.Request) { + tenantID := store.TenantIDFromContext(r.Context()) + _ = r.PathValue("agentID") // agent scoping done at document level + docID := r.PathValue("docID") + + outLinks, err := h.store.GetOutLinks(r.Context(), tenantID.String(), docID) + if err != nil { + slog.Warn("vault.outlinks failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + backlinks, err := h.store.GetBacklinks(r.Context(), tenantID.String(), docID) + if err != nil { + slog.Warn("vault.backlinks failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if outLinks == nil { + outLinks = []store.VaultLink{} + } + if backlinks == nil { + backlinks = []store.VaultBacklink{} + } + + // Filter backlinks by team boundary — derive team context from the target document + // itself (not a query param) so clients don't need to supply it correctly. + isOwner := store.IsOwnerRole(r.Context()) + if !isOwner { + targetDoc, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) + var currentTeamID string + if targetDoc != nil && targetDoc.TeamID != nil { + currentTeamID = *targetDoc.TeamID + } + filtered := make([]store.VaultBacklink, 0, len(backlinks)) + for _, bl := range backlinks { + if currentTeamID != "" { + if bl.TeamID != nil && *bl.TeamID != currentTeamID { + continue + } + } else { + if bl.TeamID != nil && *bl.TeamID != "" { + continue + } + } + filtered = append(filtered, bl) + } + backlinks = filtered + } + + // Resolve target doc titles for outlinks so the UI can display names instead of IDs. + docNames := make(map[string]string, len(outLinks)) + for _, l := range outLinks { + if _, seen := docNames[l.ToDocID]; seen { + continue + } + if d, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), l.ToDocID); err == nil && d != nil { + name := d.Title + if name == "" { + if idx := strings.LastIndex(d.Path, "/"); idx >= 0 { + name = d.Path[idx+1:] + } else { + name = d.Path + } + } + docNames[l.ToDocID] = name + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "outlinks": outLinks, + "backlinks": backlinks, + "doc_names": docNames, + }) +} + +// handleCreateLink creates a link between two vault documents. +func (h *VaultHandler) handleCreateLink(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + tenantID := store.TenantIDFromContext(r.Context()) + + var body struct { + FromDocID string `json:"from_doc_id"` + ToDocID string `json:"to_doc_id"` + LinkType string `json:"link_type"` + Context string `json:"context"` + } + if !bindJSON(w, r, locale, &body) { + return + } + if body.FromDocID == "" || body.ToDocID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from_doc_id and to_doc_id are required"}) + return + } + if body.LinkType == "" { + body.LinkType = "reference" + } + + // Verify both docs exist, same tenant, and at least source belongs to this agent. + agentID := r.PathValue("agentID") + from, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), body.FromDocID) + to, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), body.ToDocID) + if from == nil || to == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "one or both documents not found"}) + return + } + if agentID != "" && from.AgentID != nil && *from.AgentID != agentID { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "source document does not belong to this agent"}) + return + } + // Block cross-team linking (both team docs must be in same team). + if from.TeamID != nil && to.TeamID != nil && *from.TeamID != *to.TeamID { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot link documents from different teams"}) + return + } + + link := &store.VaultLink{ + FromDocID: body.FromDocID, + ToDocID: body.ToDocID, + LinkType: body.LinkType, + Context: body.Context, + } + if err := h.store.CreateLink(r.Context(), link); err != nil { + slog.Warn("vault.create_link failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, link) +} + +// handleBatchGetLinks returns all outlinks for a batch of doc IDs in one query. +func (h *VaultHandler) handleBatchGetLinks(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + tenantID := store.TenantIDFromContext(r.Context()) + + var body struct { + DocIDs []string `json:"doc_ids"` + } + if !bindJSON(w, r, locale, &body) { + return + } + if len(body.DocIDs) == 0 { + writeJSON(w, http.StatusOK, []store.VaultLink{}) + return + } + if len(body.DocIDs) > 500 { + body.DocIDs = body.DocIDs[:500] + } + + links, err := h.store.GetOutLinksBatch(r.Context(), tenantID.String(), body.DocIDs) + if err != nil { + slog.Warn("vault.batch_links failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if links == nil { + links = []store.VaultLink{} + } + writeJSON(w, http.StatusOK, links) +} + +// handleDeleteLink deletes a vault link. +func (h *VaultHandler) handleDeleteLink(w http.ResponseWriter, r *http.Request) { + tenantID := store.TenantIDFromContext(r.Context()) + linkID := r.PathValue("linkID") + + if err := h.store.DeleteLink(r.Context(), tenantID.String(), linkID); err != nil { + slog.Warn("vault.delete_link failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/http/vault_handler_upload.go b/internal/http/vault_handler_upload.go new file mode 100644 index 00000000..032a3d68 --- /dev/null +++ b/internal/http/vault_handler_upload.go @@ -0,0 +1,231 @@ +package http + +import ( + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/eventbus" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/vault" +) + +// allowedUploadExts is the whitelist of text-based file extensions accepted by vault upload. +var allowedUploadExts = map[string]bool{ + ".md": true, ".txt": true, ".json": true, ".yaml": true, ".yml": true, + ".csv": true, ".toml": true, ".xml": true, ".html": true, ".htm": true, + ".go": true, ".py": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true, + ".rs": true, ".java": true, ".rb": true, ".sh": true, ".sql": true, + ".swift": true, ".kt": true, ".c": true, ".cpp": true, ".h": true, +} + +// handleUpload accepts multipart file uploads and registers them in the vault. +// Files are written to the tenant workspace in the appropriate subfolder based on agent/team selection, +// then UpsertDocument + EventVaultDocUpserted triggers the existing enrichment pipeline. +func (h *VaultHandler) handleUpload(w http.ResponseWriter, r *http.Request) { + tenantID := store.TenantIDFromContext(r.Context()) + tenantIDStr := tenantID.String() + + // 32 MB in-memory; remainder spills to disk. + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"}) + return + } + defer r.MultipartForm.RemoveAll() + + agentIDStr := r.FormValue("agent_id") + teamIDStr := r.FormValue("team_id") + + // Validate team membership if provided. + if teamIDStr != "" { + if !h.validateTeamMembership(r.Context(), w, teamIDStr) { + return + } + } + + // Resolve agent UUID → agent_key for folder placement. + var agentKey string + if agentIDStr != "" { + if h.agents != nil { + agents, err := h.agents.List(r.Context(), "") + if err == nil { + for _, a := range agents { + if a.ID.String() == agentIDStr { + agentKey = a.AgentKey + break + } + } + } + } + if agentKey == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent not found"}) + return + } + } + + // Determine target subfolder and scope. + var subDir, scope string + switch { + case agentIDStr != "": + subDir = filepath.Join("agents", agentKey) + scope = "personal" + case teamIDStr != "": + subDir = filepath.Join("teams", teamIDStr) + scope = "team" + default: + scope = "shared" + } + + wsPath := h.resolveTenantWorkspace(r.Context()) + if wsPath == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace not available"}) + return + } + targetDir := filepath.Join(wsPath, subDir) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + slog.Warn("vault.upload: mkdir failed", "dir", targetDir, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create target directory"}) + return + } + + files := r.MultipartForm.File["files"] + if len(files) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no files provided"}) + return + } + if len(files) > 50 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "too many files (max 50)"}) + return + } + + type uploadResult struct { + Doc *store.VaultDocument `json:"document"` + Error string `json:"error,omitempty"` + } + + var results []uploadResult + var created int + + for _, fh := range files { + // Sanitize filename — basename only, no path traversal. + name := filepath.Base(fh.Filename) + if name == "." || name == ".." || name == "" { + results = append(results, uploadResult{Error: "invalid filename: " + fh.Filename}) + continue + } + + // Validate extension. + ext := strings.ToLower(filepath.Ext(name)) + if !allowedUploadExts[ext] { + results = append(results, uploadResult{Error: "unsupported file type: " + name}) + continue + } + + // Enforce per-file size limit (50 MB). + if fh.Size > 50<<20 { + results = append(results, uploadResult{Error: "file too large: " + name}) + continue + } + + // Open uploaded file. + src, err := fh.Open() + if err != nil { + results = append(results, uploadResult{Error: "failed to read: " + name}) + continue + } + + // Write to workspace. + dstPath := filepath.Join(targetDir, name) + dst, err := os.Create(dstPath) + if err != nil { + src.Close() + results = append(results, uploadResult{Error: "failed to write: " + name}) + continue + } + _, copyErr := io.Copy(dst, src) + dst.Close() + src.Close() + if copyErr != nil { + results = append(results, uploadResult{Error: "failed to write: " + name}) + continue + } + + // Hash the written file. + hash, err := vault.ContentHashFile(dstPath) + if err != nil { + results = append(results, uploadResult{Error: "failed to hash: " + name}) + continue + } + + // Build workspace-relative path for DB storage. + relPath := name + if subDir != "" { + relPath = subDir + "/" + name + } + + doc := &store.VaultDocument{ + TenantID: tenantIDStr, + Path: relPath, + Title: vault.InferTitle(relPath), + DocType: vault.InferDocType(relPath), + ContentHash: hash, + Scope: scope, + } + if agentIDStr != "" { + doc.AgentID = &agentIDStr + } + if teamIDStr != "" { + doc.TeamID = &teamIDStr + } + + if err := h.store.UpsertDocument(r.Context(), doc); err != nil { + slog.Warn("vault.upload: upsert failed", "path", relPath, "error", err) + results = append(results, uploadResult{Error: "failed to register: " + name}) + continue + } + + // Publish enrichment event (same pattern as rescan.go). + if h.eventBus != nil { + agentForEvent := "" + if agentIDStr != "" { + agentForEvent = agentIDStr + } + h.eventBus.Publish(eventbus.DomainEvent{ + ID: uuid.Must(uuid.NewV7()).String(), + Type: eventbus.EventVaultDocUpserted, + SourceID: doc.ID + ":" + hash, + TenantID: tenantIDStr, + AgentID: agentForEvent, + Timestamp: time.Now(), + Payload: eventbus.VaultDocUpsertedPayload{ + DocID: doc.ID, + TenantID: tenantIDStr, + AgentID: agentForEvent, + Path: relPath, + ContentHash: hash, + Workspace: wsPath, + }, + }) + } + + results = append(results, uploadResult{Doc: doc}) + created++ + } + + // Signal enrichment progress for WS subscribers. + if h.enrichProgress != nil && created > 0 { + h.enrichProgress.Start(created, tenantID) + } + + slog.Info("vault.upload", "tenant", tenantIDStr, "uploaded", created, "errors", len(files)-created) + + writeJSON(w, http.StatusOK, map[string]any{ + "documents": results, + "count": created, + }) +} diff --git a/internal/http/vault_handlers.go b/internal/http/vault_handlers.go index 543a19dd..ac060399 100644 --- a/internal/http/vault_handlers.go +++ b/internal/http/vault_handlers.go @@ -117,6 +117,7 @@ func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/vault/links", h.auth(h.handleCreateLink)) mux.HandleFunc("DELETE /v1/vault/links/{linkID}", h.auth(h.handleDeleteLink)) mux.HandleFunc("POST /v1/vault/links/batch", h.auth(h.handleBatchGetLinks)) + mux.HandleFunc("POST /v1/vault/upload", h.auth(h.handleUpload)) mux.HandleFunc("POST /v1/vault/rescan", h.auth(h.handleRescan)) mux.HandleFunc("POST /v1/vault/search", h.auth(h.handleSearchAll)) mux.HandleFunc("GET /v1/vault/enrichment/status", h.auth(h.handleEnrichmentStatus)) @@ -157,516 +158,6 @@ func (h *VaultHandler) parseListOpts(r *http.Request) store.VaultListOptions { return opts } -// handleListAllDocuments lists vault documents across all agents in tenant. -// Optional query param agent_id to filter by specific agent. -func (h *VaultHandler) handleListAllDocuments(w http.ResponseWriter, r *http.Request) { - tenantID := store.TenantIDFromContext(r.Context()) - agentID := r.URL.Query().Get("agent_id") - opts := h.parseListOpts(r) - - // Validate team membership if specific team requested. - if opts.TeamID != nil && *opts.TeamID != "" { - if !h.validateTeamMembership(r.Context(), w, *opts.TeamID) { - return - } - } - // Non-owner without team_id filter: show personal + user's teams. - if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { - h.applyNonOwnerTeamScope(r.Context(), &opts) - } - - docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) - if err != nil { - slog.Warn("vault.list_all failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if docs == nil { - docs = []store.VaultDocument{} - } - total, cntErr := h.store.CountDocuments(r.Context(), tenantID.String(), agentID, opts) - if cntErr != nil { - slog.Warn("vault.count failed", "error", cntErr) - } - writeJSON(w, http.StatusOK, vaultDocListResponse{Documents: docs, Total: total}) -} - -// handleListDocuments lists vault documents for a specific agent. -func (h *VaultHandler) handleListDocuments(w http.ResponseWriter, r *http.Request) { - tenantID := store.TenantIDFromContext(r.Context()) - agentID := r.PathValue("agentID") - opts := h.parseListOpts(r) - - if opts.TeamID != nil && *opts.TeamID != "" { - if !h.validateTeamMembership(r.Context(), w, *opts.TeamID) { - return - } - } - if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { - h.applyNonOwnerTeamScope(r.Context(), &opts) - } - - docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) - if err != nil { - slog.Warn("vault.list failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if docs == nil { - docs = []store.VaultDocument{} - } - total, cntErr := h.store.CountDocuments(r.Context(), tenantID.String(), agentID, opts) - if cntErr != nil { - slog.Warn("vault.count failed", "error", cntErr) - } - writeJSON(w, http.StatusOK, vaultDocListResponse{Documents: docs, Total: total}) -} - -// handleGetDocument returns a single vault document by ID, scoped to the agent. -func (h *VaultHandler) handleGetDocument(w http.ResponseWriter, r *http.Request) { - tenantID := store.TenantIDFromContext(r.Context()) - agentID := r.PathValue("agentID") - docID := r.PathValue("docID") - - doc, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) - if err != nil { - slog.Warn("vault.get failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if doc == nil || (agentID != "" && doc.AgentID != nil && *doc.AgentID != agentID) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) - return - } - // Verify team boundary — non-owner must be team member to view team docs. - if doc.TeamID != nil && *doc.TeamID != "" && !store.IsOwnerRole(r.Context()) { - if !h.validateTeamMembership(r.Context(), w, *doc.TeamID) { - return - } - } - writeJSON(w, http.StatusOK, doc) -} - -// handleSearchAll runs tenant-wide search (agent_id optional in body). -func (h *VaultHandler) handleSearchAll(w http.ResponseWriter, r *http.Request) { - h.doSearch(w, r, "") -} - -// handleSearch runs hybrid FTS+vector search scoped to a specific agent. -func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) { - h.doSearch(w, r, r.PathValue("agentID")) -} - -// doSearch is the shared search implementation for both per-agent and tenant-wide endpoints. -func (h *VaultHandler) doSearch(w http.ResponseWriter, r *http.Request, agentID string) { - locale := extractLocale(r) - tenantID := store.TenantIDFromContext(r.Context()) - - var body struct { - Query string `json:"query"` - AgentID string `json:"agent_id"` - Scope string `json:"scope"` - DocTypes []string `json:"doc_types"` - MaxResults int `json:"max_results"` - TeamID string `json:"team_id"` - } - if !bindJSON(w, r, locale, &body) { - return - } - if body.Query == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "query is required"}) - return - } - if body.MaxResults <= 0 { - body.MaxResults = 10 - } - // Body agent_id only used when path doesn't provide one (tenant-wide endpoint). - if agentID == "" { - agentID = body.AgentID - } - - searchOpts := store.VaultSearchOptions{ - Query: body.Query, - AgentID: agentID, - TenantID: tenantID.String(), - Scope: body.Scope, - DocTypes: body.DocTypes, - MaxResults: body.MaxResults, - } - if body.TeamID != "" { - if !h.validateTeamMembership(r.Context(), w, body.TeamID) { - return - } - searchOpts.TeamID = &body.TeamID - } else if !store.IsOwnerRole(r.Context()) { - if ids := h.userAccessibleTeamIDs(r.Context()); len(ids) > 0 { - searchOpts.TeamIDs = ids - } else { - empty := "" - searchOpts.TeamID = &empty - } - } - - results, err := h.store.Search(r.Context(), searchOpts) - if err != nil { - slog.Warn("vault.search failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if results == nil { - results = []store.VaultSearchResult{} - } - writeJSON(w, http.StatusOK, results) -} - -// handleGetLinks returns outgoing links and backlinks for a vault document. -func (h *VaultHandler) handleGetLinks(w http.ResponseWriter, r *http.Request) { - tenantID := store.TenantIDFromContext(r.Context()) - _ = r.PathValue("agentID") // agent scoping done at document level - docID := r.PathValue("docID") - - outLinks, err := h.store.GetOutLinks(r.Context(), tenantID.String(), docID) - if err != nil { - slog.Warn("vault.outlinks failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - backlinks, err := h.store.GetBacklinks(r.Context(), tenantID.String(), docID) - if err != nil { - slog.Warn("vault.backlinks failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if outLinks == nil { - outLinks = []store.VaultLink{} - } - if backlinks == nil { - backlinks = []store.VaultBacklink{} - } - - // Filter backlinks by team boundary — derive team context from the target document - // itself (not a query param) so clients don't need to supply it correctly. - isOwner := store.IsOwnerRole(r.Context()) - if !isOwner { - targetDoc, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) - var currentTeamID string - if targetDoc != nil && targetDoc.TeamID != nil { - currentTeamID = *targetDoc.TeamID - } - filtered := make([]store.VaultBacklink, 0, len(backlinks)) - for _, bl := range backlinks { - if currentTeamID != "" { - if bl.TeamID != nil && *bl.TeamID != currentTeamID { - continue - } - } else { - if bl.TeamID != nil && *bl.TeamID != "" { - continue - } - } - filtered = append(filtered, bl) - } - backlinks = filtered - } - - // Resolve target doc titles for outlinks so the UI can display names instead of IDs. - docNames := make(map[string]string, len(outLinks)) - for _, l := range outLinks { - if _, seen := docNames[l.ToDocID]; seen { - continue - } - if d, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), l.ToDocID); err == nil && d != nil { - name := d.Title - if name == "" { - if idx := strings.LastIndex(d.Path, "/"); idx >= 0 { - name = d.Path[idx+1:] - } else { - name = d.Path - } - } - docNames[l.ToDocID] = name - } - } - - writeJSON(w, http.StatusOK, map[string]any{ - "outlinks": outLinks, - "backlinks": backlinks, - "doc_names": docNames, - }) -} - -// handleCreateDocument creates a new vault document. -func (h *VaultHandler) handleCreateDocument(w http.ResponseWriter, r *http.Request) { - locale := extractLocale(r) - tenantID := store.TenantIDFromContext(r.Context()) - agentID := r.PathValue("agentID") - - var body struct { - Path string `json:"path"` - Title string `json:"title"` - DocType string `json:"doc_type"` - Scope string `json:"scope"` - TeamID string `json:"team_id"` - Metadata map[string]any `json:"metadata"` - } - if !bindJSON(w, r, locale, &body) { - return - } - if body.Path == "" || body.Title == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path and title are required"}) - return - } - if strings.Contains(body.Path, "..") || strings.HasPrefix(body.Path, "/") { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) - return - } - if body.DocType == "" { - body.DocType = "note" - } - if body.Scope == "" { - body.Scope = "personal" - } - if !validDocType(body.DocType) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid doc_type"}) - return - } - if !validScope(body.Scope) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid scope"}) - return - } - - doc := &store.VaultDocument{ - TenantID: tenantID.String(), - Path: body.Path, - Title: body.Title, - DocType: body.DocType, - Scope: body.Scope, - Metadata: body.Metadata, - } - if agentID != "" { - doc.AgentID = &agentID - } else if doc.Scope == "personal" { - doc.Scope = "shared" // no agent → shared scope - } - if body.TeamID != "" { - if !h.validateTeamMembership(r.Context(), w, body.TeamID) { - return - } - doc.TeamID = &body.TeamID - if body.Scope == "personal" { - doc.Scope = "team" - } - } - if err := h.store.UpsertDocument(r.Context(), doc); err != nil { - slog.Warn("vault.create failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - // Re-fetch by ID (set via RETURNING) — unambiguous even when same path exists across teams. - created, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), doc.ID) - if created != nil { - writeJSON(w, http.StatusCreated, created) - } else { - writeJSON(w, http.StatusCreated, doc) - } -} - -// handleUpdateDocument updates an existing vault document. -func (h *VaultHandler) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { - locale := extractLocale(r) - tenantID := store.TenantIDFromContext(r.Context()) - agentID := r.PathValue("agentID") - docID := r.PathValue("docID") - - existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) - if err != nil || existing == nil || (agentID != "" && existing.AgentID != nil && *existing.AgentID != agentID) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) - return - } - - var body struct { - Title *string `json:"title"` - DocType *string `json:"doc_type"` - Scope *string `json:"scope"` - TeamID *string `json:"team_id"` // nil=no change, ""=clear, "uuid"=set - Metadata map[string]any `json:"metadata"` - } - if !bindJSON(w, r, locale, &body) { - return - } - - if body.Title != nil { - existing.Title = *body.Title - } - if body.DocType != nil { - existing.DocType = *body.DocType - } - if body.Scope != nil { - existing.Scope = *body.Scope - } - if body.TeamID != nil { - // Only owner/admin can change team assignment. - if !store.IsOwnerRole(r.Context()) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can change document team assignment"}) - return - } - if *body.TeamID == "" { - existing.TeamID = nil - existing.Scope = "personal" - } else { - existing.TeamID = body.TeamID - existing.Scope = "team" - } - } - if body.Metadata != nil { - existing.Metadata = body.Metadata - } - - if err := h.store.UpsertDocument(r.Context(), existing); err != nil { - slog.Warn("vault.update failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - updated, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) - if updated != nil { - writeJSON(w, http.StatusOK, updated) - } else { - writeJSON(w, http.StatusOK, existing) - } -} - -// handleDeleteDocument deletes a vault document and its links. -func (h *VaultHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { - tenantID := store.TenantIDFromContext(r.Context()) - agentID := r.PathValue("agentID") - docID := r.PathValue("docID") - - existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) - if err != nil || existing == nil || (agentID != "" && existing.AgentID != nil && *existing.AgentID != agentID) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) - return - } - - // Verify team boundary before deletion. - if existing.TeamID != nil && *existing.TeamID != "" && !store.IsOwnerRole(r.Context()) { - if !h.validateTeamMembership(r.Context(), w, *existing.TeamID) { - return - } - } - - // DeleteDocument without RunContext applies no team_id filter (broad match on tenant+agent+path). - // This is safe because we pre-validated team membership above and use server-derived existing.Path. - // Use the doc's actual agent_id (may be empty for team/shared docs). - deleteAgentID := "" - if existing.AgentID != nil { - deleteAgentID = *existing.AgentID - } - if err := h.store.DeleteDocument(r.Context(), tenantID.String(), deleteAgentID, existing.Path); err != nil { - slog.Warn("vault.delete failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// handleCreateLink creates a link between two vault documents. -func (h *VaultHandler) handleCreateLink(w http.ResponseWriter, r *http.Request) { - locale := extractLocale(r) - tenantID := store.TenantIDFromContext(r.Context()) - - var body struct { - FromDocID string `json:"from_doc_id"` - ToDocID string `json:"to_doc_id"` - LinkType string `json:"link_type"` - Context string `json:"context"` - } - if !bindJSON(w, r, locale, &body) { - return - } - if body.FromDocID == "" || body.ToDocID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from_doc_id and to_doc_id are required"}) - return - } - if body.LinkType == "" { - body.LinkType = "reference" - } - - // Verify both docs exist, same tenant, and at least source belongs to this agent. - agentID := r.PathValue("agentID") - from, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), body.FromDocID) - to, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), body.ToDocID) - if from == nil || to == nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "one or both documents not found"}) - return - } - if agentID != "" && from.AgentID != nil && *from.AgentID != agentID { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "source document does not belong to this agent"}) - return - } - // Block cross-team linking (both team docs must be in same team). - if from.TeamID != nil && to.TeamID != nil && *from.TeamID != *to.TeamID { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot link documents from different teams"}) - return - } - - link := &store.VaultLink{ - FromDocID: body.FromDocID, - ToDocID: body.ToDocID, - LinkType: body.LinkType, - Context: body.Context, - } - if err := h.store.CreateLink(r.Context(), link); err != nil { - slog.Warn("vault.create_link failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusCreated, link) -} - -// handleBatchGetLinks returns all outlinks for a batch of doc IDs in one query. -func (h *VaultHandler) handleBatchGetLinks(w http.ResponseWriter, r *http.Request) { - locale := extractLocale(r) - tenantID := store.TenantIDFromContext(r.Context()) - - var body struct { - DocIDs []string `json:"doc_ids"` - } - if !bindJSON(w, r, locale, &body) { - return - } - if len(body.DocIDs) == 0 { - writeJSON(w, http.StatusOK, []store.VaultLink{}) - return - } - if len(body.DocIDs) > 500 { - body.DocIDs = body.DocIDs[:500] - } - - links, err := h.store.GetOutLinksBatch(r.Context(), tenantID.String(), body.DocIDs) - if err != nil { - slog.Warn("vault.batch_links failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if links == nil { - links = []store.VaultLink{} - } - writeJSON(w, http.StatusOK, links) -} - -// handleDeleteLink deletes a vault link. -func (h *VaultHandler) handleDeleteLink(w http.ResponseWriter, r *http.Request) { - tenantID := store.TenantIDFromContext(r.Context()) - linkID := r.PathValue("linkID") - - if err := h.store.DeleteLink(r.Context(), tenantID.String(), linkID); err != nil { - slog.Warn("vault.delete_link failed", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - w.WriteHeader(http.StatusNoContent) -} - // handleRescan walks the entire tenant workspace and registers missing/changed files in vault. // Infers agent/team ownership from directory structure: agents/{key}/, teams/{uuid}/, or root shared. func (h *VaultHandler) handleRescan(w http.ResponseWriter, r *http.Request) { diff --git a/ui/web/src/i18n/locales/en/vault.json b/ui/web/src/i18n/locales/en/vault.json index 5585a2b4..94a0dbd5 100644 --- a/ui/web/src/i18n/locales/en/vault.json +++ b/ui/web/src/i18n/locales/en/vault.json @@ -77,7 +77,23 @@ "rescanNoFiles": "No new files found", "rescanBusy": "Rescan already in progress", "rescanError": "Rescan failed", + "upload": { + "title": "Upload to Vault", + "destination": "Destination", + "shared": "Shared", + "agent": "Agent", + "team": "Team", + "selectAgent": "Select agent...", + "selectTeam": "Select team...", + "dropzone": "Drop files here or click to browse", + "dropzoneHint": "Text files only (.md, .txt, .json, .yaml, .csv, ...)", + "files": "Files ({{count}})", + "uploading": "Uploading...", + "upload": "Upload" + }, "toast": { + "uploadSuccess": "{{count}} file(s) uploaded", + "uploadFailed": "Upload failed", "docCreated": "Document created", "docCreateFailed": "Failed to create document", "docUpdated": "Document updated", diff --git a/ui/web/src/i18n/locales/vi/vault.json b/ui/web/src/i18n/locales/vi/vault.json index 404d596d..d17ef7ee 100644 --- a/ui/web/src/i18n/locales/vi/vault.json +++ b/ui/web/src/i18n/locales/vi/vault.json @@ -77,7 +77,23 @@ "rescanNoFiles": "Không tìm thấy file mới", "rescanBusy": "Đang quét, vui lòng đợi", "rescanError": "Quét thất bại", + "upload": { + "title": "Tải lên Kho Tri Thức", + "destination": "Đích đến", + "shared": "Chia sẻ", + "agent": "Agent", + "team": "Nhóm", + "selectAgent": "Chọn agent...", + "selectTeam": "Chọn nhóm...", + "dropzone": "Kéo thả file vào đây hoặc nhấn để chọn", + "dropzoneHint": "Chỉ file văn bản (.md, .txt, .json, .yaml, .csv, ...)", + "files": "File ({{count}})", + "uploading": "Đang tải lên...", + "upload": "Tải lên" + }, "toast": { + "uploadSuccess": "Đã tải lên {{count}} file", + "uploadFailed": "Tải lên thất bại", "docCreated": "Đã tạo tài liệu", "docCreateFailed": "Tạo tài liệu thất bại", "docUpdated": "Đã cập nhật tài liệu", diff --git a/ui/web/src/i18n/locales/zh/vault.json b/ui/web/src/i18n/locales/zh/vault.json index 1c575b9f..bf51031f 100644 --- a/ui/web/src/i18n/locales/zh/vault.json +++ b/ui/web/src/i18n/locales/zh/vault.json @@ -77,7 +77,23 @@ "rescanNoFiles": "未发现新文件", "rescanBusy": "扫描正在进行中", "rescanError": "扫描失败", + "upload": { + "title": "上传到知识库", + "destination": "目标位置", + "shared": "共享", + "agent": "智能体", + "team": "团队", + "selectAgent": "选择智能体...", + "selectTeam": "选择团队...", + "dropzone": "拖放文件到此处或点击浏览", + "dropzoneHint": "仅限文本文件 (.md, .txt, .json, .yaml, .csv, ...)", + "files": "文件 ({{count}})", + "uploading": "上传中...", + "upload": "上传" + }, "toast": { + "uploadSuccess": "已上传 {{count}} 个文件", + "uploadFailed": "上传失败", "docCreated": "文档已创建", "docCreateFailed": "创建文档失败", "docUpdated": "文档已更新", diff --git a/ui/web/src/pages/vault/hooks/use-vault-upload.ts b/ui/web/src/pages/vault/hooks/use-vault-upload.ts new file mode 100644 index 00000000..f98d1892 --- /dev/null +++ b/ui/web/src/pages/vault/hooks/use-vault-upload.ts @@ -0,0 +1,45 @@ +import { useCallback, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { toast } from "@/stores/use-toast-store"; +import i18n from "@/i18n"; +import type { VaultDocument } from "@/types/vault"; + +const VAULT_KEY = "vault"; + +interface UploadResult { + documents: Array<{ document?: VaultDocument; error?: string }>; + count: number; +} + +/** Upload files to vault via multipart POST /v1/vault/upload. */ +export function useVaultUpload() { + const http = useHttp(); + const queryClient = useQueryClient(); + const [isPending, setIsPending] = useState(false); + + const upload = useCallback( + async (files: File[], opts?: { agentId?: string; teamId?: string }) => { + setIsPending(true); + try { + const form = new FormData(); + if (opts?.agentId) form.append("agent_id", opts.agentId); + if (opts?.teamId) form.append("team_id", opts.teamId); + for (const f of files) form.append("files", f); + + const result = await http.upload("/v1/vault/upload", form); + await queryClient.invalidateQueries({ queryKey: [VAULT_KEY] }); + toast.success(i18n.t("vault:toast.uploadSuccess", { count: result.count })); + return result; + } catch (err) { + toast.error(i18n.t("vault:toast.uploadFailed"), err instanceof Error ? err.message : ""); + throw err; + } finally { + setIsPending(false); + } + }, + [http, queryClient], + ); + + return { upload, isPending }; +} diff --git a/ui/web/src/pages/vault/vault-create-dialog.tsx b/ui/web/src/pages/vault/vault-create-dialog.tsx index 3c05965f..1c7657ad 100644 --- a/ui/web/src/pages/vault/vault-create-dialog.tsx +++ b/ui/web/src/pages/vault/vault-create-dialog.tsx @@ -1,143 +1,215 @@ -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useState, useEffect, useCallback, useRef, type ChangeEvent, type DragEvent } from "react"; import { useTranslation } from "react-i18next"; +import { Upload, X, FileText } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; -import { useCreateDocument } from "./hooks/use-vault"; -import { vaultCreateSchema, type VaultCreateFormData } from "@/schemas/vault.schema"; +import { useAgents } from "@/pages/agents/hooks/use-agents"; +import { useTeams } from "@/pages/teams/hooks/use-teams"; +import { useVaultUpload } from "./hooks/use-vault-upload"; -const DOC_TYPES = ["context", "memory", "note", "skill"] as const; -const SCOPES = ["personal", "team", "shared"] as const; +const ACCEPTED_EXTS = new Set([ + ".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".toml", ".xml", ".html", ".htm", + ".go", ".py", ".js", ".ts", ".tsx", ".jsx", ".rs", ".java", ".rb", ".sh", ".sql", + ".swift", ".kt", ".c", ".cpp", ".h", +]); +const ACCEPT_ATTR = Array.from(ACCEPTED_EXTS).join(","); -interface Props { - agentId: string; - teamId?: string; - open: boolean; - onOpenChange: (open: boolean) => void; - onCreated?: () => void; +function isAllowedExt(name: string): boolean { + const dot = name.lastIndexOf("."); + if (dot < 0) return false; + return ACCEPTED_EXTS.has(name.slice(dot).toLowerCase()); } -export function VaultCreateDialog({ agentId, teamId, open, onOpenChange, onCreated }: Props) { +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +type Destination = "shared" | "agent" | "team"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onUploaded?: () => void; + defaultAgentId?: string; + defaultTeamId?: string; +} + +export function VaultCreateDialog({ open, onOpenChange, onUploaded, defaultAgentId, defaultTeamId }: Props) { const { t } = useTranslation("vault"); - const { create } = useCreateDocument(agentId); - const [saving, setSaving] = useState(false); + const { agents } = useAgents(); + const { teams, load: loadTeams } = useTeams(); + const { upload, isPending } = useVaultUpload(); + const inputRef = useRef(null); - const { - register, - handleSubmit, - watch, - setValue, - reset, - formState: { errors }, - } = useForm({ - resolver: zodResolver(vaultCreateSchema), - defaultValues: { - title: "", - path: "", - docType: "note", - scope: "personal", - }, - }); + const [destination, setDestination] = useState("shared"); + const [agentId, setAgentId] = useState(""); + const [teamId, setTeamId] = useState(""); + const [files, setFiles] = useState([]); + const [dragging, setDragging] = useState(false); - const docType = watch("docType"); - const scope = watch("scope"); + // Load teams on first open + useEffect(() => { if (open) loadTeams(); }, [open, loadTeams]); - const onValid = async (data: VaultCreateFormData) => { - setSaving(true); + // Reset state when dialog opens; pre-select destination from parent filters + useEffect(() => { + if (!open) return; + setFiles([]); + setDragging(false); + if (defaultAgentId) { + setDestination("agent"); + setAgentId(defaultAgentId); + } else if (defaultTeamId) { + setDestination("team"); + setTeamId(defaultTeamId); + } else { + setDestination("shared"); + } + }, [open, defaultAgentId, defaultTeamId]); + + const addFiles = useCallback((incoming: File[]) => { + const valid = incoming.filter((f) => isAllowedExt(f.name)); + setFiles((prev) => { + const names = new Set(prev.map((f) => f.name)); + return [...prev, ...valid.filter((f) => !names.has(f.name))]; + }); + }, []); + + const onDrop = useCallback((e: DragEvent) => { + e.preventDefault(); + setDragging(false); + addFiles(Array.from(e.dataTransfer.files)); + }, [addFiles]); + + const onFileInput = useCallback((e: ChangeEvent) => { + addFiles(Array.from(e.target.files ?? [])); + e.target.value = ""; + }, [addFiles]); + + const removeFile = (idx: number) => setFiles((prev) => prev.filter((_, i) => i !== idx)); + + const handleUpload = async () => { + const opts = destination === "agent" ? { agentId } + : destination === "team" ? { teamId } + : {}; try { - await create({ title: data.title.trim(), path: data.path.trim(), doc_type: data.docType, scope: data.scope, ...(teamId ? { team_id: teamId } : {}) }); - reset(); - onCreated?.(); + await upload(files, opts); onOpenChange(false); + onUploaded?.(); } catch { // error toasted in hook - } finally { - setSaving(false); } }; - const handleClose = (v: boolean) => { - if (!saving) { reset(); onOpenChange(v); } - }; + const canUpload = files.length > 0 + && !isPending + && (destination !== "agent" || agentId) + && (destination !== "team" || teamId); + + const handleClose = (v: boolean) => { if (!isPending) onOpenChange(v); }; return ( - {t("createDoc")} + {t("upload.title", "Upload to Vault")} -
-
- - - {errors.title && ( -

{errors.title.message}

- )} -
+
+ {/* Destination radio group */} +
+ +
+ -
- - - {errors.path && ( -

{errors.path.message}

- )} -
+ + {destination === "agent" && ( + + )} -
-
- - + + {destination === "team" && ( + + )}
+
-
- - -
+ {/* Drop zone */} +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + onClick={() => inputRef.current?.click()} + className={`flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors ${ + dragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50" + }`} + > + +

{t("upload.dropzone", "Drop files here or click to browse")}

+

{t("upload.dropzoneHint", "Text files only (.md, .txt, .json, .yaml, .csv, ...)")}

+
- - - - - + {/* File list */} + {files.length > 0 && ( +
+ +
+ {files.map((f, i) => ( +
+ + {f.name} + {formatBytes(f.size)} + +
+ ))} +
+
+ )} +
+ + + + +
); diff --git a/ui/web/src/pages/vault/vault-page.tsx b/ui/web/src/pages/vault/vault-page.tsx index 01d93171..057a67aa 100644 --- a/ui/web/src/pages/vault/vault-page.tsx +++ b/ui/web/src/pages/vault/vault-page.tsx @@ -204,7 +204,7 @@ export function VaultPage() { onSelectResult={(doc) => { setDetailDoc(doc); setSelectedDocId(doc.id); }} /> )} - + {/* Detail dialog for all document views */} diff --git a/ui/web/src/schemas/vault.schema.ts b/ui/web/src/schemas/vault.schema.ts index d335f72a..95c23d3e 100644 --- a/ui/web/src/schemas/vault.schema.ts +++ b/ui/web/src/schemas/vault.schema.ts @@ -1,17 +1,9 @@ import { z } from "zod"; -export const vaultCreateSchema = z.object({ - title: z.string().min(1, "Required"), - path: z.string().min(1, "Required"), - docType: z.string().min(1), - scope: z.string().min(1), -}); - export const vaultLinkSchema = z.object({ toDocId: z.string().min(1, "Required"), linkType: z.string().min(1, "Required"), context: z.string().max(500).optional(), }); -export type VaultCreateFormData = z.infer; export type VaultLinkFormData = z.infer;