From 28d29ba046af7f29d8851972b9b5a8564b8d9867 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sun, 12 Apr 2026 14:31:36 +0700 Subject: [PATCH] fix(vault): enrichment pipeline reliability + cross-agent classify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 fixes for vault enrichment pipeline: 1. Queue key = tenant-only (was per-agent, caused multiple batches blocking EventBus workers and progress bar flashing) 2. Classify chunks 5 candidates per LLM call (prevents response truncation that caused parse_still_failed errors) 3. Classify prompt improved: explicit "EXACTLY one entry per candidate", 5-entry example, ctx capped at 30 words 4. max_tokens kept at 1024 (sufficient for 5 candidates) 5. Progress AddDone removes !running guard (safe before Start) 6. Rescan defers event publishing via PendingEvents — Start() called before workers receive events, eliminating race 7. Upload handler same deferred publish pattern 8. Frontend enrichment timer cancels stale "complete" timeout when new enrichment starts (prevents bar disappearing) 9. Sidebar tree reloads after rescan completes Classify now searches across entire tenant (empty agentID) to build cross-agent links for future vault sharing. Access control enforced at query time — agents only see their own docs. --- internal/http/vault_handler_upload.go | 10 ++- internal/http/vault_handlers.go | 13 ++- internal/vault/enrich_classify.go | 89 +++++++++++-------- internal/vault/enrich_classify_prompt.go | 18 ++-- internal/vault/enrich_progress.go | 51 +++-------- internal/vault/enrich_worker.go | 21 ++--- internal/vault/rescan.go | 10 ++- internal/vault/rescan_test.go | 58 ++++++------ .../vault/hooks/use-enrichment-progress.ts | 19 +++- ui/web/src/pages/vault/vault-page.tsx | 2 +- 10 files changed, 152 insertions(+), 139 deletions(-) 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() { -