diff --git a/cmd/gateway.go b/cmd/gateway.go index e7805c42..d078a1a0 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -211,13 +211,16 @@ func runGateway() { } // V3: Wire vault enrichment worker (async summary + embedding + auto-linking). + var enrichProgress *vault.EnrichProgress if pgStores.Vault != nil && bgProvider != nil { - cleanupVaultEnrich := vault.RegisterEnrichWorker(vault.EnrichWorkerDeps{ + cleanupVaultEnrich, ep := vault.RegisterEnrichWorker(vault.EnrichWorkerDeps{ VaultStore: pgStores.Vault, Provider: bgProvider, Model: bgModel, EventBus: domainBus, + MsgBus: msgBus, }) + enrichProgress = ep defer cleanupVaultEnrich() slog.Info("vault enrichment worker registered", "provider", bgProvider.Name(), "model", bgModel) } @@ -292,6 +295,7 @@ func runGateway() { agentRouter: agentRouter, toolsReg: toolsReg, skillsLoader: skillsLoader, + enrichProgress: enrichProgress, workspace: workspace, dataDir: dataDir, domainBus: domainBus, diff --git a/cmd/gateway_deps.go b/cmd/gateway_deps.go index 4573b240..b05f6823 100644 --- a/cmd/gateway_deps.go +++ b/cmd/gateway_deps.go @@ -12,6 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" + "github.com/nextlevelbuilder/goclaw/internal/vault" ) // gatewayDeps holds shared dependencies used across the extracted gateway setup functions. @@ -27,6 +28,7 @@ type gatewayDeps struct { toolsReg *tools.Registry skillsLoader *skills.Loader // optional: enables skill creation in evolution approval permCache *cache.PermissionCache // nil if no tenant store; closed on shutdown to stop sweep goroutines + enrichProgress *vault.EnrichProgress // nil if enrichment worker not registered workspace string dataDir string domainBus eventbus.DomainEventBus diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index d14942ff..4048cab5 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -175,7 +175,9 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( // V3: Knowledge Vault document API if d.pgStores != nil && d.pgStores.Vault != nil { - d.server.SetVaultHandler(httpapi.NewVaultHandler(d.pgStores.Vault, d.pgStores.Teams, d.workspace, d.domainBus, d.pgStores.Agents, d.pgStores.Teams)) + vh := httpapi.NewVaultHandler(d.pgStores.Vault, d.pgStores.Teams, d.workspace, d.domainBus, d.pgStores.Agents, d.pgStores.Teams) + vh.SetEnrichProgress(d.enrichProgress) + d.server.SetVaultHandler(vh) } // V3: Episodic memory summaries API diff --git a/internal/http/vault_handlers.go b/internal/http/vault_handlers.go index c3a691ec..45e13b3b 100644 --- a/internal/http/vault_handlers.go +++ b/internal/http/vault_handlers.go @@ -34,15 +34,19 @@ type TeamLister interface { // VaultHandler serves Knowledge Vault document and link endpoints. type VaultHandler struct { - store store.VaultStore - teamAccess store.TeamAccessStore // nil = skip team membership validation (e.g. lite edition) - agents AgentLister // nil = rescan skips agent resolution - teams TeamLister // nil = rescan skips team resolution - workspace string - eventBus eventbus.DomainEventBus - rescanMu sync.Map // key: tenantID → struct{}, per-tenant concurrency guard + store store.VaultStore + teamAccess store.TeamAccessStore // nil = skip team membership validation (e.g. lite edition) + agents AgentLister // nil = rescan skips agent resolution + teams TeamLister // nil = rescan skips team resolution + workspace string + eventBus eventbus.DomainEventBus + enrichProgress *vault.EnrichProgress // nil = enrichment progress SSE disabled + rescanMu sync.Map // key: tenantID → struct{}, per-tenant concurrency guard } +// SetEnrichProgress injects the enrichment progress tracker for SSE streaming. +func (h *VaultHandler) SetEnrichProgress(p *vault.EnrichProgress) { h.enrichProgress = p } + func NewVaultHandler(s store.VaultStore, ta store.TeamAccessStore, workspace string, bus eventbus.DomainEventBus, agents AgentLister, teams TeamLister) *VaultHandler { return &VaultHandler{store: s, teamAccess: ta, agents: agents, teams: teams, workspace: workspace, eventBus: bus} } @@ -103,19 +107,26 @@ func (h *VaultHandler) applyNonOwnerTeamScope(ctx context.Context, opts *store.V } func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) { - // Cross-agent endpoints (agent_id optional). + // Cross-agent endpoints (agentID = "" from PathValue when no {agentID} in path). mux.HandleFunc("GET /v1/vault/documents", h.auth(h.handleListAllDocuments)) mux.HandleFunc("POST /v1/vault/documents", h.auth(h.handleCreateDocument)) - // Per-agent endpoints. + mux.HandleFunc("GET /v1/vault/documents/{docID}", h.auth(h.handleGetDocument)) + mux.HandleFunc("PUT /v1/vault/documents/{docID}", h.auth(h.handleUpdateDocument)) + mux.HandleFunc("DELETE /v1/vault/documents/{docID}", h.auth(h.handleDeleteDocument)) + mux.HandleFunc("GET /v1/vault/documents/{docID}/links", h.auth(h.handleGetLinks)) + 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/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)) + // Per-agent endpoints (backward compat — same handlers, agentID from path). mux.HandleFunc("GET /v1/agents/{agentID}/vault/documents", h.auth(h.handleListDocuments)) mux.HandleFunc("GET /v1/agents/{agentID}/vault/documents/{docID}", h.auth(h.handleGetDocument)) mux.HandleFunc("POST /v1/agents/{agentID}/vault/documents", h.auth(h.handleCreateDocument)) mux.HandleFunc("PUT /v1/agents/{agentID}/vault/documents/{docID}", h.auth(h.handleUpdateDocument)) mux.HandleFunc("DELETE /v1/agents/{agentID}/vault/documents/{docID}", h.auth(h.handleDeleteDocument)) - mux.HandleFunc("POST /v1/vault/rescan", h.auth(h.handleRescan)) - mux.HandleFunc("POST /v1/vault/search", h.auth(h.handleSearchAll)) - mux.HandleFunc("POST /v1/agents/{agentID}/vault/search", h.auth(h.handleSearch)) mux.HandleFunc("GET /v1/agents/{agentID}/vault/documents/{docID}/links", h.auth(h.handleGetLinks)) + mux.HandleFunc("POST /v1/agents/{agentID}/vault/search", h.auth(h.handleSearch)) mux.HandleFunc("POST /v1/agents/{agentID}/vault/links", h.auth(h.handleCreateLink)) mux.HandleFunc("DELETE /v1/agents/{agentID}/vault/links/{linkID}", h.auth(h.handleDeleteLink)) } @@ -222,7 +233,7 @@ func (h *VaultHandler) handleGetDocument(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - if doc == nil || (doc.AgentID != nil && *doc.AgentID != agentID) { + if doc == nil || (agentID != "" && doc.AgentID != nil && *doc.AgentID != agentID) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) return } @@ -447,7 +458,7 @@ func (h *VaultHandler) handleUpdateDocument(w http.ResponseWriter, r *http.Reque docID := r.PathValue("docID") existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) - if err != nil || existing == nil || (existing.AgentID != nil && *existing.AgentID != agentID) { + if err != nil || existing == nil || (agentID != "" && existing.AgentID != nil && *existing.AgentID != agentID) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) return } @@ -510,7 +521,7 @@ func (h *VaultHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Reque docID := r.PathValue("docID") existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID) - if err != nil || existing == nil || (existing.AgentID != nil && *existing.AgentID != agentID) { + if err != nil || existing == nil || (agentID != "" && existing.AgentID != nil && *existing.AgentID != agentID) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) return } @@ -567,7 +578,7 @@ func (h *VaultHandler) handleCreateLink(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusNotFound, map[string]string{"error": "one or both documents not found"}) return } - if from.AgentID != nil && *from.AgentID != agentID { + 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 } @@ -640,6 +651,11 @@ func (h *VaultHandler) handleRescan(w http.ResponseWriter, r *http.Request) { return } + // Signal enrichment progress so WS subscribers see running=true immediately. + if h.enrichProgress != nil && (result.New+result.Updated) > 0 { + h.enrichProgress.Start(result.New+result.Updated, store.TenantIDFromContext(r.Context())) + } + writeJSON(w, http.StatusOK, result) } @@ -677,6 +693,15 @@ func (h *VaultHandler) buildRescanMaps(ctx context.Context) (map[string]string, return agentMap, teamSet } +// handleEnrichmentStatus returns the current enrichment pipeline progress as JSON. +func (h *VaultHandler) handleEnrichmentStatus(w http.ResponseWriter, r *http.Request) { + if h.enrichProgress == nil { + writeJSON(w, http.StatusOK, vault.EnrichEvent{}) + return + } + writeJSON(w, http.StatusOK, h.enrichProgress.Status()) +} + var allowedDocTypes = map[string]bool{"context": true, "memory": true, "note": true, "skill": true, "episodic": true, "media": true} var allowedScopes = map[string]bool{"personal": true, "team": true, "shared": true} diff --git a/internal/vault/enrich_progress.go b/internal/vault/enrich_progress.go new file mode 100644 index 00000000..89b55736 --- /dev/null +++ b/internal/vault/enrich_progress.go @@ -0,0 +1,78 @@ +package vault + +import ( + "sync" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// EnrichProgress tracks enrichment pipeline progress and broadcasts via WS events. +type EnrichProgress struct { + mu sync.Mutex + msgBus bus.EventPublisher + tenantID uuid.UUID + total int + done int + running bool +} + +// NewEnrichProgress creates a progress tracker that broadcasts to WS clients. +func NewEnrichProgress(msgBus bus.EventPublisher) *EnrichProgress { + return &EnrichProgress{msgBus: msgBus} +} + +// EnrichEvent is the WS event payload for vault enrichment progress. +type EnrichEvent struct { + Phase string `json:"phase"` // enriching, complete + Done int `json:"done"` // docs completed so far + Total int `json:"total"` // total docs in pipeline + Running bool `json:"running"` // false when pipeline idle +} + +// Status returns current progress state (for polling fallback / HTTP endpoint). +func (p *EnrichProgress) Status() EnrichEvent { + p.mu.Lock() + defer p.mu.Unlock() + phase := "enriching" + if !p.running { + phase = "idle" + } + return EnrichEvent{Phase: phase, Done: p.done, Total: p.total, Running: p.running} +} + +func (p *EnrichProgress) broadcast(e EnrichEvent) { + if p.msgBus == nil { + return + } + bus.BroadcastForTenant(p.msgBus, protocol.EventVaultEnrichProgress, p.tenantID, e) +} + +// Start signals enrichment with updated total and tenant scope. +func (p *EnrichProgress) Start(total int, tenantID uuid.UUID) { + p.mu.Lock() + defer p.mu.Unlock() + p.total = total + p.tenantID = tenantID + p.running = true + p.broadcast(EnrichEvent{Phase: "enriching", Done: p.done, Total: p.total, Running: true}) +} + +// AddDone increments completed count by n and broadcasts progress. +func (p *EnrichProgress) AddDone(n int) { + p.mu.Lock() + defer p.mu.Unlock() + p.done += n + p.broadcast(EnrichEvent{Phase: "enriching", Done: p.done, Total: p.total, Running: true}) +} + +// Finish signals the enrichment pipeline has completed and resets counters. +func (p *EnrichProgress) Finish() { + p.mu.Lock() + defer p.mu.Unlock() + p.broadcast(EnrichEvent{Phase: "complete", Done: p.done, Total: p.total, Running: false}) + p.done = 0 + p.total = 0 + p.running = false +} diff --git a/internal/vault/enrich_worker.go b/internal/vault/enrich_worker.go index 58143e43..9a3f2065 100644 --- a/internal/vault/enrich_worker.go +++ b/internal/vault/enrich_worker.go @@ -5,6 +5,9 @@ import ( "encoding/json" "fmt" "log/slog" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "os" "path/filepath" "strings" @@ -39,19 +42,23 @@ type EnrichWorkerDeps struct { Provider providers.Provider Model string EventBus eventbus.DomainEventBus + MsgBus bus.EventPublisher // for WS event broadcast } // RegisterEnrichWorker subscribes the enrichment worker to vault doc events. -// Returns an unsubscribe function for cleanup. -func RegisterEnrichWorker(deps EnrichWorkerDeps) func() { +// Returns (unsubscribe func, progress tracker for WS broadcast). +func RegisterEnrichWorker(deps EnrichWorkerDeps) (func(), *EnrichProgress) { + progress := NewEnrichProgress(deps.MsgBus) w := &enrichWorker{ vault: deps.VaultStore, provider: deps.Provider, model: deps.Model, dedup: make(map[string]string), sem: semaphore.NewWeighted(enrichMaxConcurrent), + progress: progress, } - return deps.EventBus.Subscribe(eventbus.EventVaultDocUpserted, w.Handle) + unsub := deps.EventBus.Subscribe(eventbus.EventVaultDocUpserted, w.Handle) + return unsub, progress } // enrichWorker processes vault document upsert events to generate summaries, @@ -61,6 +68,7 @@ type enrichWorker struct { provider providers.Provider model string queue enrichBatchQueue + progress *EnrichProgress // Bounded dedup: docID → content_hash. Prevents re-processing unchanged files. dedupMu sync.Mutex @@ -109,15 +117,27 @@ type enriched struct { // Items are chunked into enrichBatchSize groups so bulk rescan doesn't // overwhelm the LLM provider with hundreds of concurrent requests. func (w *enrichWorker) processBatch(ctx context.Context, key string) { + var totalQueued int + for { items := w.queue.Drain(key) if len(items) == 0 { if w.queue.TryFinish(key) { + if totalQueued > 0 { + w.progress.Finish() + } return } continue } + totalQueued += len(items) + tenantID := uuid.Nil + if len(items) > 0 { + tenantID, _ = uuid.Parse(items[0].TenantID) + } + w.progress.Start(totalQueued, tenantID) + // Process in chunks of enrichBatchSize, up to enrichMaxConcurrent in parallel. var wg sync.WaitGroup for start := 0; start < len(items); start += enrichBatchSize { @@ -130,11 +150,13 @@ func (w *enrichWorker) processBatch(ctx context.Context, key string) { defer wg.Done() defer w.sem.Release(1) w.processChunk(ctx, chunk) + w.progress.AddDone(len(chunk)) }(items[start:end]) } wg.Wait() if w.queue.TryFinish(key) { + w.progress.Finish() return } } diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index 52ddea7f..3333c134 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -105,6 +105,9 @@ const ( // Tenant access revocation — forces affected user's UI to logout. EventTenantAccessRevoked = "tenant.access.revoked" + + // Vault enrichment pipeline progress. + EventVaultEnrichProgress = "vault.enrich.progress" ) // Agent event subtypes (in payload.type) diff --git a/ui/web/src/i18n/locales/en/vault.json b/ui/web/src/i18n/locales/en/vault.json index 2fd84dff..5585a2b4 100644 --- a/ui/web/src/i18n/locales/en/vault.json +++ b/ui/web/src/i18n/locales/en/vault.json @@ -68,6 +68,8 @@ "media": "Media" }, "rescanTooltip": "Rescan workspace", + "enriching": "Enriching", + "enrichComplete": "Enrichment complete", "rescanNew": "{{count}} new", "rescanUpdated": "{{count}} updated", "rescanUnchanged": "{{count}} unchanged", diff --git a/ui/web/src/i18n/locales/vi/vault.json b/ui/web/src/i18n/locales/vi/vault.json index d7297a42..404d596d 100644 --- a/ui/web/src/i18n/locales/vi/vault.json +++ b/ui/web/src/i18n/locales/vi/vault.json @@ -68,6 +68,8 @@ "media": "Media" }, "rescanTooltip": "Quét lại workspace", + "enriching": "Đang xử lý", + "enrichComplete": "Xử lý hoàn tất", "rescanNew": "{{count}} mới", "rescanUpdated": "{{count}} cập nhật", "rescanUnchanged": "{{count}} không đổi", diff --git a/ui/web/src/i18n/locales/zh/vault.json b/ui/web/src/i18n/locales/zh/vault.json index c4ae0a6a..1c575b9f 100644 --- a/ui/web/src/i18n/locales/zh/vault.json +++ b/ui/web/src/i18n/locales/zh/vault.json @@ -68,6 +68,8 @@ "media": "Media" }, "rescanTooltip": "重新扫描工作区", + "enriching": "处理中", + "enrichComplete": "处理完成", "rescanNew": "{{count}} 个新文件", "rescanUpdated": "{{count}} 个已更新", "rescanUnchanged": "{{count}} 个未变", diff --git a/ui/web/src/pages/vault/hooks/use-enrichment-progress.ts b/ui/web/src/pages/vault/hooks/use-enrichment-progress.ts new file mode 100644 index 00000000..6f85794d --- /dev/null +++ b/ui/web/src/pages/vault/hooks/use-enrichment-progress.ts @@ -0,0 +1,27 @@ +import { useState } from "react"; +import { useWsEvent } from "@/hooks/use-ws-event"; + +export interface EnrichmentEvent { + phase: string; // enriching, complete + done: number; + total: number; + running: boolean; +} + +/** + * Listens to vault.enrich.progress WS events and returns current enrichment state. + * Progress auto-clears 3s after completion. + */ +export function useEnrichmentProgress() { + const [event, setEvent] = useState(null); + + useWsEvent("vault.enrich.progress", (payload) => { + const data = payload as EnrichmentEvent; + setEvent(data); + if (!data.running) { + setTimeout(() => setEvent(null), 3000); + } + }); + + return event; +} diff --git a/ui/web/src/pages/vault/hooks/use-vault-mutations.ts b/ui/web/src/pages/vault/hooks/use-vault-mutations.ts index 0db6b4d0..3773344b 100644 --- a/ui/web/src/pages/vault/hooks/use-vault-mutations.ts +++ b/ui/web/src/pages/vault/hooks/use-vault-mutations.ts @@ -24,7 +24,7 @@ export function useCreateDocument(agentId: string) { const queryClient = useQueryClient(); const create = useCallback( - async (body: { path: string; title: string; doc_type: string; scope: string; metadata?: Record }) => { + async (body: { path: string; title: string; doc_type: string; scope: string; team_id?: string; metadata?: Record }) => { try { const url = agentId ? `/v1/agents/${agentId}/vault/documents` : `/v1/vault/documents`; const doc = await http.post(url, body); @@ -43,14 +43,14 @@ export function useCreateDocument(agentId: string) { } /** Update a vault document. */ -export function useUpdateDocument(agentId: string, docId: string) { +export function useUpdateDocument(docId: string) { const http = useHttp(); const queryClient = useQueryClient(); const update = useCallback( async (body: { title?: string; doc_type?: string; scope?: string; metadata?: Record }) => { try { - const doc = await http.put(`/v1/agents/${agentId}/vault/documents/${docId}`, body); + const doc = await http.put(`/v1/vault/documents/${docId}`, body); await queryClient.invalidateQueries({ queryKey: [VAULT_KEY] }); toast.success(i18n.t("vault:toast.docUpdated")); return doc; @@ -59,40 +59,40 @@ export function useUpdateDocument(agentId: string, docId: string) { throw err; } }, - [http, agentId, docId, queryClient], + [http, docId, queryClient], ); return { update }; } /** Delete a vault document. */ -export function useDeleteDocument(agentId: string, docId: string) { +export function useDeleteDocument(docId: string) { const http = useHttp(); const queryClient = useQueryClient(); const remove = useCallback(async () => { try { - await http.delete(`/v1/agents/${agentId}/vault/documents/${docId}`); + await http.delete(`/v1/vault/documents/${docId}`); await queryClient.invalidateQueries({ queryKey: [VAULT_KEY] }); toast.success(i18n.t("vault:toast.docDeleted")); } catch (err) { toast.error(i18n.t("vault:toast.docDeleteFailed"), err instanceof Error ? err.message : ""); throw err; } - }, [http, agentId, docId, queryClient]); + }, [http, docId, queryClient]); return { remove }; } /** Create a link between two vault documents. */ -export function useCreateLink(agentId: string) { +export function useCreateLink() { const http = useHttp(); const queryClient = useQueryClient(); const create = useCallback( async (body: { from_doc_id: string; to_doc_id: string; link_type: string; context?: string }) => { try { - const link = await http.post(`/v1/agents/${agentId}/vault/links`, body); + const link = await http.post(`/v1/vault/links`, body); await queryClient.invalidateQueries({ queryKey: [VAULT_KEY, "links"] }); await queryClient.invalidateQueries({ queryKey: [VAULT_KEY, "all-links"] }); toast.success(i18n.t("vault:toast.linkCreated")); @@ -102,7 +102,7 @@ export function useCreateLink(agentId: string) { throw err; } }, - [http, agentId, queryClient], + [http, queryClient], ); return { create }; @@ -146,13 +146,13 @@ export function useRescanWorkspace() { } /** Delete a vault link. */ -export function useDeleteLink(agentId: string, linkId: string) { +export function useDeleteLink(linkId: string) { const http = useHttp(); const queryClient = useQueryClient(); const remove = useCallback(async () => { try { - await http.delete(`/v1/agents/${agentId}/vault/links/${linkId}`); + await http.delete(`/v1/vault/links/${linkId}`); await queryClient.invalidateQueries({ queryKey: [VAULT_KEY, "links"] }); await queryClient.invalidateQueries({ queryKey: [VAULT_KEY, "all-links"] }); toast.success(i18n.t("vault:toast.linkDeleted")); @@ -160,7 +160,7 @@ export function useDeleteLink(agentId: string, linkId: string) { toast.error(i18n.t("vault:toast.linkDeleteFailed"), err instanceof Error ? err.message : ""); throw err; } - }, [http, agentId, linkId, queryClient]); + }, [http, linkId, queryClient]); return { remove }; } diff --git a/ui/web/src/pages/vault/hooks/use-vault.ts b/ui/web/src/pages/vault/hooks/use-vault.ts index 4aeff68f..8cb70f2d 100644 --- a/ui/web/src/pages/vault/hooks/use-vault.ts +++ b/ui/web/src/pages/vault/hooks/use-vault.ts @@ -109,7 +109,7 @@ export function useVaultAllLinks(agentId: string, documents: { id: string }[]) { const results = await Promise.all( batch.map((id) => http.get<{ outlinks: VaultLink[]; backlinks: VaultLink[] }>( - `/v1/agents/${agentId}/vault/documents/${id}/links`, + `/v1/vault/documents/${id}/links`, ).catch(() => ({ outlinks: [], backlinks: [] })), ), ); @@ -124,7 +124,7 @@ export function useVaultAllLinks(agentId: string, documents: { id: string }[]) { return true; }); }, - enabled: !!agentId && docIds.length > 0, + enabled: docIds.length > 0, staleTime: 60_000, }); @@ -157,34 +157,23 @@ export function useVaultGraphData(agentId: string, opts?: { teamId?: string }) { [documents], ); - // Fetch links grouped by agent_id — works for both single-agent and all-agents mode. + // Fetch links for all docs via cross-agent endpoint (works for null agent_id too). const { data: linksData, isLoading: linksLoading } = useQuery({ queryKey: [VAULT_KEY, "graph-links", docIdKey], queryFn: async () => { if (documents.length === 0) return []; - // Group doc IDs by agent_id - const byAgent = new Map(); - for (const doc of documents) { - const aid = doc.agent_id; - if (!aid) continue; - if (!byAgent.has(aid)) byAgent.set(aid, []); - byAgent.get(aid)!.push(doc.id); - } - const allLinks: VaultLink[] = []; - for (const [aid, ids] of byAgent) { - const batchSize = 10; - for (let i = 0; i < ids.length; i += batchSize) { - const batch = ids.slice(i, i + batchSize); - const results = await Promise.all( - batch.map((id) => - http.get<{ outlinks: VaultLink[]; backlinks: VaultLink[] }>( - `/v1/agents/${aid}/vault/documents/${id}/links`, - ).catch(() => ({ outlinks: [], backlinks: [] })), - ), - ); - for (const r of results) allLinks.push(...r.outlinks); - } + const batchSize = 10; + for (let i = 0; i < documents.length; i += batchSize) { + const batch = documents.slice(i, i + batchSize); + const results = await Promise.all( + batch.map((doc) => + http.get<{ outlinks: VaultLink[]; backlinks: VaultLink[] }>( + `/v1/vault/documents/${doc.id}/links`, + ).catch(() => ({ outlinks: [], backlinks: [] })), + ), + ); + for (const r of results) allLinks.push(...r.outlinks); } // Dedup const seen = new Set(); @@ -202,15 +191,15 @@ export function useVaultGraphData(agentId: string, opts?: { teamId?: string }) { } /** Get links (outlinks + backlinks) for a vault document. */ -export function useVaultLinks(agentId: string, docId: string | null) { +export function useVaultLinks(docId: string | null) { const http = useHttp(); const { data, isLoading } = useQuery({ - queryKey: [VAULT_KEY, "links", agentId, docId], + queryKey: [VAULT_KEY, "links", docId], queryFn: () => http.get<{ outlinks: VaultLink[]; backlinks: VaultLink[] }>( - `/v1/agents/${agentId}/vault/documents/${docId}/links`, + `/v1/vault/documents/${docId}/links`, ), - enabled: !!docId && !!agentId, + enabled: !!docId, placeholderData: (prev) => prev, }); diff --git a/ui/web/src/pages/vault/vault-create-dialog.tsx b/ui/web/src/pages/vault/vault-create-dialog.tsx index 1daa852f..3c05965f 100644 --- a/ui/web/src/pages/vault/vault-create-dialog.tsx +++ b/ui/web/src/pages/vault/vault-create-dialog.tsx @@ -16,12 +16,13 @@ const SCOPES = ["personal", "team", "shared"] as const; interface Props { agentId: string; + teamId?: string; open: boolean; onOpenChange: (open: boolean) => void; onCreated?: () => void; } -export function VaultCreateDialog({ agentId, open, onOpenChange, onCreated }: Props) { +export function VaultCreateDialog({ agentId, teamId, open, onOpenChange, onCreated }: Props) { const { t } = useTranslation("vault"); const { create } = useCreateDocument(agentId); const [saving, setSaving] = useState(false); @@ -49,7 +50,7 @@ export function VaultCreateDialog({ agentId, open, onOpenChange, onCreated }: Pr const onValid = async (data: VaultCreateFormData) => { setSaving(true); try { - await create({ title: data.title.trim(), path: data.path.trim(), doc_type: data.docType, scope: data.scope }); + await create({ title: data.title.trim(), path: data.path.trim(), doc_type: data.docType, scope: data.scope, ...(teamId ? { team_id: teamId } : {}) }); reset(); onCreated?.(); onOpenChange(false); diff --git a/ui/web/src/pages/vault/vault-detail-dialog.tsx b/ui/web/src/pages/vault/vault-detail-dialog.tsx index f2b1367f..8a73e042 100644 --- a/ui/web/src/pages/vault/vault-detail-dialog.tsx +++ b/ui/web/src/pages/vault/vault-detail-dialog.tsx @@ -24,7 +24,7 @@ interface Props { export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props) { const { t } = useTranslation("vault"); - const { outlinks, backlinks, loading } = useVaultLinks(doc?.agent_id ?? "", doc?.id ?? null); + const { outlinks, backlinks, loading } = useVaultLinks(doc?.id ?? null); const [editMode, setEditMode] = useState(false); const [editTitle, setEditTitle] = useState(""); @@ -50,8 +50,8 @@ export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props) ); // Fetch image as authenticated blob URL for rendering. const { url: imageUrl, error: imageError } = useVaultImageUrl(open && isMedia && isImage && doc ? doc.path : null); - const { update } = useUpdateDocument(doc?.agent_id ?? "", doc?.id ?? ""); - const { remove: removeDoc } = useDeleteDocument(doc?.agent_id ?? "", doc?.id ?? ""); + const { update } = useUpdateDocument(doc?.id ?? ""); + const { remove: removeDoc } = useDeleteDocument(doc?.id ?? ""); if (!doc) return null; @@ -229,7 +229,7 @@ export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props) {outlinks.length > 0 ? (
{outlinks.map((l) => ( - + ))}
) : ( diff --git a/ui/web/src/pages/vault/vault-detail-edit-section.tsx b/ui/web/src/pages/vault/vault-detail-edit-section.tsx index 4c86ff08..d77488d4 100644 --- a/ui/web/src/pages/vault/vault-detail-edit-section.tsx +++ b/ui/web/src/pages/vault/vault-detail-edit-section.tsx @@ -99,9 +99,9 @@ export function ScopeSelect({ value, onChange, t }: ScopeSelectProps) { } /** Outlink badge with inline delete confirmation. */ -export function LinkBadge({ link, agentId, t }: { link: VaultLink; agentId: string; t: (k: string) => string }) { +export function LinkBadge({ link, t }: { link: VaultLink; t: (k: string) => string }) { const [confirmDelete, setConfirmDelete] = useState(false); - const { remove } = useDeleteLink(agentId, link.id); + const { remove } = useDeleteLink(link.id); const handleDelete = async () => { try { diff --git a/ui/web/src/pages/vault/vault-detail-panel.tsx b/ui/web/src/pages/vault/vault-detail-panel.tsx index 54c4567e..8978361d 100644 --- a/ui/web/src/pages/vault/vault-detail-panel.tsx +++ b/ui/web/src/pages/vault/vault-detail-panel.tsx @@ -19,10 +19,10 @@ interface Props { export function VaultDetailPanel({ doc, open, onClose, onDeleted }: Props) { const { t } = useTranslation("vault"); - const { outlinks, backlinks } = useVaultLinks(doc?.agent_id ?? "", doc?.id ?? null); + const { outlinks, backlinks } = useVaultLinks(doc?.id ?? null); const { content, loading: contentLoading, error: contentError } = useVaultFileContent(open && doc ? doc.path : null); - const { update } = useUpdateDocument(doc?.agent_id ?? "", doc?.id ?? ""); - const { remove } = useDeleteDocument(doc?.agent_id ?? "", doc?.id ?? ""); + const { update } = useUpdateDocument(doc?.id ?? ""); + const { remove } = useDeleteDocument(doc?.id ?? ""); const [editMode, setEditMode] = useState(false); const [editTitle, setEditTitle] = useState(""); @@ -151,7 +151,7 @@ export function VaultDetailPanel({ doc, open, onClose, onDeleted }: Props) { {outlinks.length > 0 && (
- {outlinks.map((l) => )} + {outlinks.map((l) => )}
)} diff --git a/ui/web/src/pages/vault/vault-documents-table.tsx b/ui/web/src/pages/vault/vault-documents-table.tsx index 385d0f6c..6a949982 100644 --- a/ui/web/src/pages/vault/vault-documents-table.tsx +++ b/ui/web/src/pages/vault/vault-documents-table.tsx @@ -80,7 +80,7 @@ export function VaultDocumentsTable({ documents, agents, loading, onSelect }: Pr {doc.title || doc.path.split("/").pop()} - {agentMap.get(doc.agent_id) ?? doc.agent_id.slice(0, 8)} + {doc.agent_id ? (agentMap.get(doc.agent_id) ?? doc.agent_id.slice(0, 8)) : t("scope.shared")} {truncatePath(doc.path)} diff --git a/ui/web/src/pages/vault/vault-link-dialog.tsx b/ui/web/src/pages/vault/vault-link-dialog.tsx index 277ac4d0..4c71aeeb 100644 --- a/ui/web/src/pages/vault/vault-link-dialog.tsx +++ b/ui/web/src/pages/vault/vault-link-dialog.tsx @@ -31,7 +31,7 @@ interface Props { export function VaultLinkDialog({ agentId, fromDoc, open, onOpenChange, onCreated }: Props) { const { t } = useTranslation("vault"); - const { create } = useCreateLink(agentId); + const { create } = useCreateLink(); const { documents } = useVaultDocuments(agentId, { limit: 100 }); const [saving, setSaving] = useState(false); diff --git a/ui/web/src/pages/vault/vault-page.tsx b/ui/web/src/pages/vault/vault-page.tsx index 66dc1b15..01d93171 100644 --- a/ui/web/src/pages/vault/vault-page.tsx +++ b/ui/web/src/pages/vault/vault-page.tsx @@ -7,6 +7,7 @@ import { useAgents } from "@/pages/agents/hooks/use-agents"; import { useTeams } from "@/pages/teams/hooks/use-teams"; import { useIsMobile } from "@/hooks/use-media-query"; import { useVaultDocuments, useVaultGraphData, useRescanWorkspace } from "./hooks/use-vault"; +import { useEnrichmentProgress } from "./hooks/use-enrichment-progress"; import { VaultDocumentSidebar } from "./vault-document-sidebar"; import { VaultSearchDialog } from "./vault-search-dialog"; import { VaultCreateDialog } from "./vault-create-dialog"; @@ -40,6 +41,8 @@ export function VaultPage() { const [page, setPage] = useState(0); const { rescan, isPending: rescanPending } = useRescanWorkspace(); + const enrichment = useEnrichmentProgress(); + const enriching = enrichment?.running ?? false; const { documents, total, loading } = useVaultDocuments(selectedAgent, { teamId: selectedTeam || undefined, @@ -143,11 +146,11 @@ export function VaultPage() { - - {t("rescanTooltip", "Rescan workspace")} + {enriching ? t("enriching", "Enriching documents...") : t("rescanTooltip", "Rescan workspace")} @@ -163,6 +166,23 @@ export function VaultPage() { + {/* Enrichment progress bar */} + {enrichment && enrichment.total > 0 && ( +
+
+
+
+ + {enrichment.running + ? `${t("enriching", "Enriching")} ${enrichment.done}/${enrichment.total}` + : t("enrichComplete", "Enrichment complete")} + +
+ )} + {/* Graph — takes full remaining height */}
}> @@ -184,7 +204,7 @@ export function VaultPage() { onSelectResult={(doc) => { setDetailDoc(doc); setSelectedDocId(doc.id); }} /> )} - + {/* Detail dialog for all document views */} diff --git a/ui/web/src/types/vault.ts b/ui/web/src/types/vault.ts index c725c597..9ea805f5 100644 --- a/ui/web/src/types/vault.ts +++ b/ui/web/src/types/vault.ts @@ -2,7 +2,7 @@ export interface VaultDocument { id: string; tenant_id: string; - agent_id: string; + agent_id?: string | null; team_id?: string; scope: "personal" | "team" | "shared"; custom_scope?: string;