diff --git a/internal/http/vault_handler_upload.go b/internal/http/vault_handler_upload.go index c0004916..00a5ba13 100644 --- a/internal/http/vault_handler_upload.go +++ b/internal/http/vault_handler_upload.go @@ -129,6 +129,7 @@ func (h *VaultHandler) handleUpload(w http.ResponseWriter, r *http.Request) { var results []uploadResult var created int + var pendingEvents []eventbus.DomainEvent for _, fh := range files { // Sanitize filename — basename only, no path traversal. @@ -208,13 +209,13 @@ func (h *VaultHandler) handleUpload(w http.ResponseWriter, r *http.Request) { continue } - // Publish enrichment event (same pattern as rescan.go). + // Collect enrichment events — published after Start() to avoid race. if h.eventBus != nil { agentForEvent := "" if agentIDStr != "" { agentForEvent = agentIDStr } - h.eventBus.Publish(eventbus.DomainEvent{ + pendingEvents = append(pendingEvents, eventbus.DomainEvent{ ID: uuid.Must(uuid.NewV7()).String(), Type: eventbus.EventVaultDocUpserted, SourceID: doc.ID + ":" + hash, @@ -236,10 +237,13 @@ func (h *VaultHandler) handleUpload(w http.ResponseWriter, r *http.Request) { created++ } - // Signal enrichment progress for WS subscribers. + // Start progress BEFORE publishing events to avoid race with workers. if h.enrichProgress != nil && created > 0 { h.enrichProgress.Start(created, tenantID) } + for _, event := range pendingEvents { + h.eventBus.Publish(event) + } slog.Info("vault.upload", "tenant", tenantIDStr, "uploaded", created, "errors", len(files)-created) diff --git a/internal/http/vault_handlers.go b/internal/http/vault_handlers.go index 1dbf172b..8f7c3de8 100644 --- a/internal/http/vault_handlers.go +++ b/internal/http/vault_handlers.go @@ -195,9 +195,16 @@ 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())) + // Start progress BEFORE publishing events so workers see running=true + // and AddDone calls are not dropped by the !running guard. + total := result.New + result.Updated + if h.enrichProgress != nil && total > 0 { + h.enrichProgress.Start(total, store.TenantIDFromContext(r.Context())) + } + + // Now publish enrichment events — workers will call AddDone after Start. + for _, event := range result.PendingEvents { + h.eventBus.Publish(event) } writeJSON(w, http.StatusOK, result) diff --git a/internal/vault/enrich_classify.go b/internal/vault/enrich_classify.go index 50f6724c..9c4f4a58 100644 --- a/internal/vault/enrich_classify.go +++ b/internal/vault/enrich_classify.go @@ -12,11 +12,12 @@ import ( ) const ( - classifyMaxTokens = 2048 + classifyMaxTokens = 1024 classifyTemperature = 0.1 classifyCtxMaxLen = 256 // max context string length stored in DB classifySummaryMaxChars = 300 // max summary chars in prompt (validated: 300 for accuracy) classifyMaxSourceDocs = 20 // max source docs per classifyLinks call (validated: cap unbounded time) + classifyChunkSize = 5 // candidates per LLM call to fit response within max_tokens ) // validClassifyTypes — accepted link types stored directly in DB (aligned with UI vault-link-dialog.tsx). @@ -56,49 +57,55 @@ func (w *enrichWorker) classifyLinks(ctx context.Context, tenantID, agentID stri for sourceDocID, pairs := range candidates { source := pairs[0].Source - candidateDocs := make([]classifyDoc, len(pairs)) + allCandidates := make([]classifyDoc, len(pairs)) for i, p := range pairs { - candidateDocs[i] = p.Candidate + allCandidates[i] = p.Candidate } - system, user := buildClassifyPrompt(source, candidateDocs) - raw, err := w.callClassifyWithRetry(ctx, system, user) - if err != nil { - slog.Warn("vault.classify: llm_failed", "doc", sourceDocID, "err", err) - continue // SKIP fallback - } - - parsed, err := parseClassifyResponse(raw, len(candidateDocs)) - if err != nil { - hint := fmt.Sprintf("\n\nPrevious response was invalid JSON (error: %s). Output ONLY a valid JSON array.", err.Error()) - raw2, err2 := w.callClassifyWithRetry(ctx, system, user+hint) - if err2 != nil { - slog.Warn("vault.classify: retry_parse_failed", "doc", sourceDocID, "err", err2) - continue - } - parsed, err = parseClassifyResponse(raw2, len(candidateDocs)) - if err != nil { - slog.Warn("vault.classify: parse_still_failed", "doc", sourceDocID, "err", err) - continue - } - } - - // Collect valid links (collect-then-write pattern). + // Chunk candidates to keep LLM response within max_tokens. var newLinks []store.VaultLink - for _, r := range parsed { - if r.Type == "SKIP" || !validClassifyTypes[r.Type] { + for chunkStart := 0; chunkStart < len(allCandidates); chunkStart += classifyChunkSize { + chunkEnd := min(chunkStart+classifyChunkSize, len(allCandidates)) + chunk := allCandidates[chunkStart:chunkEnd] + + system, user := buildClassifyPrompt(source, chunk) + raw, err := w.callClassifyWithRetry(ctx, system, user) + if err != nil { + slog.Warn("vault.classify: llm_failed", "doc", sourceDocID, "chunk", chunkStart, "err", err) continue } - linkCtx := r.Ctx - if len(linkCtx) > classifyCtxMaxLen { - linkCtx = string([]rune(linkCtx)[:classifyCtxMaxLen]) + + parsed, err := parseClassifyResponse(raw, len(chunk)) + if err != nil { + hint := fmt.Sprintf("\n\nPrevious response was invalid JSON (error: %s). Output ONLY a valid JSON array.", err.Error()) + raw2, err2 := w.callClassifyWithRetry(ctx, system, user+hint) + if err2 != nil { + slog.Warn("vault.classify: retry_parse_failed", "doc", sourceDocID, "err", err2) + continue + } + parsed, err = parseClassifyResponse(raw2, len(chunk)) + if err != nil { + slog.Warn("vault.classify: parse_still_failed", "doc", sourceDocID, "err", err) + continue + } + } + + for _, r := range parsed { + if r.Type == "SKIP" || !validClassifyTypes[r.Type] { + continue + } + linkCtx := r.Ctx + if len(linkCtx) > classifyCtxMaxLen { + linkCtx = string([]rune(linkCtx)[:classifyCtxMaxLen]) + } + // r.Idx is 1-based within chunk; map back to original candidate. + newLinks = append(newLinks, store.VaultLink{ + FromDocID: sourceDocID, + ToDocID: chunk[r.Idx-1].DocID, + LinkType: r.Type, + Context: linkCtx, + }) } - newLinks = append(newLinks, store.VaultLink{ - FromDocID: sourceDocID, - ToDocID: candidateDocs[r.Idx-1].DocID, // idx is 1-based, validated by parseClassifyResponse - LinkType: r.Type, - Context: linkCtx, - }) } // Only replace old links if LLM produced valid replacements (avoid data loss on all-SKIP). @@ -113,12 +120,16 @@ func (w *enrichWorker) classifyLinks(ctx context.Context, tenantID, agentID stri } } -func (w *enrichWorker) gatherCandidates(ctx context.Context, tenantID, agentID string, results []enriched) map[string][]candidatePair { +func (w *enrichWorker) gatherCandidates(ctx context.Context, tenantID, _ string, results []enriched) map[string][]candidatePair { seen := make(map[string]bool) out := make(map[string][]candidatePair) for _, r := range results { - neighbors, err := w.vault.FindSimilarDocs(ctx, tenantID, agentID, r.payload.DocID, enrichSimilarityLimit) + // Search across ALL docs in the tenant (empty agentID = no agent filter). + // Cross-agent links are created freely; access control is enforced at + // query time so agents only see their own docs. Pre-built cross-agent + // links enable future vault sharing without re-enrichment. + neighbors, err := w.vault.FindSimilarDocs(ctx, tenantID, "", r.payload.DocID, enrichSimilarityLimit) if err != nil { slog.Warn("vault.classify: find_similar", "doc", r.payload.DocID, "err", err) continue diff --git a/internal/vault/enrich_classify_prompt.go b/internal/vault/enrich_classify_prompt.go index a02654c8..1c9c5488 100644 --- a/internal/vault/enrich_classify_prompt.go +++ b/internal/vault/enrich_classify_prompt.go @@ -3,7 +3,6 @@ package vault import ( "encoding/json" "fmt" - "log/slog" "strings" ) @@ -25,14 +24,19 @@ const classifySystemPrompt = `You classify relationships between documents in a - contradicts: A conflicts with or opposes B's content ## Rules -- Output ONLY a valid JSON array, no other text +- Respond with EXACTLY one JSON entry per candidate, no markdown fences, no explanation - Use SKIP when documents are similar but have no meaningful relationship - Prefer specific types (reference, depends_on) over generic (related) -- Each candidate classified independently -- Keep ctx descriptions under 60 words +- Keep ctx under 30 words ## Output Format -[{"idx":1,"type":"reference","ctx":"mentions OAuth config for setup"},{"idx":2,"type":"SKIP"}]` +[ + {"idx":1,"type":"reference","ctx":"mentions OAuth config"}, + {"idx":2,"type":"SKIP"}, + {"idx":3,"type":"extends","ctx":"adds error handling details"}, + {"idx":4,"type":"SKIP"}, + {"idx":5,"type":"depends_on","ctx":"requires auth middleware"} +]` // buildClassifyPrompt formats the system and user prompts for classify LLM call. func buildClassifyPrompt(source classifyDoc, candidates []classifyDoc) (system, user string) { @@ -50,7 +54,6 @@ func buildClassifyPrompt(source classifyDoc, candidates []classifyDoc) (system, // Uses partial success model: invalid entries filtered silently, error only on total unmarshal failure. func parseClassifyResponse(raw string, count int) ([]classifyResult, error) { raw = strings.TrimSpace(raw) - // Strip code fences. raw = strings.TrimPrefix(raw, "```json") raw = strings.TrimPrefix(raw, "```") raw = strings.TrimSuffix(raw, "```") @@ -61,17 +64,14 @@ func parseClassifyResponse(raw string, count int) ([]classifyResult, error) { return nil, fmt.Errorf("json unmarshal: %w", err) } - // Filter invalid entries (partial success). valid := results[:0] for _, r := range results { if r.Idx < 1 || r.Idx > count { - slog.Debug("vault.classify: idx out of range", "idx", r.Idx, "count", count) continue } if r.Type != "SKIP" && !validClassifyTypes[r.Type] { continue } - // Truncate context. if len(r.Ctx) > classifyCtxMaxLen { r.Ctx = string([]rune(r.Ctx)[:classifyCtxMaxLen]) } diff --git a/internal/vault/enrich_progress.go b/internal/vault/enrich_progress.go index 00eab55b..92f486b9 100644 --- a/internal/vault/enrich_progress.go +++ b/internal/vault/enrich_progress.go @@ -10,16 +10,14 @@ import ( // EnrichProgress tracks enrichment pipeline progress and broadcasts via WS events. // Lifecycle: handler calls Start(total) once with the global count, -// worker batches call AddDone(n) as docs complete, and MarkBatchDone() -// when a per-agent batch finishes. Auto-completes when done >= total. +// worker chunks call AddDone(n) as they complete. Auto-completes when done >= total. type EnrichProgress struct { - mu sync.Mutex - msgBus bus.EventPublisher - tenantID uuid.UUID - total int - done int - running bool - activeBatches int // number of worker batches in flight + 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. @@ -53,7 +51,7 @@ func (p *EnrichProgress) broadcast(e EnrichEvent) { bus.BroadcastForTenant(p.msgBus, protocol.EventVaultEnrichProgress, p.tenantID, e) } -// Start signals enrichment with the global total. Called by the HTTP rescan +// Start signals enrichment with the global total. Called by the HTTP rescan/upload // handler ONCE with the full count. Resets counters for a fresh run. func (p *EnrichProgress) Start(total int, tenantID uuid.UUID) { p.mu.Lock() @@ -62,20 +60,12 @@ func (p *EnrichProgress) Start(total int, tenantID uuid.UUID) { p.total = total p.tenantID = tenantID p.running = true - p.activeBatches = 0 p.broadcast(EnrichEvent{Phase: "enriching", Done: 0, Total: total, Running: true}) } -// TrackBatch increments the active batch counter. Called by worker when -// a per-agent batch starts processing. -func (p *EnrichProgress) TrackBatch() { - p.mu.Lock() - defer p.mu.Unlock() - p.activeBatches++ -} - // AddDone increments completed count by n and broadcasts progress. -// Auto-completes when done >= total. +// Auto-completes when done >= total. Safe to call before Start() — +// early calls are accumulated and checked once Start() sets total. func (p *EnrichProgress) AddDone(n int) { p.mu.Lock() defer p.mu.Unlock() @@ -88,29 +78,14 @@ func (p *EnrichProgress) AddDone(n int) { p.broadcast(EnrichEvent{Phase: "enriching", Done: p.done, Total: p.total, Running: true}) } -// MarkBatchDone decrements the active batch counter. If all batches -// finished and done < total (some docs failed), force-complete. -func (p *EnrichProgress) MarkBatchDone() { - p.mu.Lock() - defer p.mu.Unlock() - p.activeBatches-- - if p.activeBatches <= 0 && p.running { - p.broadcast(EnrichEvent{Phase: "complete", Done: p.done, Total: p.total, Running: false}) - p.running = false - p.activeBatches = 0 - } -} - -// Finish signals the enrichment pipeline has completed and resets counters. -// Kept for backward compat but prefer AddDone auto-complete or MarkBatchDone. +// Finish forces completion. Only needed if done never reaches total +// (e.g. context cancelled before all chunks processed). func (p *EnrichProgress) Finish() { p.mu.Lock() defer p.mu.Unlock() if !p.running { - return // already completed via auto-complete + return } 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 af62a0cc..537ddd62 100644 --- a/internal/vault/enrich_worker.go +++ b/internal/vault/enrich_worker.go @@ -105,14 +105,10 @@ func (w *enrichWorker) Handle(ctx context.Context, event eventbus.DomainEvent) e } w.dedupMu.Unlock() - // Batch key: tenant + agent for agent-scoped docs. - // Team/shared docs (empty AgentID) batch together per tenant so bulk rescan - // benefits from chunked processing instead of 1-doc-per-queue. - batchScope := payload.AgentID - if batchScope == "" { - batchScope = "_shared" - } - key := payload.TenantID + ":" + batchScope + // Batch key: tenant-only. All docs for the same tenant share one queue + // so a single processBatch goroutine drains everything in order. + // Agent/team scope is carried in the payload for classify phase. + key := payload.TenantID if !w.queue.Enqueue(key, payload) { return nil // another goroutine already processing this agent's queue } @@ -132,26 +128,22 @@ 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) { - w.progress.TrackBatch() - var totalQueued int - for { items := w.queue.Drain(key) if len(items) == 0 { if w.queue.TryFinish(key) { - w.progress.MarkBatchDone() return } continue } - totalQueued += len(items) - // Process in chunks of enrichBatchSize, up to enrichMaxConcurrent in parallel. var wg sync.WaitGroup for start := 0; start < len(items); start += enrichBatchSize { end := min(start+enrichBatchSize, len(items)) if err := w.sem.Acquire(ctx, 1); err != nil { + // Context cancelled — count remaining as done so progress completes. + w.progress.AddDone(len(items) - start) break } wg.Add(1) @@ -165,7 +157,6 @@ func (w *enrichWorker) processBatch(ctx context.Context, key string) { wg.Wait() if w.queue.TryFinish(key) { - w.progress.MarkBatchDone() return } } diff --git a/internal/vault/rescan.go b/internal/vault/rescan.go index 585fc77a..fc0feee2 100644 --- a/internal/vault/rescan.go +++ b/internal/vault/rescan.go @@ -30,6 +30,11 @@ type RescanResult struct { Skipped int `json:"skipped"` Errors int `json:"errors"` Truncated bool `json:"truncated"` + + // PendingEvents holds enrichment events collected during scan. + // Caller must publish these AFTER calling progress.Start(total) + // to avoid race between event workers and progress tracking. + PendingEvents []eventbus.DomainEvent `json:"-"` } // RescanWorkspace walks the tenant workspace and registers missing or changed @@ -107,9 +112,10 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto result.New++ } - // Publish enrichment event. AgentID in payload stays string for serialization. + // Collect enrichment events — published AFTER the loop so the caller + // can call progress.Start(total) before workers receive events. if bus != nil { - bus.Publish(eventbus.DomainEvent{ + result.PendingEvents = append(result.PendingEvents, eventbus.DomainEvent{ ID: uuid.Must(uuid.NewV7()).String(), Type: eventbus.EventVaultDocUpserted, SourceID: doc.ID + ":" + hash, diff --git a/internal/vault/rescan_test.go b/internal/vault/rescan_test.go index 2c8b2a06..d86b3b2d 100644 --- a/internal/vault/rescan_test.go +++ b/internal/vault/rescan_test.go @@ -2,7 +2,9 @@ package vault import "testing" -// TestInferOwnerFromPath covers the new tenant-wide path parser. +// TestInferOwnerFromPath covers the tenant-wide path parser. +// All patterns now return the FULL relPath (no prefix stripping) so +// enrichment workers can locate files via filepath.Join(workspace, path). func TestInferOwnerFromPath(t *testing.T) { agentMap := map[string]string{ "my-bot": "uuid-1", @@ -20,65 +22,77 @@ func TestInferOwnerFromPath(t *testing.T) { wantScope string wantStrippedPath string }{ - // agents/{key}/... → personal scope, strip prefix + // Legacy agents/{key}/... → personal scope, full path preserved { path: "agents/my-bot/notes/todo.md", wantAgentID: strPtr("uuid-1"), wantScope: "personal", - wantStrippedPath: "notes/todo.md", + wantStrippedPath: "agents/my-bot/notes/todo.md", }, - // agents/{key} with no trailing path → personal, empty stripped path { path: "agents/my-bot/file.md", wantAgentID: strPtr("uuid-1"), wantScope: "personal", - wantStrippedPath: "file.md", + wantStrippedPath: "agents/my-bot/file.md", }, - // teams/{uuid}/... → team scope, strip prefix + // Root-level {agent_key}/... → personal scope (workspace layout) + { + path: "my-bot/telegram/123/report.md", + wantAgentID: strPtr("uuid-1"), + wantScope: "personal", + wantStrippedPath: "my-bot/telegram/123/report.md", + }, + { + path: "other-bot/docs/guide.md", + wantAgentID: strPtr("uuid-2"), + wantScope: "personal", + wantStrippedPath: "other-bot/docs/guide.md", + }, + // teams/{uuid}/... → team scope, full path preserved { path: "teams/" + validUUID + "/doc.md", wantTeamID: strPtr(validUUID), wantScope: "team", - wantStrippedPath: "doc.md", + wantStrippedPath: "teams/" + validUUID + "/doc.md", }, - // teams/{uuid}/deep/nested → team scope { path: "teams/" + validUUID + "/deep/nested.md", wantTeamID: strPtr(validUUID), wantScope: "team", - wantStrippedPath: "deep/nested.md", + wantStrippedPath: "teams/" + validUUID + "/deep/nested.md", }, - // root-level file → shared scope, path unchanged + // Root-level file (no slash) → shared { path: "README.md", wantScope: "shared", wantStrippedPath: "README.md", }, - // nested file not under agents/ or teams/ → shared + // Nested file not matching any agent key → shared { path: "docs/guide.md", wantScope: "shared", wantStrippedPath: "docs/guide.md", }, - // unknown agent → skip (scope="") + // Unknown agent under agents/ prefix → skip { path: "agents/unknown-bot/file.md", wantScope: "", }, - // invalid team UUID → skip + // Invalid team UUID → skip { path: "teams/not-a-uuid/file.md", wantScope: "", }, - // valid UUID but not in teamSet → skip + // Valid UUID but not in teamSet → skip { path: "teams/11111111-2222-3333-4444-555555555555/file.md", wantScope: "", }, - // malformed agents path (no trailing file) is still an unknown agent key check + // Unknown root folder (not an agent key) → shared { - path: "agents/unknown/", - wantScope: "", + path: "telegram/group/file.md", + wantScope: "shared", + wantStrippedPath: "telegram/group/file.md", }, } @@ -90,7 +104,7 @@ func TestInferOwnerFromPath(t *testing.T) { t.Errorf("scope = %q, want %q", gotScope, tt.wantScope) } if tt.wantScope == "" { - return // skip is signaled; remaining fields don't matter + return } if tt.wantStrippedPath != "" && gotPath != tt.wantStrippedPath { t.Errorf("strippedPath = %q, want %q", gotPath, tt.wantStrippedPath) @@ -127,7 +141,6 @@ func TestInferVaultDocType(t *testing.T) { {"web-fetch/page.html", "note"}, {"skills/my-skill/SKILL.md", "skill"}, {"deep/soul.md", "context"}, - // Phase 01 — new `document` docType for office/PDF files. {"docs/spec.pdf", "document"}, {"docs/sheet.xlsx", "document"}, {"docs/slide.pptx", "document"}, @@ -143,16 +156,11 @@ func TestInferVaultDocType(t *testing.T) { } } -// TestInferDocType_PathPrefixWinsOverExt ensures path-prefix rules -// (memory/, skills/, episodic/, SOUL/IDENTITY/AGENTS) take precedence -// over extension-based classification. Phase 01 refactor must preserve -// this ordering after introducing the extension whitelist fallback. func TestInferDocType_PathPrefixWinsOverExt(t *testing.T) { cases := []struct { path string want string }{ - // Path prefix rules — trump extension {"memory/foo.md", "memory"}, {"memory/snapshots/day.md", "memory"}, {"skills/my-skill/README.md", "skill"}, @@ -161,13 +169,11 @@ func TestInferDocType_PathPrefixWinsOverExt(t *testing.T) { {"path/to/SOUL.md", "context"}, {"path/to/IDENTITY.md", "context"}, {"path/to/AGENTS.md", "context"}, - // Extension fallback (whitelist) — no path-prefix match {"notes/daily.md", "note"}, {"photos/cat.png", "media"}, {"videos/clip.mp4", "media"}, {"audio/voice.mp3", "media"}, {"docs/spec.pdf", "document"}, - // Unknown / non-whitelisted → note (default) {"data/file", "note"}, {"binaries/tool.exe", "note"}, } diff --git a/ui/web/src/pages/vault/hooks/use-enrichment-progress.ts b/ui/web/src/pages/vault/hooks/use-enrichment-progress.ts index 6f85794d..49ef7a0b 100644 --- a/ui/web/src/pages/vault/hooks/use-enrichment-progress.ts +++ b/ui/web/src/pages/vault/hooks/use-enrichment-progress.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import { useWsEvent } from "@/hooks/use-ws-event"; export interface EnrichmentEvent { @@ -10,16 +10,29 @@ export interface EnrichmentEvent { /** * Listens to vault.enrich.progress WS events and returns current enrichment state. - * Progress auto-clears 3s after completion. + * Progress auto-clears 3s after completion. Stale timers are cancelled when + * new events arrive to prevent progress bar from disappearing mid-enrichment. */ export function useEnrichmentProgress() { const [event, setEvent] = useState(null); + const timerRef = useRef>(null); useWsEvent("vault.enrich.progress", (payload) => { const data = payload as EnrichmentEvent; + + // Cancel any pending clear timer from a previous "complete" event. + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setEvent(data); + if (!data.running) { - setTimeout(() => setEvent(null), 3000); + timerRef.current = setTimeout(() => { + setEvent(null); + timerRef.current = null; + }, 3000); } }); diff --git a/ui/web/src/pages/vault/vault-page.tsx b/ui/web/src/pages/vault/vault-page.tsx index d37fdd85..f024b73c 100644 --- a/ui/web/src/pages/vault/vault-page.tsx +++ b/ui/web/src/pages/vault/vault-page.tsx @@ -137,7 +137,7 @@ export function VaultPage() { -