mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
feat(vault): tree sidebar with lazy-load folder hierarchy
Replace flat paginated document list with collapsible tree view. Backend: two-query-per-level approach (files + DISTINCT deeper paths for virtual folder derivation), path parsing in Go for PG+SQLite compatibility, text_pattern_ops index for prefix range scans. Frontend: VaultTree component with doc_type colored icons, compact single-line nodes, truncate-middle filenames, scope dots, hover tooltip for date. Sidebar expanded to 384px on large screens. Also fixes pre-existing SQLite scanVaultDocRow missing path_basename.
This commit is contained in:
1 parent
31b2662ecf
commit
35ce8cc2c5
14 files changed
+638
-191
No files matched your search
@@ -0,0 +1,56 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// handleVaultTree returns immediate children (files + virtual folders) under
|
||||
// a given path prefix for lazy-loading the vault sidebar tree.
|
||||
func (h *VaultHandler) handleVaultTree(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := store.TenantIDFromContext(r.Context())
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
if strings.Contains(path, "..") || strings.HasPrefix(path, "/") {
|
||||
slog.Warn("security.vault_tree_traversal", "path", path)
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
|
||||
opts := store.VaultTreeOptions{
|
||||
Path: path,
|
||||
Scope: r.URL.Query().Get("scope"),
|
||||
DocTypes: splitCSV(r.URL.Query().Get("doc_type")),
|
||||
}
|
||||
if teamID := r.URL.Query().Get("team_id"); teamID != "" {
|
||||
opts.TeamID = &teamID
|
||||
}
|
||||
|
||||
if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) {
|
||||
if ids := h.userAccessibleTeamIDs(r.Context()); len(ids) > 0 {
|
||||
opts.TeamIDs = ids
|
||||
} else {
|
||||
empty := ""
|
||||
opts.TeamID = &empty
|
||||
}
|
||||
}
|
||||
if opts.TeamID != nil && *opts.TeamID != "" {
|
||||
if !h.validateTeamMembership(r.Context(), w, *opts.TeamID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := h.store.ListTreeEntries(r.Context(), tenantID.String(), opts)
|
||||
if err != nil {
|
||||
slog.Warn("vault.tree failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tree"})
|
||||
return
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []store.VaultTreeEntry{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
||||
}
|
||||
@@ -119,6 +119,7 @@ func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /v1/vault/links/batch", h.auth(h.handleBatchGetLinks))
|
||||
mux.HandleFunc("POST /v1/vault/upload", h.auth(h.handleUpload))
|
||||
mux.HandleFunc("POST /v1/vault/rescan", h.auth(h.handleRescan))
|
||||
mux.HandleFunc("GET /v1/vault/tree", h.auth(h.handleVaultTree))
|
||||
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).
|
||||
|
||||
@@ -674,3 +674,128 @@ func (s *PGVaultStore) mergeResults(fts, vec []store.VaultSearchResult, ftsW, ve
|
||||
return results
|
||||
}
|
||||
|
||||
// ListTreeEntries returns immediate children (files + virtual folders) under the given path prefix.
|
||||
func (s *PGVaultStore) ListTreeEntries(ctx context.Context, tenantID string, opts store.VaultTreeOptions) ([]store.VaultTreeEntry, error) {
|
||||
tid, err := parseUUID(tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vault tree: tenant: %w", err)
|
||||
}
|
||||
prefix := opts.Path
|
||||
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
|
||||
fileQ := `SELECT id, path, title, doc_type, scope, updated_at FROM vault_documents WHERE tenant_id = $1`
|
||||
fileArgs := []any{tid}
|
||||
fp := 2
|
||||
if prefix == "" {
|
||||
fileQ += " AND path NOT LIKE '%/%'"
|
||||
} else {
|
||||
fileQ += fmt.Sprintf(" AND path LIKE $%d AND path NOT LIKE $%d", fp, fp+1)
|
||||
fileArgs = append(fileArgs, prefix+"%", prefix+"%/%")
|
||||
fp += 2
|
||||
}
|
||||
fileQ, fileArgs, fp = appendTreeFilters(fileQ, fileArgs, fp, opts)
|
||||
fileQ += " ORDER BY path"
|
||||
|
||||
deepQ := `SELECT DISTINCT path FROM vault_documents WHERE tenant_id = $1`
|
||||
deepArgs := []any{tid}
|
||||
dp := 2
|
||||
if prefix == "" {
|
||||
deepQ += " AND path LIKE '%/%'"
|
||||
} else {
|
||||
deepQ += fmt.Sprintf(" AND path LIKE $%d", dp)
|
||||
deepArgs = append(deepArgs, prefix+"%/%")
|
||||
dp++
|
||||
}
|
||||
deepQ, deepArgs, _ = appendTreeFilters(deepQ, deepArgs, dp, opts)
|
||||
deepQ += " LIMIT 50000"
|
||||
|
||||
fileRows, err := s.db.QueryContext(ctx, fileQ, fileArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vault tree files: %w", err)
|
||||
}
|
||||
defer fileRows.Close()
|
||||
var entries []store.VaultTreeEntry
|
||||
for fileRows.Next() {
|
||||
var id, path, title, docType, scope string
|
||||
var updatedAt time.Time
|
||||
if err := fileRows.Scan(&id, &path, &title, &docType, &scope, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("vault tree scan: %w", err)
|
||||
}
|
||||
name := path
|
||||
if idx := strings.LastIndex(path, "/"); idx >= 0 {
|
||||
name = path[idx+1:]
|
||||
}
|
||||
ua := updatedAt
|
||||
entries = append(entries, store.VaultTreeEntry{
|
||||
Name: name, Path: path, DocID: id, DocType: docType, Scope: scope, Title: title, UpdatedAt: &ua,
|
||||
})
|
||||
}
|
||||
if err := fileRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("vault tree files: %w", err)
|
||||
}
|
||||
|
||||
deepRows, err := s.db.QueryContext(ctx, deepQ, deepArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vault tree deep: %w", err)
|
||||
}
|
||||
defer deepRows.Close()
|
||||
var deepPaths []string
|
||||
for deepRows.Next() {
|
||||
var p string
|
||||
if err := deepRows.Scan(&p); err != nil {
|
||||
return nil, fmt.Errorf("vault tree deep scan: %w", err)
|
||||
}
|
||||
deepPaths = append(deepPaths, p)
|
||||
}
|
||||
if err := deepRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("vault tree deep: %w", err)
|
||||
}
|
||||
|
||||
for _, fname := range extractFolderNames(prefix, deepPaths) {
|
||||
entries = append(entries, store.VaultTreeEntry{
|
||||
Name: fname, Path: prefix + fname, IsDir: true, HasChildren: true,
|
||||
})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].IsDir != entries[j].IsDir {
|
||||
return entries[i].IsDir
|
||||
}
|
||||
return entries[i].Name < entries[j].Name
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func appendTreeFilters(q string, args []any, p int, opts store.VaultTreeOptions) (string, []any, int) {
|
||||
q, args, p = appendTeamFilter(q, args, p, opts.TeamID, opts.TeamIDs)
|
||||
if opts.Scope != "" {
|
||||
q += fmt.Sprintf(" AND scope = $%d", p)
|
||||
args = append(args, opts.Scope)
|
||||
p++
|
||||
}
|
||||
if len(opts.DocTypes) > 0 {
|
||||
q += fmt.Sprintf(" AND doc_type = ANY($%d)", p)
|
||||
args = append(args, pqStringArray(opts.DocTypes))
|
||||
p++
|
||||
}
|
||||
return q, args, p
|
||||
}
|
||||
|
||||
func extractFolderNames(prefix string, deepPaths []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var folders []string
|
||||
for _, p := range deepPaths {
|
||||
rest := strings.TrimPrefix(p, prefix)
|
||||
if idx := strings.Index(rest, "/"); idx > 0 {
|
||||
seg := rest[:idx]
|
||||
if !seen[seg] {
|
||||
seen[seg] = true
|
||||
folders = append(folders, seg)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(folders)
|
||||
return folders
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 16
|
||||
const SchemaVersion = 17
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -435,6 +435,9 @@ CREATE INDEX IF NOT EXISTS idx_vault_links_source
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_delegation
|
||||
ON vault_documents(json_extract(metadata, '$.delegation_id'))
|
||||
WHERE json_extract(metadata, '$.delegation_id') IS NOT NULL;`,
|
||||
|
||||
// Version 16 → 17: path prefix index for vault tree lazy-load queries.
|
||||
16: `CREATE INDEX IF NOT EXISTS idx_vault_docs_path_prefix ON vault_documents(tenant_id, path);`,
|
||||
}
|
||||
|
||||
// backfillV16 populates base_name / path_basename for rows that existed
|
||||
|
||||
@@ -1542,6 +1542,7 @@ CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_hash ON vault_documents(content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_team ON vault_documents(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_basename ON vault_documents(tenant_id, path_basename);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_path_prefix ON vault_documents(tenant_id, path);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_delegation
|
||||
ON vault_documents(json_extract(metadata, '$.delegation_id'))
|
||||
WHERE json_extract(metadata, '$.delegation_id') IS NOT NULL;
|
||||
|
||||
@@ -382,6 +382,109 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ListTreeEntries returns immediate children (files + virtual folders) under the given path prefix.
|
||||
func (s *SQLiteVaultStore) ListTreeEntries(ctx context.Context, tenantID string, opts store.VaultTreeOptions) ([]store.VaultTreeEntry, error) {
|
||||
prefix := opts.Path
|
||||
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
fileQ := `SELECT id, path, title, doc_type, scope, updated_at FROM vault_documents WHERE tenant_id = ?`
|
||||
fileArgs := []any{tenantID}
|
||||
if prefix == "" {
|
||||
fileQ += " AND path NOT LIKE '%/%'"
|
||||
} else {
|
||||
fileQ += " AND path LIKE ? AND path NOT LIKE ?"
|
||||
fileArgs = append(fileArgs, prefix+"%", prefix+"%/%")
|
||||
}
|
||||
fileQ, fileArgs = sqliteAppendTreeFilters(fileQ, fileArgs, opts)
|
||||
fileQ += " ORDER BY path"
|
||||
|
||||
deepQ := `SELECT DISTINCT path FROM vault_documents WHERE tenant_id = ?`
|
||||
deepArgs := []any{tenantID}
|
||||
if prefix == "" {
|
||||
deepQ += " AND path LIKE '%/%'"
|
||||
} else {
|
||||
deepQ += " AND path LIKE ?"
|
||||
deepArgs = append(deepArgs, prefix+"%/%")
|
||||
}
|
||||
deepQ, deepArgs = sqliteAppendTreeFilters(deepQ, deepArgs, opts)
|
||||
deepQ += " LIMIT 50000"
|
||||
|
||||
fileRows, err := s.db.QueryContext(ctx, fileQ, fileArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vault tree files: %w", err)
|
||||
}
|
||||
defer fileRows.Close()
|
||||
var entries []store.VaultTreeEntry
|
||||
for fileRows.Next() {
|
||||
var id, path, title, docType, scope string
|
||||
var ua sqliteTime
|
||||
if err := fileRows.Scan(&id, &path, &title, &docType, &scope, &ua); err != nil {
|
||||
return nil, fmt.Errorf("vault tree scan: %w", err)
|
||||
}
|
||||
name := path
|
||||
if idx := strings.LastIndex(path, "/"); idx >= 0 {
|
||||
name = path[idx+1:]
|
||||
}
|
||||
t := ua.Time
|
||||
entries = append(entries, store.VaultTreeEntry{
|
||||
Name: name, Path: path, DocID: id, DocType: docType, Scope: scope, Title: title, UpdatedAt: &t,
|
||||
})
|
||||
}
|
||||
if err := fileRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("vault tree files: %w", err)
|
||||
}
|
||||
|
||||
deepRows, err := s.db.QueryContext(ctx, deepQ, deepArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vault tree deep: %w", err)
|
||||
}
|
||||
defer deepRows.Close()
|
||||
seen := make(map[string]bool)
|
||||
for deepRows.Next() {
|
||||
var p string
|
||||
if err := deepRows.Scan(&p); err != nil {
|
||||
return nil, fmt.Errorf("vault tree deep scan: %w", err)
|
||||
}
|
||||
rest := strings.TrimPrefix(p, prefix)
|
||||
if idx := strings.Index(rest, "/"); idx > 0 {
|
||||
seg := rest[:idx]
|
||||
if !seen[seg] {
|
||||
seen[seg] = true
|
||||
entries = append(entries, store.VaultTreeEntry{
|
||||
Name: seg, Path: prefix + seg, IsDir: true, HasChildren: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := deepRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("vault tree deep: %w", err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].IsDir != entries[j].IsDir {
|
||||
return entries[i].IsDir
|
||||
}
|
||||
return entries[i].Name < entries[j].Name
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func sqliteAppendTreeFilters(q string, args []any, opts store.VaultTreeOptions) (string, []any) {
|
||||
q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs)
|
||||
if opts.Scope != "" {
|
||||
q += " AND scope = ?"
|
||||
args = append(args, opts.Scope)
|
||||
}
|
||||
if len(opts.DocTypes) > 0 {
|
||||
ph := strings.Repeat("?,", len(opts.DocTypes)-1) + "?"
|
||||
q += " AND doc_type IN (" + ph + ")"
|
||||
for _, dt := range opts.DocTypes {
|
||||
args = append(args, dt)
|
||||
}
|
||||
}
|
||||
return q, args
|
||||
}
|
||||
|
||||
// --- scan helpers ---
|
||||
|
||||
func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) {
|
||||
@@ -409,7 +512,7 @@ func scanVaultDocRow(rows *sql.Rows) (*store.VaultDocument, error) {
|
||||
var agentID *string
|
||||
ca, ua := &sqliteTime{}, &sqliteTime{}
|
||||
err := rows.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &doc.Scope, &doc.CustomScope,
|
||||
&doc.Path, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua)
|
||||
&doc.Path, &doc.PathBasename, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -74,6 +74,28 @@ type VaultListOptions struct {
|
||||
Offset int
|
||||
}
|
||||
|
||||
// VaultTreeEntry represents a file or virtual folder in the vault tree.
|
||||
type VaultTreeEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
HasChildren bool `json:"hasChildren,omitempty"`
|
||||
DocID string `json:"docId,omitempty"`
|
||||
DocType string `json:"docType,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// VaultTreeOptions configures a vault tree listing query.
|
||||
type VaultTreeOptions struct {
|
||||
Path string
|
||||
TeamID *string
|
||||
TeamIDs []string
|
||||
Scope string
|
||||
DocTypes []string
|
||||
}
|
||||
|
||||
// VaultStore manages the Knowledge Vault document registry and links.
|
||||
type VaultStore interface {
|
||||
// Document CRUD
|
||||
@@ -85,6 +107,9 @@ type VaultStore interface {
|
||||
CountDocuments(ctx context.Context, tenantID, agentID string, opts VaultListOptions) (int, error)
|
||||
UpdateHash(ctx context.Context, tenantID, id, newHash string) error
|
||||
|
||||
// ListTreeEntries returns immediate children (files + virtual folders) under the given path prefix.
|
||||
ListTreeEntries(ctx context.Context, tenantID string, opts VaultTreeOptions) ([]VaultTreeEntry, error)
|
||||
|
||||
// GetDocumentsByIDs returns documents matching the given IDs with tenant isolation.
|
||||
GetDocumentsByIDs(ctx context.Context, tenantID string, docIDs []string) ([]VaultDocument, error)
|
||||
// GetDocumentByBasename finds a document by path basename (case-insensitive).
|
||||
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 48
|
||||
const RequiredSchemaVersion uint = 49
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS idx_vault_docs_path_prefix;
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_vault_docs_path_prefix
|
||||
ON vault_documents (tenant_id, path text_pattern_ops);
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
ChevronRight, Folder, FolderOpen, Loader2,
|
||||
FileText, Brain, StickyNote, Sparkles, Clock, Image, FileType,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { TreeNode } from "@/lib/file-helpers";
|
||||
import type { VaultTreeEntry } from "../hooks/use-vault-tree";
|
||||
|
||||
const DOC_TYPE_ICONS: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
context: { icon: FileText, color: "text-blue-600 dark:text-blue-400" },
|
||||
memory: { icon: Brain, color: "text-purple-600 dark:text-purple-400" },
|
||||
note: { icon: StickyNote, color: "text-amber-600 dark:text-amber-400" },
|
||||
skill: { icon: Sparkles, color: "text-emerald-600 dark:text-emerald-400" },
|
||||
episodic: { icon: Clock, color: "text-orange-600 dark:text-orange-400" },
|
||||
media: { icon: Image, color: "text-rose-600 dark:text-rose-400" },
|
||||
document: { icon: FileType, color: "text-cyan-600 dark:text-cyan-400" },
|
||||
};
|
||||
const DEFAULT_ICON = { icon: FileText, color: "text-muted-foreground" };
|
||||
|
||||
const SCOPE_DOT: Record<string, string> = {
|
||||
personal: "bg-blue-400",
|
||||
team: "bg-green-400",
|
||||
shared: "bg-amber-400",
|
||||
};
|
||||
|
||||
/** Truncate filename in the middle: "very-long-file-name.md" → "very-lo…me.md" */
|
||||
function truncateMiddle(s: string, max = 28): string {
|
||||
if (s.length <= max) return s;
|
||||
const keep = Math.floor((max - 1) / 2);
|
||||
return s.slice(0, keep) + "…" + s.slice(s.length - keep);
|
||||
}
|
||||
|
||||
export interface VaultTreeProps {
|
||||
tree: TreeNode[];
|
||||
meta: Map<string, VaultTreeEntry>;
|
||||
loading: boolean;
|
||||
activePath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
onLoadMore: (path: string) => void;
|
||||
}
|
||||
|
||||
function VaultTreeNode({
|
||||
node, depth, meta, activePath, onSelect, onLoadMore,
|
||||
}: {
|
||||
node: TreeNode; depth: number; meta: Map<string, VaultTreeEntry>;
|
||||
activePath: string | null; onSelect: (path: string) => void; onLoadMore: (path: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const entry = meta.get(node.path);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
const willExpand = !expanded;
|
||||
setExpanded(willExpand);
|
||||
if (willExpand && node.isDir && node.hasChildren && node.children.length === 0 && !node.loading) {
|
||||
onLoadMore(node.path);
|
||||
}
|
||||
}, [expanded, node.isDir, node.hasChildren, node.children.length, node.loading, node.path, onLoadMore]);
|
||||
|
||||
// --- Folder node ---
|
||||
if (node.isDir) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm cursor-pointer hover:bg-accent"
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`} />
|
||||
{expanded
|
||||
? <FolderOpen className="h-4 w-4 shrink-0 text-yellow-600" />
|
||||
: <Folder className="h-4 w-4 shrink-0 text-yellow-600" />}
|
||||
<span className="truncate text-xs">{node.name}</span>
|
||||
{node.loading && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground ml-auto" />}
|
||||
{!node.loading && node.children.length > 0 && (
|
||||
<span className="ml-auto shrink-0 rounded-full bg-muted px-1 text-2xs tabular-nums text-muted-foreground">
|
||||
{node.children.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expanded && node.children.map((child) => (
|
||||
<VaultTreeNode key={child.path} node={child} depth={depth + 1}
|
||||
meta={meta} activePath={activePath} onSelect={onSelect} onLoadMore={onLoadMore} />
|
||||
))}
|
||||
{expanded && node.hasChildren && node.children.length === 0 && !node.loading && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
style={{ paddingLeft: `${(depth + 1) * 16 + 20}px` }}
|
||||
onClick={() => onLoadMore(node.path)}>
|
||||
<Loader2 className="h-3 w-3" /><span>Load more</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- File node (compact single-line) ---
|
||||
const isActive = activePath === node.path;
|
||||
const docType = entry?.docType ?? "";
|
||||
const { icon: Icon, color } = DOC_TYPE_ICONS[docType] ?? DEFAULT_ICON;
|
||||
const title = entry?.title || node.name;
|
||||
const scopeDot = entry?.scope ? SCOPE_DOT[entry.scope] : null;
|
||||
const relTime = entry?.updatedAt ? formatRelativeTime(entry.updatedAt) : null;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`flex w-full items-center gap-1.5 rounded px-2 py-1 text-left cursor-pointer ${
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 20}px` }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 shrink-0 ${color}`} />
|
||||
<span className="truncate text-xs">{truncateMiddle(title)}</span>
|
||||
{scopeDot && <span className={`ml-auto h-1.5 w-1.5 rounded-full shrink-0 ${scopeDot}`} />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
<p className="font-medium">{title}</p>
|
||||
{relTime && <p className="text-muted-foreground">{relTime}</p>}
|
||||
{entry?.scope && <p className="text-muted-foreground capitalize">{entry.scope}</p>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function VaultTree({ tree, meta, loading, activePath, onSelect, onLoadMore }: VaultTreeProps) {
|
||||
if (loading && tree.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-1 text-muted-foreground">
|
||||
<FileText className="h-5 w-5" /><span className="text-sm">No documents</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex-1 min-h-0">
|
||||
{tree.map((node) => (
|
||||
<VaultTreeNode key={node.path} node={node} depth={0}
|
||||
meta={meta} activePath={activePath} onSelect={onSelect} onLoadMore={onLoadMore} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { buildTree, mergeSubtree, setNodeLoading, type TreeNode } from "@/lib/file-helpers";
|
||||
|
||||
export interface VaultTreeEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
hasChildren?: boolean;
|
||||
docId?: string;
|
||||
docType?: string;
|
||||
scope?: string;
|
||||
title?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface VaultTreeResponse { entries: VaultTreeEntry[] }
|
||||
|
||||
interface VaultTreeFilter {
|
||||
scope?: string;
|
||||
doc_type?: string;
|
||||
team_id?: string;
|
||||
}
|
||||
|
||||
function toTreeInputs(entries: VaultTreeEntry[]) {
|
||||
return entries.map((e) => ({
|
||||
path: e.path, name: e.name, isDir: e.isDir, size: 0, hasChildren: e.hasChildren,
|
||||
}));
|
||||
}
|
||||
|
||||
export function useVaultTree(filter: VaultTreeFilter = {}) {
|
||||
const http = useHttp();
|
||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||
const [meta, setMeta] = useState<Map<string, VaultTreeEntry>>(new Map());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const buildParams = useCallback(
|
||||
(extraPath?: string): Record<string, string> => {
|
||||
const p: Record<string, string> = {};
|
||||
if (extraPath) p.path = extraPath;
|
||||
if (filter.scope) p.scope = filter.scope;
|
||||
if (filter.doc_type) p.doc_type = filter.doc_type;
|
||||
if (filter.team_id) p.team_id = filter.team_id;
|
||||
return p;
|
||||
},
|
||||
[filter.scope, filter.doc_type, filter.team_id],
|
||||
);
|
||||
|
||||
const loadRoot = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await http.get<VaultTreeResponse>("/v1/vault/tree", buildParams());
|
||||
const entries = res.entries ?? [];
|
||||
const m = new Map<string, VaultTreeEntry>();
|
||||
for (const e of entries) m.set(e.path, e);
|
||||
setMeta(m);
|
||||
setTree(buildTree(toTreeInputs(entries)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [http, buildParams]);
|
||||
|
||||
const loadSubtree = useCallback(async (path: string) => {
|
||||
setTree((prev) => setNodeLoading(prev, path, true));
|
||||
try {
|
||||
const res = await http.get<VaultTreeResponse>("/v1/vault/tree", buildParams(path));
|
||||
const entries = res.entries ?? [];
|
||||
setMeta((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const e of entries) next.set(e.path, e);
|
||||
return next;
|
||||
});
|
||||
setTree((prev) => mergeSubtree(prev, path, toTreeInputs(entries)));
|
||||
} catch {
|
||||
setTree((prev) => setNodeLoading(prev, path, false));
|
||||
}
|
||||
}, [http, buildParams]);
|
||||
|
||||
return { tree, meta, loading, loadRoot, loadSubtree };
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Link2, ChevronLeft, ChevronRight, Search, X, Loader2,
|
||||
Search, X, Loader2,
|
||||
FileText, Brain, StickyNote, Sparkles, Clock, Image, FileType,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { TreeNode } from "@/lib/file-helpers";
|
||||
import type { VaultDocument, VaultSearchResult } from "@/types/vault";
|
||||
import { VaultTree } from "./components/vault-tree";
|
||||
import type { VaultTreeEntry } from "./hooks/use-vault-tree";
|
||||
import { useVaultSearchAll } from "./hooks/use-vault";
|
||||
|
||||
const DOC_TYPE_CONFIG: Record<string, { color: string; bg: string; icon: typeof FileText; dotColor: string }> = {
|
||||
@@ -19,70 +20,35 @@ const DOC_TYPE_CONFIG: Record<string, { color: string; bg: string; icon: typeof
|
||||
media: { color: "text-rose-600 dark:text-rose-400", bg: "bg-rose-500/10", icon: Image, dotColor: "bg-rose-500" },
|
||||
document: { color: "text-cyan-600 dark:text-cyan-400", bg: "bg-cyan-500/10", icon: FileType, dotColor: "bg-cyan-500" },
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG = { color: "text-muted-foreground", bg: "bg-muted", icon: FileText, dotColor: "bg-muted-foreground" };
|
||||
const DOC_TYPES = ["context", "memory", "note", "skill", "episodic", "media", "document"] as const;
|
||||
|
||||
interface Props {
|
||||
documents: VaultDocument[];
|
||||
selectedId: string | null;
|
||||
linkCounts: Map<string, number>;
|
||||
onSelect: (doc: VaultDocument) => void;
|
||||
tree: TreeNode[];
|
||||
meta: Map<string, VaultTreeEntry>;
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
onLoadMore: (path: string) => void;
|
||||
loading: boolean;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
docType: string;
|
||||
onDocTypeChange: (type: string) => void;
|
||||
agentId: string;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
function DocCard({ doc, selected, linkCount, onClick }: {
|
||||
doc: VaultDocument; selected: boolean; linkCount: number; onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("vault");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
function SearchResultCard({ doc, onClick }: { doc: VaultDocument; onClick: () => void }) {
|
||||
const cfg = DOC_TYPE_CONFIG[doc.doc_type] ?? DEFAULT_CONFIG;
|
||||
const Icon = cfg.icon;
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`group mx-1.5 my-0.5 flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-all ${
|
||||
selected
|
||||
? "bg-accent shadow-sm ring-1 ring-accent-foreground/10"
|
||||
: "hover:bg-muted/60"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="mx-1.5 my-0.5 flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-muted/60" onClick={onClick}>
|
||||
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded ${cfg.bg}`}>
|
||||
<Icon className={`h-3 w-3 ${cfg.color}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium leading-snug">
|
||||
{doc.title || doc.path.split("/").pop()}
|
||||
</span>
|
||||
<span className="block truncate text-xs font-medium leading-snug">{doc.title || doc.path.split("/").pop()}</span>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-muted-foreground">
|
||||
<span>{t(`type.${doc.doc_type}`)}</span>
|
||||
<span>·</span>
|
||||
<span>{t(`scope.${doc.scope}`)}</span>
|
||||
{linkCount > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Link2 className="h-2.5 w-2.5" />
|
||||
{linkCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(doc.updated_at)}</span>
|
||||
<span>{doc.doc_type}</span><span>·</span><span>{doc.scope}</span>
|
||||
<span>·</span><span>{formatRelativeTime(doc.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,9 +56,8 @@ function DocCard({ doc, selected, linkCount, onClick }: {
|
||||
}
|
||||
|
||||
export function VaultDocumentSidebar({
|
||||
documents, selectedId, linkCounts, onSelect, loading,
|
||||
page, totalPages, total, onPageChange,
|
||||
docType, onDocTypeChange, agentId, teamId,
|
||||
tree, meta, selectedPath, onSelect, onLoadMore,
|
||||
loading, docType, onDocTypeChange, agentId, teamId,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("vault");
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -102,12 +67,11 @@ export function VaultDocumentSidebar({
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const { search } = useVaultSearchAll();
|
||||
|
||||
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
if (!q.trim()) { setSearchResults(null); return; }
|
||||
setSearching(true);
|
||||
try {
|
||||
const results = await search(q, {
|
||||
@@ -117,11 +81,7 @@ export function VaultDocumentSidebar({
|
||||
maxResults: 30,
|
||||
});
|
||||
setSearchResults(results);
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
} catch { setSearchResults([]); } finally { setSearching(false); }
|
||||
},
|
||||
[search, agentId, docType, teamId],
|
||||
);
|
||||
@@ -129,125 +89,73 @@ export function VaultDocumentSidebar({
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (!value.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
if (!value.trim()) { setSearchResults(null); return; }
|
||||
debounceRef.current = setTimeout(() => doSearch(value), 300);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setQuery("");
|
||||
setSearchResults(null);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// Show search results or regular document list
|
||||
const clearSearch = () => { setQuery(""); setSearchResults(null); inputRef.current?.focus(); };
|
||||
const isSearchMode = query.trim().length > 0;
|
||||
const displayDocs = isSearchMode && searchResults
|
||||
? searchResults.map((r) => r.document)
|
||||
: documents;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r bg-background">
|
||||
{/* Filter bar: type chips + search */}
|
||||
<div className="shrink-0 border-b px-2 py-1.5 space-y-1.5">
|
||||
{/* Type filter chips — horizontal scroll */}
|
||||
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none">
|
||||
<button
|
||||
onClick={() => onDocTypeChange("")}
|
||||
<button onClick={() => onDocTypeChange("")}
|
||||
className={`shrink-0 px-1.5 py-0.5 rounded text-2xs font-medium transition-colors ${
|
||||
!docType ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
!docType ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"}`}>
|
||||
{t("allTypes")}
|
||||
</button>
|
||||
{DOC_TYPES.map((dt) => {
|
||||
const cfg = DOC_TYPE_CONFIG[dt] ?? DEFAULT_CONFIG;
|
||||
const active = docType === dt;
|
||||
return (
|
||||
<button
|
||||
key={dt}
|
||||
onClick={() => onDocTypeChange(active ? "" : dt)}
|
||||
<button key={dt} onClick={() => onDocTypeChange(active ? "" : dt)}
|
||||
className={`shrink-0 flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-medium transition-colors ${
|
||||
active ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
active ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground"}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${active ? "bg-primary-foreground" : cfg.dotColor}`} />
|
||||
{t(`type.${dt}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<Badge variant="secondary" className="text-2xs tabular-nums ml-auto shrink-0">{total}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
{searching
|
||||
? <Loader2 className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground animate-spin" />
|
||||
: <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
: <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />}
|
||||
<input ref={inputRef} type="text" value={query} onChange={(e) => handleQueryChange(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-full rounded-md border bg-background pl-7 pr-7 py-1 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
className="w-full rounded-md border bg-background pl-7 pr-7 py-1 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" />
|
||||
{query && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<button onClick={clearSearch} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Doc list */}
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{(loading || searching) && displayDocs.length === 0 ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="mx-1.5 my-0.5 flex items-center gap-2 rounded-md px-2 py-1.5">
|
||||
<div className="h-6 w-6 shrink-0 animate-pulse rounded bg-muted" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-3.5 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-2.5 w-1/2 animate-pulse rounded bg-muted" />
|
||||
{isSearchMode ? (
|
||||
(searching && !searchResults) ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="mx-1.5 my-0.5 flex items-center gap-2 rounded-md px-2 py-1.5">
|
||||
<div className="h-6 w-6 shrink-0 animate-pulse rounded bg-muted" />
|
||||
<div className="flex-1 space-y-1"><div className="h-3.5 w-3/4 animate-pulse rounded bg-muted" /><div className="h-2.5 w-1/2 animate-pulse rounded bg-muted" /></div>
|
||||
</div>
|
||||
))
|
||||
) : searchResults && searchResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 gap-1 text-muted-foreground">
|
||||
<FileText className="h-5 w-5" /><span className="text-sm">{t("noResults")}</span>
|
||||
</div>
|
||||
))
|
||||
) : displayDocs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 gap-1 text-muted-foreground">
|
||||
<FileText className="h-5 w-5" />
|
||||
<span className="text-sm">{isSearchMode ? t("noResults") : t("noDocuments")}</span>
|
||||
</div>
|
||||
) : (
|
||||
searchResults?.map((r) => (
|
||||
<SearchResultCard key={r.document.id} doc={r.document} onClick={() => onSelect(r.document.path)} />
|
||||
))
|
||||
)
|
||||
) : (
|
||||
displayDocs.map((doc) => (
|
||||
<DocCard
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
selected={doc.id === selectedId}
|
||||
linkCount={linkCounts.get(doc.id) ?? 0}
|
||||
onClick={() => onSelect(doc)}
|
||||
/>
|
||||
))
|
||||
<VaultTree tree={tree} meta={meta} loading={loading}
|
||||
activePath={selectedPath} onSelect={onSelect} onLoadMore={onLoadMore} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination footer — hidden during search */}
|
||||
{!isSearchMode && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 px-3 py-1.5 border-t text-xs text-muted-foreground">
|
||||
<Button variant="ghost" size="xs" disabled={page === 0} onClick={() => onPageChange(page - 1)}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="tabular-nums">{page + 1} / {totalPages}</span>
|
||||
<Button variant="ghost" size="xs" disabled={page >= totalPages - 1} onClick={() => onPageChange(page + 1)}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { Search, FileArchive, Plus, PanelLeftOpen, FolderSync, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
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 { useVaultGraphData, useRescanWorkspace } from "./hooks/use-vault";
|
||||
import { useVaultTree } from "./hooks/use-vault-tree";
|
||||
import { useEnrichmentProgress } from "./hooks/use-enrichment-progress";
|
||||
import { VaultDocumentSidebar } from "./vault-document-sidebar";
|
||||
import { VaultSearchDialog } from "./vault-search-dialog";
|
||||
@@ -20,10 +22,9 @@ const VaultDetailDialog = lazy(() =>
|
||||
import("./vault-detail-dialog").then((m) => ({ default: m.VaultDetailDialog })),
|
||||
);
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export function VaultPage() {
|
||||
const { t } = useTranslation("vault");
|
||||
const http = useHttp();
|
||||
const { agents } = useAgents();
|
||||
const { teams, load: loadTeams } = useTeams();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -35,44 +36,42 @@ export function VaultPage() {
|
||||
const [docType, setDocType] = useState("");
|
||||
const [detailDoc, setDetailDoc] = useState<VaultDocument | null>(null);
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
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,
|
||||
docType: docType || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const treeFilter = useMemo(() => ({
|
||||
doc_type: docType || undefined,
|
||||
team_id: selectedTeam || undefined,
|
||||
}), [docType, selectedTeam]);
|
||||
const { tree, meta, loading, loadRoot, loadSubtree } = useVaultTree(treeFilter);
|
||||
|
||||
// Link counts from graph data (client-side computation)
|
||||
const { links } = useVaultGraphData(selectedAgent, { teamId: selectedTeam || undefined });
|
||||
const linkCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const l of links) {
|
||||
counts.set(l.from_doc_id, (counts.get(l.from_doc_id) ?? 0) + 1);
|
||||
counts.set(l.to_doc_id, (counts.get(l.to_doc_id) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [links]);
|
||||
useEffect(() => { loadRoot(); }, [loadRoot]);
|
||||
|
||||
const handleAgentChange = (v: string) => { setSelectedAgent(v); setPage(0); };
|
||||
const handleTeamChange = (v: string) => { setSelectedTeam(v); setPage(0); };
|
||||
const handleDocTypeChange = (v: string) => { setDocType(v); setPage(0); };
|
||||
// Graph data for the graph view panel
|
||||
useVaultGraphData(selectedAgent, { teamId: selectedTeam || undefined });
|
||||
|
||||
// Sidebar click → open detail modal + highlight graph node
|
||||
const handleSidebarSelect = (doc: VaultDocument) => {
|
||||
setDetailDoc(doc);
|
||||
setSelectedDocId(doc.id);
|
||||
const handleAgentChange = (v: string) => { setSelectedAgent(v); };
|
||||
const handleTeamChange = (v: string) => { setSelectedTeam(v); };
|
||||
const handleDocTypeChange = (v: string) => { setDocType(v); };
|
||||
|
||||
// Tree file click → fetch full doc for detail modal
|
||||
const handleTreeSelect = useCallback(async (path: string) => {
|
||||
setSelectedPath(path);
|
||||
const entry = meta.get(path);
|
||||
if (!entry?.docId) return;
|
||||
setSelectedDocId(entry.docId);
|
||||
try {
|
||||
const doc = await http.get<VaultDocument>(`/v1/vault/documents/${entry.docId}`);
|
||||
setDetailDoc(doc);
|
||||
} catch { /* http layer handles toast */ }
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
};
|
||||
}, [meta, http, isMobile]);
|
||||
|
||||
// Graph single-click → highlight only
|
||||
const handleNodeSelect = useCallback((docId: string | null) => {
|
||||
@@ -89,7 +88,6 @@ export function VaultPage() {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full overflow-hidden">
|
||||
{/* Mobile drawer backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-black/50" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
@@ -98,18 +96,15 @@ export function VaultPage() {
|
||||
<div className={
|
||||
isMobile
|
||||
? `fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw] transition-transform duration-200 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`
|
||||
: "w-80 md:w-[280px] lg:w-80 shrink-0"
|
||||
: "w-80 md:w-80 lg:w-96 shrink-0"
|
||||
}>
|
||||
<VaultDocumentSidebar
|
||||
documents={documents}
|
||||
selectedId={selectedDocId}
|
||||
linkCounts={linkCounts}
|
||||
onSelect={handleSidebarSelect}
|
||||
tree={tree}
|
||||
meta={meta}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleTreeSelect}
|
||||
onLoadMore={loadSubtree}
|
||||
loading={loading}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
docType={docType}
|
||||
onDocTypeChange={handleDocTypeChange}
|
||||
agentId={selectedAgent}
|
||||
@@ -119,7 +114,6 @@ export function VaultPage() {
|
||||
|
||||
{/* Main: header + graph + detail panel */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex h-10 items-center gap-2 px-3 border-b shrink-0">
|
||||
{isMobile && (
|
||||
<Button variant="ghost" size="xs" className="h-7 w-7 p-0" onClick={() => setSidebarOpen(true)}>
|
||||
@@ -129,7 +123,6 @@ export function VaultPage() {
|
||||
<FileArchive className="h-4 w-4 text-indigo-500 shrink-0" />
|
||||
<span className="text-sm font-semibold mr-auto">{t("title")}</span>
|
||||
|
||||
{/* Filters */}
|
||||
<select value={selectedAgent} onChange={(e) => handleAgentChange(e.target.value)}
|
||||
className="text-base md:text-sm border rounded px-2 py-1 bg-background h-7">
|
||||
<option value="">{t("allAgents")}</option>
|
||||
@@ -166,14 +159,11 @@ 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 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
|
||||
@@ -183,7 +173,6 @@ export function VaultPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph — takes full remaining height */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Suspense fallback={<div className="h-full animate-pulse bg-muted" />}>
|
||||
<VaultGraphView
|
||||
@@ -197,7 +186,6 @@ export function VaultPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search dialog — result opens detail modal */}
|
||||
{selectedAgent && (
|
||||
<VaultSearchDialog
|
||||
agentId={selectedAgent} open={searchOpen} onOpenChange={setSearchOpen}
|
||||
@@ -206,7 +194,6 @@ export function VaultPage() {
|
||||
)}
|
||||
<VaultCreateDialog open={createOpen} onOpenChange={setCreateOpen} defaultAgentId={selectedAgent} defaultTeamId={selectedTeam} />
|
||||
|
||||
{/* Detail dialog for all document views */}
|
||||
<Suspense fallback={null}>
|
||||
<VaultDetailDialog
|
||||
doc={detailDoc} open={!!detailDoc}
|
||||
|
||||
Reference in new issue
Block a user