mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 14:17:40 +00:00
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:
1 parent
57957ba6b1
commit
49fced695f
21 files changed
+261
-82
No files matched your search
+5
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -68,6 +68,8 @@
|
||||
"media": "Media"
|
||||
},
|
||||
"rescanTooltip": "Rescan workspace",
|
||||
"enriching": "Enriching",
|
||||
"enrichComplete": "Enrichment complete",
|
||||
"rescanNew": "{{count}} new",
|
||||
"rescanUpdated": "{{count}} updated",
|
||||
"rescanUnchanged": "{{count}} unchanged",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user