mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 14:17:40 +00:00
feat(vault): modal document view, compact sidebar, non-owner team access
UI: - Replace bottom detail panel with modal dialog for all doc views - Redesign sidebar with type icons, compact layout, inline metadata - Always show team select, fix missing load() call for teams hook - Align header heights between sidebar and main panel Backend: - Add TeamIDs filter to VaultListOptions and VaultSearchOptions - Non-owner users now see personal + their teams' docs (was personal-only) - Refactor PG search to use searchTeamFilter for FTS and vector queries - Update both PG and SQLite stores with shared team filter helpers
This commit is contained in:
1 parent
99da7edd01
commit
b416620ac7
7 files changed
+228
-333
No files matched your search
@@ -54,6 +54,34 @@ func (h *VaultHandler) validateTeamMembership(ctx context.Context, w http.Respon
|
||||
return true
|
||||
}
|
||||
|
||||
// userAccessibleTeamIDs returns the team IDs accessible by the current non-owner user.
|
||||
// Returns nil if no teams are found or team store is unavailable.
|
||||
func (h *VaultHandler) userAccessibleTeamIDs(ctx context.Context) []string {
|
||||
userID := store.UserIDFromContext(ctx)
|
||||
if userID == "" || h.teamAccess == nil {
|
||||
return nil
|
||||
}
|
||||
teams, err := h.teamAccess.ListUserTeams(ctx, userID)
|
||||
if err != nil || len(teams) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, len(teams))
|
||||
for i, t := range teams {
|
||||
ids[i] = t.ID.String()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// applyNonOwnerTeamScope restricts a non-owner vault list to personal + user's teams.
|
||||
func (h *VaultHandler) applyNonOwnerTeamScope(ctx context.Context, opts *store.VaultListOptions) {
|
||||
if ids := h.userAccessibleTeamIDs(ctx); len(ids) > 0 {
|
||||
opts.TeamIDs = ids
|
||||
} else {
|
||||
empty := ""
|
||||
opts.TeamID = &empty
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Cross-agent endpoint (agent_id optional query param).
|
||||
mux.HandleFunc("GET /v1/vault/documents", h.auth(h.handleListAllDocuments))
|
||||
@@ -107,10 +135,9 @@ func (h *VaultHandler) handleListAllDocuments(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
}
|
||||
// Non-owner without team_id filter: default to personal (NULL team_id) only.
|
||||
// Non-owner without team_id filter: show personal + user's teams.
|
||||
if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) {
|
||||
empty := ""
|
||||
opts.TeamID = &empty
|
||||
h.applyNonOwnerTeamScope(r.Context(), &opts)
|
||||
}
|
||||
|
||||
docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts)
|
||||
@@ -141,8 +168,7 @@ func (h *VaultHandler) handleListDocuments(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) {
|
||||
empty := ""
|
||||
opts.TeamID = &empty
|
||||
h.applyNonOwnerTeamScope(r.Context(), &opts)
|
||||
}
|
||||
|
||||
docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts)
|
||||
@@ -224,8 +250,12 @@ func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
searchOpts.TeamID = &body.TeamID
|
||||
} else if !store.IsOwnerRole(r.Context()) {
|
||||
empty := ""
|
||||
searchOpts.TeamID = &empty // non-owner: personal only
|
||||
if ids := h.userAccessibleTeamIDs(r.Context()); len(ids) > 0 {
|
||||
searchOpts.TeamIDs = ids
|
||||
} else {
|
||||
empty := ""
|
||||
searchOpts.TeamID = &empty
|
||||
}
|
||||
}
|
||||
|
||||
results, err := h.store.Search(r.Context(), searchOpts)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -13,6 +14,29 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// appendTeamFilter appends the team_id clause to a vault query.
|
||||
// TeamIDs (personal + listed teams) takes precedence over TeamID (single team / personal-only).
|
||||
func appendTeamFilter(q string, args []any, p int, teamID *string, teamIDs []string) (string, []any, int) {
|
||||
if len(teamIDs) > 0 {
|
||||
ph := make([]string, len(teamIDs))
|
||||
for i, id := range teamIDs {
|
||||
ph[i] = fmt.Sprintf("$%d", p)
|
||||
args = append(args, mustParseUUID(id))
|
||||
p++
|
||||
}
|
||||
q += " AND (team_id IS NULL OR team_id IN (" + strings.Join(ph, ",") + "))"
|
||||
} else if teamID != nil {
|
||||
if *teamID != "" {
|
||||
q += fmt.Sprintf(" AND team_id = $%d", p)
|
||||
args = append(args, mustParseUUID(*teamID))
|
||||
p++
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
}
|
||||
return q, args, p
|
||||
}
|
||||
|
||||
// PGVaultStore implements store.VaultStore backed by PostgreSQL.
|
||||
type PGVaultStore struct {
|
||||
db *sql.DB
|
||||
@@ -177,15 +201,7 @@ func (s *PGVaultStore) ListDocuments(ctx context.Context, tenantID, agentID stri
|
||||
p++
|
||||
}
|
||||
|
||||
if opts.TeamID != nil {
|
||||
if *opts.TeamID != "" {
|
||||
q += fmt.Sprintf(" AND team_id = $%d", p)
|
||||
args = append(args, mustParseUUID(*opts.TeamID))
|
||||
p++
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
}
|
||||
q, args, p = appendTeamFilter(q, args, p, opts.TeamID, opts.TeamIDs)
|
||||
|
||||
if opts.Scope != "" {
|
||||
q += fmt.Sprintf(" AND scope = $%d", p)
|
||||
@@ -237,15 +253,7 @@ func (s *PGVaultStore) CountDocuments(ctx context.Context, tenantID, agentID str
|
||||
args = append(args, aid)
|
||||
p++
|
||||
}
|
||||
if opts.TeamID != nil {
|
||||
if *opts.TeamID != "" {
|
||||
q += fmt.Sprintf(" AND team_id = $%d", p)
|
||||
args = append(args, mustParseUUID(*opts.TeamID))
|
||||
p++
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -281,17 +289,8 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions
|
||||
tid := mustParseUUID(opts.TenantID)
|
||||
aid := mustParseUUID(opts.AgentID)
|
||||
|
||||
// Parse team_id filter.
|
||||
var teamUUID *uuid.UUID
|
||||
if opts.TeamID != nil {
|
||||
if *opts.TeamID != "" {
|
||||
t := mustParseUUID(*opts.TeamID)
|
||||
teamUUID = &t
|
||||
}
|
||||
// opts.TeamID == ptr-to-empty → teamUUID stays nil → filters for team_id IS NULL
|
||||
}
|
||||
// opts.TeamID == nil → teamUUID stays nil and useTeamFilter = false (handled below)
|
||||
useTeamFilter := opts.TeamID != nil
|
||||
// Build team filter for search sub-queries.
|
||||
tf := buildSearchTeamFilter(opts.TeamID, opts.TeamIDs)
|
||||
|
||||
maxResults := opts.MaxResults
|
||||
if maxResults <= 0 {
|
||||
@@ -299,7 +298,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions
|
||||
}
|
||||
|
||||
// FTS search
|
||||
ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, teamUUID, useTeamFilter, opts.Scope, opts.DocTypes, maxResults*2)
|
||||
ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -310,7 +309,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions
|
||||
vecs, embErr := s.embProvider.Embed(ctx, []string{opts.Query})
|
||||
if embErr == nil && len(vecs) > 0 {
|
||||
var vecErr error
|
||||
vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, teamUUID, useTeamFilter, opts.Scope, opts.DocTypes, maxResults*2)
|
||||
vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2)
|
||||
if vecErr != nil {
|
||||
slog.Debug("vault.vector_search_fallback", "err", vecErr)
|
||||
vecResults = nil
|
||||
@@ -334,7 +333,55 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, agentID uuid.UUID, teamID *uuid.UUID, useTeamFilter bool, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
|
||||
// searchTeamFilter holds pre-parsed team filter info for search sub-queries.
|
||||
type searchTeamFilter struct {
|
||||
teamIDs []uuid.UUID // personal + these teams (len > 0 = multi-team mode)
|
||||
teamID *uuid.UUID // single team filter (nil + active = personal-only)
|
||||
active bool // whether any team filter is applied
|
||||
}
|
||||
|
||||
func buildSearchTeamFilter(teamID *string, teamIDs []string) searchTeamFilter {
|
||||
if len(teamIDs) > 0 {
|
||||
uuids := make([]uuid.UUID, len(teamIDs))
|
||||
for i, id := range teamIDs {
|
||||
uuids[i] = mustParseUUID(id)
|
||||
}
|
||||
return searchTeamFilter{teamIDs: uuids, active: true}
|
||||
}
|
||||
if teamID != nil {
|
||||
if *teamID != "" {
|
||||
t := mustParseUUID(*teamID)
|
||||
return searchTeamFilter{teamID: &t, active: true}
|
||||
}
|
||||
return searchTeamFilter{active: true} // personal-only
|
||||
}
|
||||
return searchTeamFilter{} // no filter
|
||||
}
|
||||
|
||||
// appendSearchTeamClause appends team filter SQL to a search query.
|
||||
func (tf searchTeamFilter) append(q string, args []any, p int) (string, []any, int) {
|
||||
if !tf.active {
|
||||
return q, args, p
|
||||
}
|
||||
if len(tf.teamIDs) > 0 {
|
||||
ph := make([]string, len(tf.teamIDs))
|
||||
for i, id := range tf.teamIDs {
|
||||
ph[i] = fmt.Sprintf("$%d", p)
|
||||
args = append(args, id)
|
||||
p++
|
||||
}
|
||||
q += " AND (team_id IS NULL OR team_id IN (" + strings.Join(ph, ",") + "))"
|
||||
} else if tf.teamID != nil {
|
||||
q += fmt.Sprintf(" AND team_id = $%d", p)
|
||||
args = append(args, *tf.teamID)
|
||||
p++
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
return q, args, p
|
||||
}
|
||||
|
||||
func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at,
|
||||
ts_rank(tsv, plainto_tsquery('simple', $1)) AS score
|
||||
FROM vault_documents
|
||||
@@ -342,15 +389,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, ag
|
||||
args := []any{query, tenantID, agentID}
|
||||
p := 4
|
||||
|
||||
if useTeamFilter {
|
||||
if teamID != nil {
|
||||
q += fmt.Sprintf(" AND team_id = $%d", p)
|
||||
args = append(args, *teamID)
|
||||
p++
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
}
|
||||
q, args, p = tf.append(q, args, p)
|
||||
|
||||
if scope != "" {
|
||||
q += fmt.Sprintf(" AND scope = $%d", p)
|
||||
@@ -373,7 +412,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, ag
|
||||
return vaultSearchRowsToResults(scanned, "vault"), nil
|
||||
}
|
||||
|
||||
func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID, agentID uuid.UUID, teamID *uuid.UUID, useTeamFilter bool, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
|
||||
func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
|
||||
vecStr := vectorToString(embedding)
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at,
|
||||
1 - (embedding <=> $1) AS score
|
||||
@@ -382,15 +421,7 @@ func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, te
|
||||
args := []any{vecStr, tenantID, agentID}
|
||||
p := 4
|
||||
|
||||
if useTeamFilter {
|
||||
if teamID != nil {
|
||||
q += fmt.Sprintf(" AND team_id = $%d", p)
|
||||
args = append(args, *teamID)
|
||||
p++
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
}
|
||||
q, args, p = tf.append(q, args, p)
|
||||
|
||||
if scope != "" {
|
||||
q += fmt.Sprintf(" AND scope = $%d", p)
|
||||
|
||||
@@ -15,6 +15,25 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// sqliteAppendTeamFilter appends the team_id clause to a vault query.
|
||||
func sqliteAppendTeamFilter(q string, args []any, teamID *string, teamIDs []string) (string, []any) {
|
||||
if len(teamIDs) > 0 {
|
||||
ph := strings.Repeat("?,", len(teamIDs)-1) + "?"
|
||||
q += " AND (team_id IS NULL OR team_id IN (" + ph + "))"
|
||||
for _, id := range teamIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
} else if teamID != nil {
|
||||
if *teamID != "" {
|
||||
q += " AND team_id = ?"
|
||||
args = append(args, *teamID)
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
}
|
||||
return q, args
|
||||
}
|
||||
|
||||
// SQLiteVaultStore implements store.VaultStore backed by SQLite.
|
||||
type SQLiteVaultStore struct {
|
||||
db *sql.DB
|
||||
@@ -118,14 +137,7 @@ func (s *SQLiteVaultStore) ListDocuments(ctx context.Context, tenantID, agentID
|
||||
q += " AND agent_id = ?"
|
||||
args = append(args, agentID)
|
||||
}
|
||||
if opts.TeamID != nil {
|
||||
if *opts.TeamID != "" {
|
||||
q += " AND team_id = ?"
|
||||
args = append(args, *opts.TeamID)
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
}
|
||||
q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs)
|
||||
if opts.Scope != "" {
|
||||
q += " AND scope = ?"
|
||||
args = append(args, opts.Scope)
|
||||
@@ -176,14 +188,7 @@ func (s *SQLiteVaultStore) CountDocuments(ctx context.Context, tenantID, agentID
|
||||
q += " AND agent_id = ?"
|
||||
args = append(args, agentID)
|
||||
}
|
||||
if opts.TeamID != nil {
|
||||
if *opts.TeamID != "" {
|
||||
q += " AND team_id = ?"
|
||||
args = append(args, *opts.TeamID)
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
}
|
||||
q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs)
|
||||
if opts.Scope != "" {
|
||||
q += " AND scope = ?"
|
||||
args = append(args, opts.Scope)
|
||||
@@ -248,14 +253,7 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt
|
||||
AND (title LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\')`
|
||||
args := []any{opts.TenantID, opts.AgentID, pattern, pattern}
|
||||
|
||||
if opts.TeamID != nil {
|
||||
if *opts.TeamID != "" {
|
||||
q += " AND team_id = ?"
|
||||
args = append(args, *opts.TeamID)
|
||||
} else {
|
||||
q += " AND team_id IS NULL"
|
||||
}
|
||||
}
|
||||
q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs)
|
||||
|
||||
q += " ORDER BY updated_at DESC LIMIT ?"
|
||||
args = append(args, maxResults*2)
|
||||
|
||||
@@ -55,6 +55,7 @@ type VaultSearchOptions struct {
|
||||
AgentID string
|
||||
TenantID string
|
||||
TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team
|
||||
TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view)
|
||||
Scope string // empty = all scopes
|
||||
DocTypes []string // empty = all types
|
||||
MaxResults int // default 10
|
||||
@@ -64,6 +65,7 @@ type VaultSearchOptions struct {
|
||||
// VaultListOptions configures a list query for vault documents.
|
||||
type VaultListOptions struct {
|
||||
TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team
|
||||
TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view)
|
||||
Scope string // empty = all
|
||||
DocTypes []string // empty = all
|
||||
Limit int
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Pencil, Plus, FileText, Link2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
import { useVaultLinks, useVaultFileContent, useUpdateDocument, useDeleteDocument } from "./hooks/use-vault";
|
||||
import { VaultLinkDialog } from "./vault-link-dialog";
|
||||
import { VaultEditControls, DocTypeSelect, ScopeSelect, LinkBadge } from "./vault-detail-edit-section";
|
||||
import type { VaultDocument } from "@/types/vault";
|
||||
|
||||
interface Props {
|
||||
doc: VaultDocument | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function VaultDetailPanel({ doc, open, onClose, onDeleted }: Props) {
|
||||
const { t } = useTranslation("vault");
|
||||
const { outlinks, backlinks } = useVaultLinks(doc?.agent_id ?? "", doc?.id ?? null);
|
||||
const { content, loading: contentLoading, error: contentError } = useVaultFileContent(open && doc ? doc.path : null);
|
||||
const { update } = useUpdateDocument(doc?.agent_id ?? "", doc?.id ?? "");
|
||||
const { remove } = useDeleteDocument(doc?.agent_id ?? "", doc?.id ?? "");
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editDocType, setEditDocType] = useState("");
|
||||
const [editScope, setEditScope] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
|
||||
// Reset edit state when doc changes
|
||||
useEffect(() => { setEditMode(false); setConfirmDelete(false); }, [doc?.id]);
|
||||
|
||||
// Escape to close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
const startEdit = () => {
|
||||
if (!doc) return;
|
||||
setEditTitle(doc.title); setEditDocType(doc.doc_type); setEditScope(doc.scope);
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try { await update({ title: editTitle, doc_type: editDocType, scope: editScope }); setEditMode(false); }
|
||||
catch { /* toasted */ }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setSaving(true);
|
||||
try { await remove(); onDeleted?.(); onClose(); }
|
||||
catch { /* toasted */ }
|
||||
finally { setSaving(false); setConfirmDelete(false); }
|
||||
};
|
||||
|
||||
if (!doc || !open) return null;
|
||||
|
||||
const hash = doc.content_hash;
|
||||
const hashDisplay = hash.length > 16 ? `${hash.slice(0, 8)}...${hash.slice(-8)}` : hash;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-t bg-background flex flex-col" style={{ height: 240 }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b shrink-0">
|
||||
{editMode ? (
|
||||
<Input value={editTitle} onChange={(e) => setEditTitle(e.target.value)}
|
||||
className="h-7 text-sm font-medium flex-1" autoFocus />
|
||||
) : (
|
||||
<span className="text-sm font-medium truncate flex-1">{doc.title || doc.path}</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
<>
|
||||
<DocTypeSelect value={editDocType} onChange={setEditDocType} t={t} />
|
||||
<ScopeSelect value={editScope} onChange={setEditScope} t={t} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Badge variant="secondary" className="text-2xs px-1.5 py-0">{t(`type.${doc.doc_type}`)}</Badge>
|
||||
<Badge variant="outline" className="text-2xs px-1.5 py-0">{t(`scope.${doc.scope}`)}</Badge>
|
||||
</>
|
||||
)}
|
||||
{!editMode && (
|
||||
<Button variant="ghost" size="xs" className="h-6 w-6 p-0" onClick={startEdit}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="xs" className="h-6 w-6 p-0" onClick={onClose}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Edit controls */}
|
||||
{editMode && (
|
||||
<div className="px-3 py-1 border-b shrink-0">
|
||||
<VaultEditControls saving={saving} confirmDelete={confirmDelete}
|
||||
onSave={handleSave} onCancel={() => { setEditMode(false); setConfirmDelete(false); }}
|
||||
onDeleteRequest={() => setConfirmDelete(true)} onDeleteConfirm={handleDelete}
|
||||
onDeleteCancel={() => setConfirmDelete(false)} t={t} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content: left = markdown, right = meta */}
|
||||
<div className="flex-1 min-h-0 flex overflow-hidden">
|
||||
{/* Markdown preview */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 border-r">
|
||||
{contentLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
) : contentError ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" /> {t("detail.fileNotFound")}
|
||||
</div>
|
||||
) : content ? (
|
||||
<MarkdownRenderer content={content}
|
||||
className="text-xs [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:text-xs [&_h2]:font-semibold [&_p]:text-xs [&_li]:text-xs [&_code]:text-2xs [&_pre]:overflow-x-auto [&_img]:max-w-full" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" /> {t("detail.emptyContent")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta column */}
|
||||
<div className="w-[220px] shrink-0 overflow-y-auto px-3 py-2 space-y-2 text-xs">
|
||||
<div className="font-mono text-2xs text-muted-foreground break-all" dir="rtl" title={doc.path}>
|
||||
{doc.path}
|
||||
</div>
|
||||
<div className="font-mono text-2xs text-muted-foreground">SHA: {hashDisplay}</div>
|
||||
|
||||
{/* Outlinks */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span>{t("detail.outlinks")} ({outlinks.length})</span>
|
||||
<Button variant="ghost" size="xs" className="h-5 px-1 ml-auto" onClick={() => setLinkDialogOpen(true)}>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{outlinks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{outlinks.map((l) => <LinkBadge key={l.id} link={l} agentId={doc.agent_id} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backlinks */}
|
||||
{backlinks.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground">{t("detail.backlinks")} ({backlinks.length})</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{backlinks.map((l) => (
|
||||
<Badge key={l.id} variant="secondary" className="text-2xs">{l.from_doc_id.slice(0, 8)}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{linkDialogOpen && (
|
||||
<VaultLinkDialog agentId={doc.agent_id} fromDoc={doc}
|
||||
open={linkDialogOpen} onOpenChange={setLinkDialogOpen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
Link2, ChevronLeft, ChevronRight,
|
||||
FileText, Brain, StickyNote, Sparkles, Clock, Image,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { VaultDocument } from "@/types/vault";
|
||||
|
||||
const DOC_DOT_COLORS: Record<string, string> = {
|
||||
context: "bg-blue-500",
|
||||
memory: "bg-purple-500",
|
||||
note: "bg-yellow-500",
|
||||
skill: "bg-green-500",
|
||||
episodic: "bg-orange-500",
|
||||
media: "bg-red-500",
|
||||
const DOC_TYPE_CONFIG: Record<string, { color: string; bg: string; icon: typeof FileText }> = {
|
||||
context: { color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", icon: FileText },
|
||||
memory: { color: "text-purple-600 dark:text-purple-400", bg: "bg-purple-500/10", icon: Brain },
|
||||
note: { color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-500/10", icon: StickyNote },
|
||||
skill: { color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", icon: Sparkles },
|
||||
episodic: { color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-500/10", icon: Clock },
|
||||
media: { color: "text-rose-600 dark:text-rose-400", bg: "bg-rose-500/10", icon: Image },
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG = { color: "text-muted-foreground", bg: "bg-muted", icon: FileText };
|
||||
|
||||
interface Props {
|
||||
documents: VaultDocument[];
|
||||
selectedId: string | null;
|
||||
@@ -31,6 +37,8 @@ function DocCard({ doc, selected, linkCount, onClick }: {
|
||||
}) {
|
||||
const { t } = useTranslation("vault");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const cfg = DOC_TYPE_CONFIG[doc.doc_type] ?? DEFAULT_CONFIG;
|
||||
const Icon = cfg.icon;
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) ref.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
@@ -39,26 +47,40 @@ function DocCard({ doc, selected, linkCount, onClick }: {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`px-3 py-2 cursor-pointer border-b transition-colors ${selected ? "bg-accent/50" : "hover:bg-muted/50"}`}
|
||||
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="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${DOC_DOT_COLORS[doc.doc_type] ?? "bg-gray-400"}`} />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{/* Type icon */}
|
||||
<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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium leading-snug">
|
||||
{doc.title || doc.path.split("/").pop()}
|
||||
</span>
|
||||
{linkCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-2xs text-muted-foreground shrink-0">
|
||||
<Link2 className="h-3 w-3" />
|
||||
{linkCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 pl-4">
|
||||
{t(`type.${doc.doc_type}`)} · {t(`scope.${doc.scope}`)}
|
||||
</div>
|
||||
<div className="text-2xs text-muted-foreground pl-4">
|
||||
{formatRelativeTime(doc.updated_at)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -72,24 +94,27 @@ export function VaultDocumentSidebar({
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<span className="text-sm font-medium">{t("title")}</span>
|
||||
<span className="text-2xs text-muted-foreground">{total}</span>
|
||||
<div className="flex h-10 items-center justify-between px-3 border-b shrink-0">
|
||||
<span className="text-sm font-semibold">{t("title")}</span>
|
||||
<Badge variant="secondary" className="text-2xs tabular-nums">{total}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Doc list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{loading && documents.length === 0 ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="px-3 py-2 border-b space-y-1.5">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
|
||||
<div className="h-2.5 w-1/3 animate-pulse rounded bg-muted" />
|
||||
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>
|
||||
))
|
||||
) : documents.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
|
||||
{t("noDocuments")}
|
||||
<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("noDocuments")}</span>
|
||||
</div>
|
||||
) : (
|
||||
documents.map((doc) => (
|
||||
@@ -110,7 +135,7 @@ export function VaultDocumentSidebar({
|
||||
<Button variant="ghost" size="xs" disabled={page === 0} onClick={() => onPageChange(page - 1)}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span>{page + 1} / {totalPages}</span>
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, lazy, Suspense } from "react";
|
||||
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, FileArchive, Plus, PanelLeftOpen } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,7 +8,6 @@ import { useTeams } from "@/pages/teams/hooks/use-teams";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useVaultDocuments, useVaultGraphData } from "./hooks/use-vault";
|
||||
import { VaultDocumentSidebar } from "./vault-document-sidebar";
|
||||
import { VaultDetailPanel } from "./vault-detail-panel";
|
||||
import { VaultSearchDialog } from "./vault-search-dialog";
|
||||
import { VaultCreateDialog } from "./vault-create-dialog";
|
||||
import type { VaultDocument } from "@/types/vault";
|
||||
@@ -25,16 +24,17 @@ const PAGE_SIZE = 100;
|
||||
export function VaultPage() {
|
||||
const { t } = useTranslation("vault");
|
||||
const { agents } = useAgents();
|
||||
const { teams } = useTeams();
|
||||
const { teams, load: loadTeams } = useTeams();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => { loadTeams(); }, [loadTeams]);
|
||||
|
||||
const [selectedAgent, setSelectedAgent] = useState("");
|
||||
const [selectedTeam, setSelectedTeam] = useState("");
|
||||
const [selectedDoc, setSelectedDoc] = useState<VaultDocument | null>(null);
|
||||
const [detailDoc, setDetailDoc] = useState<VaultDocument | null>(null);
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [searchResultDoc, setSearchResultDoc] = useState<VaultDocument | null>(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
@@ -59,9 +59,9 @@ export function VaultPage() {
|
||||
const handleAgentChange = (v: string) => { setSelectedAgent(v); setPage(0); };
|
||||
const handleTeamChange = (v: string) => { setSelectedTeam(v); setPage(0); };
|
||||
|
||||
// Sidebar click → select doc + highlight graph node
|
||||
// Sidebar click → open detail modal + highlight graph node
|
||||
const handleSidebarSelect = (doc: VaultDocument) => {
|
||||
setSelectedDoc(doc);
|
||||
setDetailDoc(doc);
|
||||
setSelectedDocId(doc.id);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
};
|
||||
@@ -69,16 +69,15 @@ export function VaultPage() {
|
||||
// Graph single-click → highlight only
|
||||
const handleNodeSelect = (docId: string | null) => {
|
||||
setSelectedDocId(docId);
|
||||
if (!docId) setSelectedDoc(null);
|
||||
};
|
||||
|
||||
// Graph double-click → open detail + highlight
|
||||
// Graph double-click → open detail modal + highlight
|
||||
const handleNodeDoubleClick = (doc: VaultDocument) => {
|
||||
setSelectedDoc(doc);
|
||||
setDetailDoc(doc);
|
||||
setSelectedDocId(doc.id);
|
||||
};
|
||||
|
||||
const handleCloseDetail = () => { setSelectedDoc(null); setSelectedDocId(null); };
|
||||
const handleCloseDetail = () => { setDetailDoc(null); };
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full overflow-hidden">
|
||||
@@ -109,7 +108,7 @@ export function VaultPage() {
|
||||
{/* Main: header + graph + detail panel */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b flex-wrap shrink-0">
|
||||
<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)}>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
@@ -120,17 +119,15 @@ export function VaultPage() {
|
||||
|
||||
{/* 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-8">
|
||||
className="text-base md:text-sm border rounded px-2 py-1 bg-background h-7">
|
||||
<option value="">{t("allAgents")}</option>
|
||||
{(agents ?? []).map((a) => <option key={a.id} value={a.id}>{a.display_name || a.agent_key}</option>)}
|
||||
</select>
|
||||
{(teams ?? []).length > 0 && (
|
||||
<select value={selectedTeam} onChange={(e) => handleTeamChange(e.target.value)}
|
||||
className="text-base md:text-sm border rounded px-2 py-1 bg-background h-8">
|
||||
<option value="">{t("allTeams", "All teams")}</option>
|
||||
{(teams ?? []).map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select value={selectedTeam} onChange={(e) => handleTeamChange(e.target.value)}
|
||||
className="text-base md:text-sm border rounded px-2 py-1 bg-background h-7">
|
||||
<option value="">{t("allTeams", "All teams")}</option>
|
||||
{(teams ?? []).map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
|
||||
</select>
|
||||
<Button size="sm" variant="outline" onClick={() => setSearchOpen(true)} disabled={!selectedAgent}>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -148,7 +145,7 @@ export function VaultPage() {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Graph */}
|
||||
{/* Graph — takes full remaining height */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Suspense fallback={<div className="h-full animate-pulse bg-muted" />}>
|
||||
<VaultGraphView
|
||||
@@ -160,31 +157,23 @@ export function VaultPage() {
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Detail panel (bottom, collapsible) */}
|
||||
<VaultDetailPanel
|
||||
doc={selectedDoc}
|
||||
open={!!selectedDoc}
|
||||
onClose={handleCloseDetail}
|
||||
onDeleted={handleCloseDetail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search dialog — result opens legacy modal */}
|
||||
{/* Search dialog — result opens detail modal */}
|
||||
{selectedAgent && (
|
||||
<VaultSearchDialog
|
||||
agentId={selectedAgent} open={searchOpen} onOpenChange={setSearchOpen}
|
||||
onSelectResult={setSearchResultDoc}
|
||||
onSelectResult={(doc) => { setDetailDoc(doc); setSelectedDocId(doc.id); }}
|
||||
/>
|
||||
)}
|
||||
{selectedAgent && <VaultCreateDialog agentId={selectedAgent} open={createOpen} onOpenChange={setCreateOpen} />}
|
||||
|
||||
{/* Legacy detail dialog for search results only */}
|
||||
{/* Detail dialog for all document views */}
|
||||
<Suspense fallback={null}>
|
||||
<VaultDetailDialog
|
||||
doc={searchResultDoc} open={!!searchResultDoc}
|
||||
onOpenChange={(open) => !open && setSearchResultDoc(null)}
|
||||
onDeleted={() => setSearchResultDoc(null)}
|
||||
doc={detailDoc} open={!!detailDoc}
|
||||
onOpenChange={(open) => { if (!open) handleCloseDetail(); }}
|
||||
onDeleted={handleCloseDetail}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user