mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 16:17:25 +00:00
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:
1 parent
e839f42412
commit
da0cd81c0e
11 files changed
+1032
-625
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user