feat(vault): replace create dialog with multi-file upload modal

- Add POST /v1/vault/upload multipart endpoint with extension whitelist,
  50MB/file + 50 file count limits, path traversal prevention
- Replace manual create dialog with upload dialog: radio destination
  (Shared/Agent/Team), drag-drop zone, scrollable file list
- Files written to workspace folders, auto-enrich via EventVaultDocUpserted
- Split vault_handlers.go (991 lines) into 4 focused modules (~260 lines each)
- Add i18n keys for upload dialog (en/vi/zh)
This commit is contained in:
viettranx committed 2026-04-10 20:19:52 +07:00
1 parent e839f42412
commit da0cd81c0e
11 files changed
+1032 -625

No files matched your search

+273
View File
@@ -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)
}
+255
View File
@@ -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)
}
+231
View File
@@ -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,
})
}
+1 -510
View File
@@ -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) {
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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": "文档已更新",
@@ -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<UploadResult>("/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 };
}
+178 -106
View File
@@ -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<HTMLInputElement>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors },
} = useForm<VaultCreateFormData>({
resolver: zodResolver(vaultCreateSchema),
defaultValues: {
title: "",
path: "",
docType: "note",
scope: "personal",
},
});
const [destination, setDestination] = useState<Destination>("shared");
const [agentId, setAgentId] = useState("");
const [teamId, setTeamId] = useState("");
const [files, setFiles] = useState<File[]>([]);
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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md max-sm:inset-0">
<DialogHeader>
<DialogTitle>{t("createDoc")}</DialogTitle>
<DialogTitle>{t("upload.title", "Upload to Vault")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onValid)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="vault-title">{t("fields.title")} *</Label>
<Input
id="vault-title"
{...register("title")}
placeholder={t("fields.titlePlaceholder")}
className="text-base md:text-sm"
/>
{errors.title && (
<p className="text-xs text-destructive">{errors.title.message}</p>
)}
</div>
<div className="space-y-4">
{/* Destination radio group */}
<fieldset className="space-y-2">
<Label>{t("upload.destination", "Destination")}</Label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" name="dest" checked={destination === "shared"}
onChange={() => setDestination("shared")} className="accent-primary" />
{t("upload.shared", "Shared")}
</label>
<div className="space-y-1.5">
<Label htmlFor="vault-path">{t("fields.path")} *</Label>
<Input
id="vault-path"
{...register("path")}
placeholder={t("fields.pathPlaceholder")}
className="text-base md:text-sm font-mono"
/>
{errors.path && (
<p className="text-xs text-destructive">{errors.path.message}</p>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" name="dest" checked={destination === "agent"}
onChange={() => setDestination("agent")} className="accent-primary" />
{t("upload.agent", "Agent")}
</label>
{destination === "agent" && (
<select value={agentId} onChange={(e) => setAgentId(e.target.value)}
className="ml-6 w-[calc(100%-1.5rem)] text-base md:text-sm border rounded px-2 py-1.5 bg-background">
<option value="">{t("upload.selectAgent", "Select agent...")}</option>
{(agents ?? []).map((a) => (
<option key={a.id} value={a.id}>{a.display_name || a.agent_key}</option>
))}
</select>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="vault-doctype">{t("fields.docType")}</Label>
<select
id="vault-doctype"
value={docType}
onChange={(e) => setValue("docType", e.target.value, { shouldValidate: true })}
className="w-full text-base md:text-sm border rounded px-2 py-1.5 bg-background"
>
{DOC_TYPES.map((dt) => (
<option key={dt} value={dt}>{t(`type.${dt}`)}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" name="dest" checked={destination === "team"}
onChange={() => setDestination("team")} className="accent-primary" />
{t("upload.team", "Team")}
</label>
{destination === "team" && (
<select value={teamId} onChange={(e) => setTeamId(e.target.value)}
className="ml-6 w-[calc(100%-1.5rem)] text-base md:text-sm border rounded px-2 py-1.5 bg-background">
<option value="">{t("upload.selectTeam", "Select team...")}</option>
{(teams ?? []).map((team) => (
<option key={team.id} value={team.id}>{team.name}</option>
))}
</select>
)}
</div>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor="vault-scope">{t("fields.scope")}</Label>
<select
id="vault-scope"
value={scope}
onChange={(e) => setValue("scope", e.target.value, { shouldValidate: true })}
className="w-full text-base md:text-sm border rounded px-2 py-1.5 bg-background"
>
{SCOPES.map((s) => (
<option key={s} value={s}>{t(`scope.${s}`)}</option>
))}
</select>
</div>
{/* Drop zone */}
<div
onDragOver={(e) => { 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"
}`}
>
<Upload className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t("upload.dropzone", "Drop files here or click to browse")}</p>
<p className="text-xs text-muted-foreground/60">{t("upload.dropzoneHint", "Text files only (.md, .txt, .json, .yaml, .csv, ...)")}</p>
<input ref={inputRef} type="file" multiple accept={ACCEPT_ATTR}
onChange={onFileInput} className="hidden" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => { reset(); onOpenChange(false); }} disabled={saving}>
{t("cancel")}
</Button>
<Button type="submit" disabled={saving}>
{saving ? t("saving") : t("create")}
</Button>
</DialogFooter>
</form>
{/* File list */}
{files.length > 0 && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t("upload.files", { count: files.length, defaultValue: `Files (${files.length})` })}
</Label>
<div className="max-h-48 overflow-y-auto overscroll-contain space-y-1 rounded border p-2">
{files.map((f, i) => (
<div key={f.name} className="flex items-center gap-2 text-sm">
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate flex-1 font-mono text-xs">{f.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatBytes(f.size)}</span>
<button type="button" onClick={() => removeFile(i)}
className="shrink-0 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive">
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
{t("cancel")}
</Button>
<Button onClick={handleUpload} disabled={!canUpload}>
{isPending ? t("upload.uploading", "Uploading...") : t("upload.upload", "Upload")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
+1 -1
View File
@@ -204,7 +204,7 @@ export function VaultPage() {
onSelectResult={(doc) => { setDetailDoc(doc); setSelectedDocId(doc.id); }}
/>
)}
<VaultCreateDialog agentId={selectedAgent} teamId={selectedTeam} open={createOpen} onOpenChange={setCreateOpen} />
<VaultCreateDialog open={createOpen} onOpenChange={setCreateOpen} defaultAgentId={selectedAgent} defaultTeamId={selectedTeam} />
{/* Detail dialog for all document views */}
<Suspense fallback={null}>
-8
View File
@@ -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<typeof vaultCreateSchema>;
export type VaultLinkFormData = z.infer<typeof vaultLinkSchema>;