feat(vault): cross-agent API, WS enrichment progress, nullable agent_id

Backend:
- Add cross-agent routes for all vault CRUD (/v1/vault/documents/*, /v1/vault/links/*)
  alongside existing per-agent routes for backward compat.
- All handlers skip agent ownership check when agentID is empty.
- Path traversal validation (reject .. and leading /) on create.
- Scope defaults to "shared" when creating without agent.
- Enrichment progress broadcast via WS event (vault.enrich.progress)
  using existing MessageBus infrastructure.
- GET /v1/vault/enrichment/status for polling fallback.

Frontend:
- Fix VaultDocument.agent_id type to optional/nullable — prevents
  runtime crash on shared/team docs without agent.
- All vault hooks use cross-agent URLs (/v1/vault/...) — no more
  skipping docs with null agent_id in graph/links/mutations.
- Remove dead agentId params from useUpdateDocument, useDeleteDocument,
  useCreateLink, useDeleteLink, useVaultLinks, LinkBadge.
- Pass team_id from selected team filter to create dialog.
- WS-based enrichment progress bar on vault page (useWsEvent hook).
- Disable rescan button during enrichment, auto-clear after 3s.
This commit is contained in:
viettranx committed 2026-04-10 18:25:08 +07:00
1 parent 57957ba6b1
commit 49fced695f
21 files changed
+261 -82

No files matched your search

+5 -1
View File
@@ -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,
+2
View File
@@ -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
+3 -1
View File
@@ -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
+41 -16
View File
@@ -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}
+78
View File
@@ -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
}
+25 -3
View File
@@ -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
}
}
+3
View File
@@ -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)
+2
View File
@@ -68,6 +68,8 @@
"media": "Media"
},
"rescanTooltip": "Rescan workspace",
"enriching": "Enriching",
"enrichComplete": "Enrichment complete",
"rescanNew": "{{count}} new",
"rescanUpdated": "{{count}} updated",
"rescanUnchanged": "{{count}} unchanged",
+2
View File
@@ -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",
+2
View File
@@ -68,6 +68,8 @@
"media": "Media"
},
"rescanTooltip": "重新扫描工作区",
"enriching": "处理中",
"enrichComplete": "处理完成",
"rescanNew": "{{count}} 个新文件",
"rescanUpdated": "{{count}} 个已更新",
"rescanUnchanged": "{{count}} 个未变",
@@ -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<EnrichmentEvent | null>(null);
useWsEvent("vault.enrich.progress", (payload) => {
const data = payload as EnrichmentEvent;
setEvent(data);
if (!data.running) {
setTimeout(() => setEvent(null), 3000);
}
});
return event;
}
@@ -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<string, unknown> }) => {
async (body: { path: string; title: string; doc_type: string; scope: string; team_id?: string; metadata?: Record<string, unknown> }) => {
try {
const url = agentId ? `/v1/agents/${agentId}/vault/documents` : `/v1/vault/documents`;
const doc = await http.post<VaultDocument>(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<string, unknown> }) => {
try {
const doc = await http.put<VaultDocument>(`/v1/agents/${agentId}/vault/documents/${docId}`, body);
const doc = await http.put<VaultDocument>(`/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<VaultLink>(`/v1/agents/${agentId}/vault/links`, body);
const link = await http.post<VaultLink>(`/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 };
}
+18 -29
View File
@@ -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<string, string[]>();
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<string>();
@@ -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,
});
@@ -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);
@@ -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 <img> 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 ? (
<div className="flex flex-wrap items-center gap-1 flex-1">
{outlinks.map((l) => (
<LinkBadge key={l.id} link={l} agentId={doc.agent_id} t={t} />
<LinkBadge key={l.id} link={l} t={t} />
))}
</div>
) : (
@@ -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 {
@@ -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) {
</div>
{outlinks.length > 0 && (
<div className="flex flex-wrap gap-1">
{outlinks.map((l) => <LinkBadge key={l.id} link={l} agentId={doc.agent_id} t={t} />)}
{outlinks.map((l) => <LinkBadge key={l.id} link={l} t={t} />)}
</div>
)}
</div>
@@ -80,7 +80,7 @@ export function VaultDocumentsTable({ documents, agents, loading, onSelect }: Pr
{doc.title || doc.path.split("/").pop()}
</td>
<td className="px-3 py-2 text-muted-foreground text-xs whitespace-nowrap">
{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")}
</td>
<td className="px-3 py-2 text-muted-foreground max-w-[200px]" title={doc.path}>
<span className="font-mono text-xs">{truncatePath(doc.path)}</span>
+1 -1
View File
@@ -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);
+24 -4
View File
@@ -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() {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="outline" onClick={() => rescan()} disabled={rescanPending}>
{rescanPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderSync className="h-3.5 w-3.5" />}
<Button size="sm" variant="outline" onClick={() => rescan()} disabled={rescanPending || enriching}>
{(rescanPending || enriching) ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderSync className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{t("rescanTooltip", "Rescan workspace")}</TooltipContent>
<TooltipContent>{enriching ? t("enriching", "Enriching documents...") : t("rescanTooltip", "Rescan workspace")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
@@ -163,6 +166,23 @@ export function VaultPage() {
</TooltipProvider>
</div>
{/* Enrichment progress bar */}
{enrichment && enrichment.total > 0 && (
<div className="px-3 py-1.5 flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${Math.round((enrichment.done / enrichment.total) * 100)}%` }}
/>
</div>
<span className="shrink-0">
{enrichment.running
? `${t("enriching", "Enriching")} ${enrichment.done}/${enrichment.total}`
: t("enrichComplete", "Enrichment complete")}
</span>
</div>
)}
{/* Graph — takes full remaining height */}
<div className="flex-1 min-h-0 relative">
<Suspense fallback={<div className="h-full animate-pulse bg-muted" />}>
@@ -184,7 +204,7 @@ export function VaultPage() {
onSelectResult={(doc) => { setDetailDoc(doc); setSelectedDocId(doc.id); }}
/>
)}
<VaultCreateDialog agentId={selectedAgent} open={createOpen} onOpenChange={setCreateOpen} />
<VaultCreateDialog agentId={selectedAgent} teamId={selectedTeam} open={createOpen} onOpenChange={setCreateOpen} />
{/* Detail dialog for all document views */}
<Suspense fallback={null}>
+1 -1
View File
@@ -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;