mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
feat(vault): tenant-wide rescan with nullable agent_id + media preview
Vault rescan redesigned from per-agent to tenant-wide:
- POST /v1/vault/rescan replaces POST /v1/agents/{id}/vault/rescan
- agent_id nullable in vault_documents (PG migration 046 + SQLite v14)
- Path-based scope inference: agents/{key}/ → personal, teams/{uuid}/ → team, root → shared
- Interceptor sets agent_id=NULL for team-scoped file writes
- Enrichment worker batch key handles empty agent_id
- web-fetch/ directory excluded from vault scan at any depth
- Media preview: images render via authenticated blob URL, binary files show metadata
- Scan button no longer requires agent selection
This commit is contained in:
1 parent
139b454efd
commit
9f77bfe711
49 files changed
+1550
-312
No files matched your search
+60
-29
@@ -181,17 +181,16 @@ func runGateway() {
|
||||
}
|
||||
setupMemoryEmbeddings(pgStores, providerRegistry)
|
||||
|
||||
// Resolve background provider for consolidation + vault enrichment.
|
||||
// Fallback: background.provider → agent.default_provider → first registered provider.
|
||||
bgProvider, bgModel := resolveBackgroundProvider(cfg, providerRegistry)
|
||||
|
||||
// V3: Wire consolidation pipeline (episodic → semantic → KG → dreaming)
|
||||
if pgStores.Episodic != nil {
|
||||
var consolidationProvider providers.Provider
|
||||
if names := providerRegistry.ListForTenant(providers.MasterTenantID); len(names) > 0 {
|
||||
consolidationProvider, _ = providerRegistry.GetForTenant(providers.MasterTenantID, names[0])
|
||||
}
|
||||
if consolidationProvider != nil {
|
||||
// Create KG extractor for semantic worker (entity/relation extraction from episodic summaries)
|
||||
if bgProvider != nil {
|
||||
var kgExtractor *kg.Extractor
|
||||
if pgStores.KnowledgeGraph != nil {
|
||||
kgExtractor = kg.NewExtractor(consolidationProvider, consolidationProvider.DefaultModel(), 0)
|
||||
kgExtractor = kg.NewExtractor(bgProvider, bgModel, 0)
|
||||
}
|
||||
cleanupConsolidation := consolidation.Register(consolidation.ConsolidationDeps{
|
||||
EpisodicStore: pgStores.Episodic,
|
||||
@@ -199,36 +198,28 @@ func runGateway() {
|
||||
KGStore: pgStores.KnowledgeGraph,
|
||||
SessionStore: pgStores.Sessions,
|
||||
EventBus: domainBus,
|
||||
Provider: consolidationProvider,
|
||||
Model: consolidationProvider.DefaultModel(),
|
||||
Provider: bgProvider,
|
||||
Model: bgModel,
|
||||
Extractor: kgExtractor,
|
||||
// Per-agent dreaming overrides (MemoryConfig.Dreaming JSONB).
|
||||
AgentStore: pgStores.Agents,
|
||||
AgentStore: pgStores.Agents,
|
||||
})
|
||||
defer cleanupConsolidation()
|
||||
slog.Info("consolidation pipeline registered")
|
||||
slog.Info("consolidation pipeline registered", "provider", bgProvider.Name(), "model", bgModel)
|
||||
} else {
|
||||
slog.Warn("consolidation pipeline skipped: no provider available")
|
||||
}
|
||||
}
|
||||
|
||||
// V3: Wire vault enrichment worker (async summary + embedding + auto-linking).
|
||||
// Resolves provider independently from consolidation pipeline.
|
||||
if pgStores.Vault != nil {
|
||||
var vaultProvider providers.Provider
|
||||
if names := providerRegistry.ListForTenant(providers.MasterTenantID); len(names) > 0 {
|
||||
vaultProvider, _ = providerRegistry.GetForTenant(providers.MasterTenantID, names[0])
|
||||
}
|
||||
if vaultProvider != nil {
|
||||
cleanupVaultEnrich := vault.RegisterEnrichWorker(vault.EnrichWorkerDeps{
|
||||
VaultStore: pgStores.Vault,
|
||||
Provider: vaultProvider,
|
||||
Model: vaultProvider.DefaultModel(),
|
||||
EventBus: domainBus,
|
||||
})
|
||||
defer cleanupVaultEnrich()
|
||||
slog.Info("vault enrichment worker registered")
|
||||
}
|
||||
if pgStores.Vault != nil && bgProvider != nil {
|
||||
cleanupVaultEnrich := vault.RegisterEnrichWorker(vault.EnrichWorkerDeps{
|
||||
VaultStore: pgStores.Vault,
|
||||
Provider: bgProvider,
|
||||
Model: bgModel,
|
||||
EventBus: domainBus,
|
||||
})
|
||||
defer cleanupVaultEnrich()
|
||||
slog.Info("vault enrichment worker registered", "provider", bgProvider.Name(), "model", bgModel)
|
||||
}
|
||||
|
||||
loadBootstrapFiles(pgStores, workspace, agentCfg)
|
||||
@@ -319,7 +310,11 @@ func runGateway() {
|
||||
// Wire dependencies for system prompt preview parity.
|
||||
if agentsH != nil {
|
||||
agentsH.SetPreviewDeps(toolsReg, skillsLoader)
|
||||
agentsH.SetPreviewStores(pgStores.Teams, pgStores.AgentLinks)
|
||||
var skillAccess store.SkillAccessStore
|
||||
if pgStores.Skills != nil {
|
||||
skillAccess, _ = pgStores.Skills.(store.SkillAccessStore)
|
||||
}
|
||||
agentsH.SetPreviewStores(pgStores.Teams, pgStores.AgentLinks, skillAccess)
|
||||
}
|
||||
|
||||
// External wake/trigger API
|
||||
@@ -543,3 +538,39 @@ func runGateway() {
|
||||
sigCh: sigCh,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveBackgroundProvider picks the LLM provider+model for background workers
|
||||
// (vault enrichment, consolidation). Fallback chain:
|
||||
//
|
||||
// background.provider/model → agent.default_provider/model → first registered provider.
|
||||
func resolveBackgroundProvider(cfg *config.Config, reg *providers.Registry) (providers.Provider, string) {
|
||||
try := func(name, model string) (providers.Provider, string, bool) {
|
||||
if name == "" {
|
||||
return nil, "", false
|
||||
}
|
||||
p, err := reg.GetForTenant(providers.MasterTenantID, name)
|
||||
if err != nil || p == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
if model == "" {
|
||||
model = p.DefaultModel()
|
||||
}
|
||||
return p, model, true
|
||||
}
|
||||
|
||||
// 1. Explicit background config
|
||||
if p, m, ok := try(cfg.Gateway.BackgroundProvider, cfg.Gateway.BackgroundModel); ok {
|
||||
return p, m
|
||||
}
|
||||
// 2. Agent default provider
|
||||
if p, m, ok := try(cfg.Agents.Defaults.Provider, cfg.Agents.Defaults.Model); ok {
|
||||
return p, m
|
||||
}
|
||||
// 3. First registered provider (legacy fallback)
|
||||
if names := reg.ListForTenant(providers.MasterTenantID); len(names) > 0 {
|
||||
if p, m, ok := try(names[0], ""); ok {
|
||||
return p, m
|
||||
}
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
|
||||
|
||||
// V3: Knowledge Vault document API
|
||||
if d.pgStores != nil && d.pgStores.Vault != nil {
|
||||
d.server.SetVaultHandler(httpapi.NewVaultHandler(d.pgStores.Vault, d.pgStores.Teams, d.workspace, d.domainBus))
|
||||
d.server.SetVaultHandler(httpapi.NewVaultHandler(d.pgStores.Vault, d.pgStores.Teams, d.workspace, d.domainBus, d.pgStores.Agents, d.pgStores.Teams))
|
||||
}
|
||||
|
||||
// V3: Episodic memory summaries API
|
||||
|
||||
@@ -106,6 +106,10 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
|
||||
setBool("gateway.tool_status", cfg.Gateway.ToolStatus)
|
||||
setInt("gateway.task_recovery_interval_sec", cfg.Gateway.TaskRecoveryIntervalSec)
|
||||
|
||||
// Background workers
|
||||
set("background.provider", cfg.Gateway.BackgroundProvider)
|
||||
set("background.model", cfg.Gateway.BackgroundModel)
|
||||
|
||||
// Tools
|
||||
set("tools.profile", cfg.Tools.Profile)
|
||||
setInt("tools.rate_limit_per_hour", cfg.Tools.RateLimitPerHour)
|
||||
|
||||
@@ -4,34 +4,49 @@ import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// PreviewDeps holds optional dependencies for building a preview system prompt.
|
||||
// All fields are nil-safe — missing deps simply skip resolution for that section.
|
||||
type PreviewDeps struct {
|
||||
AgentStore store.AgentStore
|
||||
TeamStore store.TeamStore
|
||||
AgentLinks store.AgentLinkStore
|
||||
ProviderReg *providers.Registry
|
||||
ToolLister interface{ List() []string }
|
||||
SkillsLoader interface {
|
||||
AgentStore store.AgentStore
|
||||
TeamStore store.TeamStore
|
||||
AgentLinks store.AgentLinkStore
|
||||
ProviderReg *providers.Registry
|
||||
SkillAccessStore store.SkillAccessStore
|
||||
ToolLister interface {
|
||||
List() []string
|
||||
Get(name string) (tools.Tool, bool)
|
||||
Aliases() map[string]string
|
||||
}
|
||||
SkillsLoader interface {
|
||||
BuildPinnedSummary(ctx context.Context, names []string) string
|
||||
BuildSummary(ctx context.Context, allowList []string) string
|
||||
}
|
||||
DataDir string // for team workspace path construction
|
||||
}
|
||||
|
||||
// PreviewResult holds the output of BuildPreviewPrompt.
|
||||
type PreviewResult struct {
|
||||
Prompt string
|
||||
ToolDefs []providers.ToolDefinition // tool definitions (schemas) as sent to the LLM
|
||||
}
|
||||
|
||||
// BuildPreviewPrompt builds a system prompt for preview purposes.
|
||||
// Reuses the same BuildSystemPrompt() as the LLM pipeline, resolving as many
|
||||
// fields as possible from agent data + DB stores. Runtime-only fields
|
||||
// (channel, peer kind, session context, credentials) are left at zero values —
|
||||
// BuildSystemPrompt already nil-checks every field.
|
||||
func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMode, userID string, deps PreviewDeps) string {
|
||||
func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMode, userID string, deps PreviewDeps) PreviewResult {
|
||||
// --- Context files ---
|
||||
var contextFiles []bootstrap.ContextFile
|
||||
if deps.AgentStore != nil {
|
||||
@@ -62,6 +77,63 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
|
||||
toolNames = fallbackPreviewToolNames
|
||||
}
|
||||
|
||||
// --- skill_manage gating (matches loop_history.go:124-131) ---
|
||||
if !ag.ParseSkillEvolve() {
|
||||
filtered := make([]string, 0, len(toolNames))
|
||||
for _, n := range toolNames {
|
||||
if n != "skill_manage" {
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
}
|
||||
toolNames = filtered
|
||||
}
|
||||
|
||||
// --- Basic agent tool policy: deny-only (Allow/AlsoAllow/ByProvider not applied).
|
||||
// Full PolicyEngine requires runtime state (provider name, channel) not available in preview. ---
|
||||
if toolPolicy := ag.ParseToolsConfig(); toolPolicy != nil && len(toolPolicy.Deny) > 0 {
|
||||
denySet := make(map[string]bool, len(toolPolicy.Deny))
|
||||
for _, d := range toolPolicy.Deny {
|
||||
denySet[d] = true
|
||||
}
|
||||
filtered := make([]string, 0, len(toolNames))
|
||||
for _, n := range toolNames {
|
||||
if !denySet[n] {
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
}
|
||||
toolNames = filtered
|
||||
}
|
||||
|
||||
// --- Alias exclusion (matches loop_history.go:136-146) ---
|
||||
if deps.ToolLister != nil {
|
||||
if aliasSet := deps.ToolLister.Aliases(); len(aliasSet) > 0 {
|
||||
filtered := make([]string, 0, len(toolNames))
|
||||
for _, n := range toolNames {
|
||||
if _, isAlias := aliasSet[n]; !isAlias {
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
}
|
||||
toolNames = filtered
|
||||
}
|
||||
}
|
||||
|
||||
// --- MCP tool descriptions (matches loop_history_supplement.go:44-58) ---
|
||||
var mcpToolDescs map[string]string
|
||||
if deps.ToolLister != nil {
|
||||
descs := make(map[string]string)
|
||||
for _, name := range toolNames {
|
||||
if !strings.HasPrefix(name, "mcp_") || name == "mcp_tool_search" {
|
||||
continue
|
||||
}
|
||||
if tool, ok := deps.ToolLister.Get(name); ok {
|
||||
descs[name] = tool.Description()
|
||||
}
|
||||
}
|
||||
if len(descs) > 0 {
|
||||
mcpToolDescs = descs
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sandbox ---
|
||||
sandboxCfg := ag.ParseSandboxConfig()
|
||||
sandboxEnabled := sandboxCfg != nil && sandboxCfg.Mode != "" && sandboxCfg.Mode != "off"
|
||||
@@ -76,6 +148,32 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
|
||||
pinnedSummary = deps.SkillsLoader.BuildPinnedSummary(ctx, pinnedSkills)
|
||||
}
|
||||
|
||||
// --- Skills summary (BuildSummary + token count) ---
|
||||
var skillsSummary string
|
||||
if deps.SkillsLoader != nil {
|
||||
var skillAllowList []string
|
||||
if deps.SkillAccessStore != nil {
|
||||
if accessible, err := deps.SkillAccessStore.ListAccessible(ctx, ag.ID, userID); err == nil {
|
||||
skillAllowList = make([]string, 0, len(accessible))
|
||||
for _, sk := range accessible {
|
||||
skillAllowList = append(skillAllowList, sk.Slug)
|
||||
}
|
||||
} else {
|
||||
// On error: empty list (no skills). Preview is diagnostic; safer than showing all.
|
||||
skillAllowList = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
summary := deps.SkillsLoader.BuildSummary(ctx, skillAllowList)
|
||||
if summary != "" {
|
||||
tokens := tokencount.NewFallbackCounter().Count("claude-3", summary)
|
||||
if tokens <= skillInlineMaxTokens {
|
||||
skillsSummary = summary
|
||||
}
|
||||
// Over threshold → search-only mode (skillsSummary stays empty)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Provider contribution ---
|
||||
var providerContrib *providers.PromptContribution
|
||||
if deps.ProviderReg != nil && ag.Provider != "" {
|
||||
@@ -121,8 +219,31 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tool definitions (schemas sent to LLM alongside the system prompt) ---
|
||||
var toolDefs []providers.ToolDefinition
|
||||
if deps.ToolLister != nil {
|
||||
for _, name := range toolNames {
|
||||
if tool, ok := deps.ToolLister.Get(name); ok {
|
||||
toolDefs = append(toolDefs, tools.ToProviderDef(tool))
|
||||
}
|
||||
}
|
||||
// Include alias definitions (LLM receives both canonical + aliases)
|
||||
for alias, canonical := range deps.ToolLister.Aliases() {
|
||||
if tool, ok := deps.ToolLister.Get(canonical); ok {
|
||||
toolDefs = append(toolDefs, providers.ToolDefinition{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionSchema{
|
||||
Name: alias,
|
||||
Description: tool.Description(),
|
||||
Parameters: tool.Parameters(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build system prompt (same function as LLM pipeline) ---
|
||||
return BuildSystemPrompt(SystemPromptConfig{
|
||||
prompt := BuildSystemPrompt(SystemPromptConfig{
|
||||
AgentID: ag.AgentKey,
|
||||
AgentUUID: ag.ID.String(),
|
||||
DisplayName: ag.DisplayName,
|
||||
@@ -146,6 +267,8 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
|
||||
SandboxEnabled: sandboxEnabled,
|
||||
SandboxContainerDir: sandboxContainerDir,
|
||||
PinnedSkillsSummary: pinnedSummary,
|
||||
SkillsSummary: skillsSummary,
|
||||
MCPToolDescs: mcpToolDescs,
|
||||
IsTeamContext: isTeamCtx,
|
||||
TeamWorkspace: teamWorkspace,
|
||||
TeamMembers: teamMembers,
|
||||
@@ -154,8 +277,9 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
|
||||
OrchMode: orchMode,
|
||||
// Runtime-only fields left at zero: Channel, ChannelType, ChatTitle,
|
||||
// PeerKind, OwnerIDs, ExtraPrompt, CredentialCLIContext, IsBootstrap,
|
||||
// MCPToolDescs, SkillsSummary, SandboxWorkspaceAccess
|
||||
// SandboxWorkspaceAccess
|
||||
})
|
||||
return PreviewResult{Prompt: prompt, ToolDefs: toolDefs}
|
||||
}
|
||||
|
||||
// mergePreviewUserFiles overlays per-user files onto base agent-level files.
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func baseAgent() *store.AgentData {
|
||||
return &store.AgentData{
|
||||
BaseModel: store.BaseModel{ID: uuid.New()},
|
||||
AgentKey: "test-agent",
|
||||
AgentType: store.AgentTypePredefined,
|
||||
Workspace: "/workspace",
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_NilDeps(t *testing.T) {
|
||||
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{})
|
||||
if r.Prompt == "" {
|
||||
t.Fatal("expected non-empty prompt with nil deps")
|
||||
}
|
||||
if !strings.Contains(r.Prompt, "read_file") {
|
||||
t.Error("expected fallback tool names in prompt")
|
||||
}
|
||||
// No tool lister → no tool defs
|
||||
if len(r.ToolDefs) != 0 {
|
||||
t.Errorf("expected no tool defs with nil ToolLister, got %d", len(r.ToolDefs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_SkillsInline(t *testing.T) {
|
||||
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
|
||||
SkillsLoader: &mockSkillsLoader{
|
||||
summary: "<available_skills>\n<skill name=\"git\">Git operations</skill>\n</available_skills>",
|
||||
},
|
||||
})
|
||||
if !strings.Contains(r.Prompt, "<available_skills>") {
|
||||
t.Error("expected skills XML inlined in prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_SkillsSearchMode(t *testing.T) {
|
||||
bigSummary := strings.Repeat("x", 10000)
|
||||
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
|
||||
SkillsLoader: &mockSkillsLoader{summary: bigSummary},
|
||||
})
|
||||
if strings.Contains(r.Prompt, bigSummary) {
|
||||
t.Error("expected large summary to be excluded (search-only mode)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_PinnedSkillsHybrid(t *testing.T) {
|
||||
ag := baseAgent()
|
||||
ag.OtherConfig = []byte(`{"pinned_skills":["deploy"]}`)
|
||||
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "", PreviewDeps{
|
||||
SkillsLoader: &mockSkillsLoader{
|
||||
pinned: "<skill name=\"deploy\">Deploy to prod</skill>",
|
||||
summary: "<available_skills>\n<skill name=\"git\">Git ops</skill>\n</available_skills>",
|
||||
},
|
||||
})
|
||||
if !strings.Contains(r.Prompt, "deploy") || !strings.Contains(r.Prompt, "Pinned skills") {
|
||||
t.Error("expected pinned skills section in prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_SkillAllowList(t *testing.T) {
|
||||
ag := baseAgent()
|
||||
loader := &mockSkillsLoader{
|
||||
summary: "<available_skills><skill name=\"allowed\">ok</skill></available_skills>",
|
||||
}
|
||||
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "user1", PreviewDeps{
|
||||
SkillsLoader: loader,
|
||||
SkillAccessStore: &mockSkillAccessStore{
|
||||
accessible: []store.SkillInfo{{Slug: "allowed-skill"}},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(r.Prompt, "<available_skills>") {
|
||||
t.Error("expected filtered skills in prompt")
|
||||
}
|
||||
if len(loader.capturedAllow) != 1 || loader.capturedAllow[0] != "allowed-skill" {
|
||||
t.Errorf("expected allow list [allowed-skill], got %v", loader.capturedAllow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_SkillAccessStoreError(t *testing.T) {
|
||||
ag := baseAgent()
|
||||
loader := &mockSkillsLoader{
|
||||
summary: "<available_skills><skill name=\"s\">desc</skill></available_skills>",
|
||||
}
|
||||
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "user1", PreviewDeps{
|
||||
SkillsLoader: loader,
|
||||
SkillAccessStore: &mockSkillAccessStore{err: errors.New("db error")},
|
||||
})
|
||||
if r.Prompt == "" {
|
||||
t.Fatal("expected non-empty prompt on SkillAccessStore error")
|
||||
}
|
||||
if loader.capturedAllow == nil || len(loader.capturedAllow) != 0 {
|
||||
t.Errorf("expected empty (non-nil) allow list on error, got %v", loader.capturedAllow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_MCPToolDescs(t *testing.T) {
|
||||
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
|
||||
ToolLister: &mockToolLister{
|
||||
tools: map[string]string{
|
||||
"read_file": "Read a file",
|
||||
"mcp_pg_query": "Run PostgreSQL queries",
|
||||
},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(r.Prompt, "mcp_pg_query") {
|
||||
t.Error("expected MCP tool description in prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_MCPToolSearchExcluded(t *testing.T) {
|
||||
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
|
||||
ToolLister: &mockToolLister{
|
||||
tools: map[string]string{
|
||||
"read_file": "Read a file",
|
||||
"mcp_tool_search": "Search MCP tools",
|
||||
},
|
||||
},
|
||||
})
|
||||
if strings.Contains(r.Prompt, "Search MCP tools") {
|
||||
t.Error("mcp_tool_search should not appear in MCP tool descriptions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_AliasExclusion(t *testing.T) {
|
||||
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
|
||||
ToolLister: &mockToolLister{
|
||||
tools: map[string]string{
|
||||
"read_file": "Read a file",
|
||||
"Read": "Alias for read_file",
|
||||
"exec": "Execute shell",
|
||||
"Bash": "Alias for exec",
|
||||
},
|
||||
aliases: map[string]string{
|
||||
"Read": "read_file",
|
||||
"Bash": "exec",
|
||||
},
|
||||
},
|
||||
})
|
||||
if strings.Contains(r.Prompt, "- Read\n") || strings.Contains(r.Prompt, "- Bash\n") {
|
||||
t.Error("aliases should be excluded from tool list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_SkillManageGating(t *testing.T) {
|
||||
ag := baseAgent()
|
||||
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "", PreviewDeps{
|
||||
ToolLister: &mockToolLister{
|
||||
tools: map[string]string{
|
||||
"read_file": "Read a file",
|
||||
"skill_manage": "Manage skills",
|
||||
},
|
||||
},
|
||||
})
|
||||
if strings.Contains(r.Prompt, "skill_manage") {
|
||||
t.Error("skill_manage should be excluded when skill_evolve is off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_SkillManageEnabled(t *testing.T) {
|
||||
ag := baseAgent()
|
||||
ag.SkillEvolve = true
|
||||
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "", PreviewDeps{
|
||||
ToolLister: &mockToolLister{
|
||||
tools: map[string]string{
|
||||
"read_file": "Read a file",
|
||||
"skill_manage": "Manage skills",
|
||||
"skill_search": "Search skills",
|
||||
},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(r.Prompt, "skill_manage") {
|
||||
t.Error("skill_manage should be present when skill_evolve is on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_ToolPolicyDeny(t *testing.T) {
|
||||
ag := baseAgent()
|
||||
ag.ToolsConfig = []byte(`{"deny":["exec","web_fetch"]}`)
|
||||
r := BuildPreviewPrompt(context.Background(), ag, PromptFull, "", PreviewDeps{
|
||||
ToolLister: &mockToolLister{
|
||||
tools: map[string]string{
|
||||
"read_file": "Read a file",
|
||||
"exec": "Execute shell",
|
||||
"web_fetch": "Fetch web page",
|
||||
},
|
||||
},
|
||||
})
|
||||
if strings.Contains(r.Prompt, "- exec\n") {
|
||||
t.Error("denied tool 'exec' should be excluded")
|
||||
}
|
||||
if strings.Contains(r.Prompt, "- web_fetch\n") {
|
||||
t.Error("denied tool 'web_fetch' should be excluded")
|
||||
}
|
||||
if !strings.Contains(r.Prompt, "read_file") {
|
||||
t.Error("non-denied tool 'read_file' should be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPrompt_ToolDefs(t *testing.T) {
|
||||
r := BuildPreviewPrompt(context.Background(), baseAgent(), PromptFull, "", PreviewDeps{
|
||||
ToolLister: &mockToolLister{
|
||||
tools: map[string]string{
|
||||
"read_file": "Read a file",
|
||||
"exec": "Execute shell",
|
||||
},
|
||||
aliases: map[string]string{
|
||||
"Read": "read_file",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Should have canonical tools + aliases in tool defs
|
||||
if len(r.ToolDefs) != 3 { // read_file + exec + Read alias
|
||||
t.Errorf("expected 3 tool defs (2 canonical + 1 alias), got %d", len(r.ToolDefs))
|
||||
}
|
||||
// Verify alias is included in defs even though excluded from system prompt
|
||||
found := false
|
||||
for _, td := range r.ToolDefs {
|
||||
if td.Function.Name == "Read" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected alias 'Read' in tool defs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// mockToolLister implements the widened ToolLister interface for testing.
|
||||
type mockToolLister struct {
|
||||
tools map[string]string // name → description
|
||||
aliases map[string]string // alias → canonical
|
||||
}
|
||||
|
||||
func (m *mockToolLister) List() []string {
|
||||
names := make([]string, 0, len(m.tools))
|
||||
for n := range m.tools {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *mockToolLister) Get(name string) (tools.Tool, bool) {
|
||||
desc, ok := m.tools[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return &mockTool{name: name, desc: desc}, true
|
||||
}
|
||||
|
||||
func (m *mockToolLister) Aliases() map[string]string {
|
||||
if m.aliases == nil {
|
||||
return nil
|
||||
}
|
||||
return m.aliases
|
||||
}
|
||||
|
||||
// mockTool is a minimal tools.Tool implementation for testing.
|
||||
type mockTool struct {
|
||||
name string
|
||||
desc string
|
||||
}
|
||||
|
||||
func (t *mockTool) Name() string { return t.name }
|
||||
func (t *mockTool) Description() string { return t.desc }
|
||||
func (t *mockTool) Parameters() map[string]any { return nil }
|
||||
func (t *mockTool) Execute(_ context.Context, _ map[string]any) *tools.Result { return nil }
|
||||
|
||||
// mockSkillsLoader implements the widened SkillsLoader interface.
|
||||
type mockSkillsLoader struct {
|
||||
pinned string // pre-built pinned XML
|
||||
summary string // pre-built full summary
|
||||
capturedAllow []string // set by BuildSummary for test assertions
|
||||
}
|
||||
|
||||
func (m *mockSkillsLoader) BuildPinnedSummary(_ context.Context, _ []string) string {
|
||||
return m.pinned
|
||||
}
|
||||
|
||||
func (m *mockSkillsLoader) BuildSummary(_ context.Context, allowList []string) string {
|
||||
m.capturedAllow = allowList
|
||||
return m.summary
|
||||
}
|
||||
|
||||
// mockSkillAccessStore returns canned skill access lists.
|
||||
type mockSkillAccessStore struct {
|
||||
accessible []store.SkillInfo
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockSkillAccessStore) ListAccessible(_ context.Context, _ uuid.UUID, _ string) ([]store.SkillInfo, error) {
|
||||
return m.accessible, m.err
|
||||
}
|
||||
@@ -359,6 +359,8 @@ type GatewayConfig struct {
|
||||
BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false)
|
||||
ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true)
|
||||
TaskRecoveryIntervalSec int `json:"task_recovery_interval_sec,omitempty"` // team task recovery ticker interval in seconds (default 300 = 5min)
|
||||
BackgroundProvider string `json:"background_provider,omitempty"` // LLM provider for background workers (vault enrichment, consolidation)
|
||||
BackgroundModel string `json:"background_model,omitempty"` // LLM model for background workers
|
||||
}
|
||||
|
||||
// ToolsConfig controls tool availability, policy, and web search.
|
||||
|
||||
@@ -52,6 +52,10 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) {
|
||||
boolean("gateway.tool_status", &c.Gateway.ToolStatus)
|
||||
integer("gateway.task_recovery_interval_sec", &c.Gateway.TaskRecoveryIntervalSec)
|
||||
|
||||
// Background workers (vault enrichment, consolidation)
|
||||
str("background.provider", &c.Gateway.BackgroundProvider)
|
||||
str("background.model", &c.Gateway.BackgroundModel)
|
||||
|
||||
// Tools
|
||||
str("tools.profile", &c.Tools.Profile)
|
||||
integer("tools.rate_limit_per_hour", &c.Tools.RateLimitPerHour)
|
||||
|
||||
+16
-8
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
@@ -32,8 +33,9 @@ type AgentsHandler struct {
|
||||
kgStore store.KnowledgeGraphStore // for import (nil = disabled)
|
||||
episodicStore store.EpisodicStore // for import (nil in SQLite/lite builds)
|
||||
vaultStore store.VaultStore // for vault import (nil = disabled)
|
||||
toolsReg ToolLister // for system prompt preview tool resolution (nil = fallback)
|
||||
skillsLoader SkillPinnedBuilder // for system prompt preview pinned skills (nil = skip)
|
||||
toolsReg ToolPreviewLister // for system prompt preview tool resolution (nil = fallback)
|
||||
skillsLoader SkillPreviewBuilder // for system prompt preview pinned skills (nil = skip)
|
||||
skillAccessStore store.SkillAccessStore // for system prompt preview skill filtering (nil = skip)
|
||||
teamStore store.TeamStore // for system prompt preview team context (nil = skip)
|
||||
agentLinkStore store.AgentLinkStore // for system prompt preview delegation targets (nil = skip)
|
||||
defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace")
|
||||
@@ -82,24 +84,30 @@ func (h *AgentsHandler) SetVaultStore(vs store.VaultStore) {
|
||||
h.vaultStore = vs
|
||||
}
|
||||
|
||||
// ToolLister is satisfied by tools.Registry for system prompt preview.
|
||||
type ToolLister interface{ List() []string }
|
||||
// ToolPreviewLister is satisfied by tools.Registry for system prompt preview.
|
||||
type ToolPreviewLister interface {
|
||||
List() []string
|
||||
Get(name string) (tools.Tool, bool)
|
||||
Aliases() map[string]string
|
||||
}
|
||||
|
||||
// SkillPinnedBuilder is satisfied by skills.Loader for pinned skills summary.
|
||||
type SkillPinnedBuilder interface {
|
||||
// SkillPreviewBuilder is satisfied by skills.Loader for system prompt preview.
|
||||
type SkillPreviewBuilder interface {
|
||||
BuildPinnedSummary(ctx context.Context, names []string) string
|
||||
BuildSummary(ctx context.Context, allowList []string) string
|
||||
}
|
||||
|
||||
// SetPreviewDeps attaches optional dependencies for system prompt preview.
|
||||
func (h *AgentsHandler) SetPreviewDeps(tl ToolLister, sl SkillPinnedBuilder) {
|
||||
func (h *AgentsHandler) SetPreviewDeps(tl ToolPreviewLister, sl SkillPreviewBuilder) {
|
||||
h.toolsReg = tl
|
||||
h.skillsLoader = sl
|
||||
}
|
||||
|
||||
// SetPreviewStores attaches team + agent link stores for system prompt preview.
|
||||
func (h *AgentsHandler) SetPreviewStores(ts store.TeamStore, als store.AgentLinkStore) {
|
||||
func (h *AgentsHandler) SetPreviewStores(ts store.TeamStore, als store.AgentLinkStore, sas store.SkillAccessStore) {
|
||||
h.teamStore = ts
|
||||
h.agentLinkStore = als
|
||||
h.skillAccessStore = sas
|
||||
}
|
||||
|
||||
// isOwnerUser checks if the given user ID is a system owner.
|
||||
|
||||
@@ -354,9 +354,10 @@ func (h *AgentsHandler) importVault(ctx context.Context, ag *store.AgentData, ar
|
||||
progressFn(ProgressEvent{Phase: "vault_documents", Status: "running", Total: len(arc.vaultDocuments)})
|
||||
}
|
||||
for _, d := range arc.vaultDocuments {
|
||||
agentIDStr := ag.ID.String()
|
||||
doc := &store.VaultDocument{
|
||||
TenantID: tid.String(),
|
||||
AgentID: ag.ID.String(),
|
||||
AgentID: &agentIDStr,
|
||||
TeamID: nil, // team_id not portable
|
||||
Scope: d.Scope,
|
||||
CustomScope: d.CustomScope,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
|
||||
)
|
||||
|
||||
@@ -18,10 +19,11 @@ type promptPreviewSection struct {
|
||||
|
||||
// promptPreviewResponse is the API response for system prompt preview.
|
||||
type promptPreviewResponse struct {
|
||||
Mode string `json:"mode"`
|
||||
Prompt string `json:"prompt"`
|
||||
TokenCount int `json:"token_count"`
|
||||
Sections []promptPreviewSection `json:"sections"`
|
||||
Mode string `json:"mode"`
|
||||
Prompt string `json:"prompt"`
|
||||
TokenCount int `json:"token_count"`
|
||||
Sections []promptPreviewSection `json:"sections"`
|
||||
Tools []providers.ToolDefinition `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// handleSystemPromptPreview renders the actual system prompt for an agent in a given mode.
|
||||
@@ -49,26 +51,28 @@ func (h *AgentsHandler) handleSystemPromptPreview(w http.ResponseWriter, r *http
|
||||
// Build preview prompt — reuses same BuildSystemPrompt() as LLM pipeline.
|
||||
// Runtime-only fields (channel, peer kind, credentials) are zero-valued;
|
||||
// BuildSystemPrompt nil-checks every field so these sections are simply skipped.
|
||||
prompt := agent.BuildPreviewPrompt(ctx, ag, mode, r.URL.Query().Get("user_id"), agent.PreviewDeps{
|
||||
AgentStore: h.agents,
|
||||
TeamStore: h.teamStore,
|
||||
AgentLinks: h.agentLinkStore,
|
||||
ProviderReg: h.providerReg,
|
||||
ToolLister: h.toolsReg,
|
||||
SkillsLoader: h.skillsLoader,
|
||||
DataDir: h.dataDir,
|
||||
result := agent.BuildPreviewPrompt(ctx, ag, mode, r.URL.Query().Get("user_id"), agent.PreviewDeps{
|
||||
AgentStore: h.agents,
|
||||
TeamStore: h.teamStore,
|
||||
AgentLinks: h.agentLinkStore,
|
||||
ProviderReg: h.providerReg,
|
||||
ToolLister: h.toolsReg,
|
||||
SkillsLoader: h.skillsLoader,
|
||||
SkillAccessStore: h.skillAccessStore,
|
||||
DataDir: h.dataDir,
|
||||
})
|
||||
|
||||
counter := tokencount.NewFallbackCounter()
|
||||
tokens := counter.Count("claude-3", prompt)
|
||||
sections := parseSections(prompt)
|
||||
tokens := counter.Count("claude-3", result.Prompt)
|
||||
sections := parseSections(result.Prompt)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(promptPreviewResponse{
|
||||
Mode: string(mode),
|
||||
Prompt: prompt,
|
||||
Prompt: result.Prompt,
|
||||
TokenCount: tokens,
|
||||
Sections: sections,
|
||||
Tools: result.ToolDefs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -23,17 +22,29 @@ type vaultDocListResponse struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// AgentLister is the subset of AgentStore needed by VaultHandler (rescan agent_key→UUID mapping).
|
||||
type AgentLister interface {
|
||||
List(ctx context.Context, ownerID string) ([]store.AgentData, error)
|
||||
}
|
||||
|
||||
// TeamLister is the subset of TeamStore needed by VaultHandler (rescan team validation).
|
||||
type TeamLister interface {
|
||||
ListTeams(ctx context.Context) ([]store.TeamData, error)
|
||||
}
|
||||
|
||||
// VaultHandler serves Knowledge Vault document and link endpoints.
|
||||
type VaultHandler struct {
|
||||
store store.VaultStore
|
||||
teamAccess store.TeamAccessStore // nil = skip team membership validation (e.g. lite edition)
|
||||
agents AgentLister // nil = rescan skips agent resolution
|
||||
teams TeamLister // nil = rescan skips team resolution
|
||||
workspace string
|
||||
eventBus eventbus.DomainEventBus
|
||||
rescanMu sync.Map // key: agentID → struct{}, per-agent concurrency guard
|
||||
rescanMu sync.Map // key: tenantID → struct{}, per-tenant concurrency guard
|
||||
}
|
||||
|
||||
func NewVaultHandler(s store.VaultStore, ta store.TeamAccessStore, workspace string, bus eventbus.DomainEventBus) *VaultHandler {
|
||||
return &VaultHandler{store: s, teamAccess: ta, workspace: workspace, eventBus: bus}
|
||||
func NewVaultHandler(s store.VaultStore, ta store.TeamAccessStore, workspace string, bus eventbus.DomainEventBus, agents AgentLister, teams TeamLister) *VaultHandler {
|
||||
return &VaultHandler{store: s, teamAccess: ta, agents: agents, teams: teams, workspace: workspace, eventBus: bus}
|
||||
}
|
||||
|
||||
// validateTeamMembership checks that the requesting user belongs to the given team.
|
||||
@@ -100,7 +111,8 @@ func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /v1/agents/{agentID}/vault/documents", h.auth(h.handleCreateDocument))
|
||||
mux.HandleFunc("PUT /v1/agents/{agentID}/vault/documents/{docID}", h.auth(h.handleUpdateDocument))
|
||||
mux.HandleFunc("DELETE /v1/agents/{agentID}/vault/documents/{docID}", h.auth(h.handleDeleteDocument))
|
||||
mux.HandleFunc("POST /v1/agents/{agentID}/vault/rescan", h.auth(h.handleRescan))
|
||||
mux.HandleFunc("POST /v1/vault/rescan", h.auth(h.handleRescan))
|
||||
mux.HandleFunc("POST /v1/vault/search", h.auth(h.handleSearchAll))
|
||||
mux.HandleFunc("POST /v1/agents/{agentID}/vault/search", h.auth(h.handleSearch))
|
||||
mux.HandleFunc("GET /v1/agents/{agentID}/vault/documents/{docID}/links", h.auth(h.handleGetLinks))
|
||||
mux.HandleFunc("POST /v1/agents/{agentID}/vault/links", h.auth(h.handleCreateLink))
|
||||
@@ -209,7 +221,7 @@ func (h *VaultHandler) handleGetDocument(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if doc == nil || doc.AgentID != agentID {
|
||||
if doc == nil || (doc.AgentID != nil && *doc.AgentID != agentID) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
@@ -222,14 +234,24 @@ func (h *VaultHandler) handleGetDocument(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// handleSearch runs hybrid FTS+vector search on vault documents.
|
||||
// handleSearchAll runs tenant-wide search (agent_id optional in body).
|
||||
func (h *VaultHandler) handleSearchAll(w http.ResponseWriter, r *http.Request) {
|
||||
h.doSearch(w, r, "")
|
||||
}
|
||||
|
||||
// handleSearch runs hybrid FTS+vector search scoped to a specific agent.
|
||||
func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
h.doSearch(w, r, r.PathValue("agentID"))
|
||||
}
|
||||
|
||||
// doSearch is the shared search implementation for both per-agent and tenant-wide endpoints.
|
||||
func (h *VaultHandler) doSearch(w http.ResponseWriter, r *http.Request, agentID string) {
|
||||
locale := extractLocale(r)
|
||||
tenantID := store.TenantIDFromContext(r.Context())
|
||||
agentID := r.PathValue("agentID")
|
||||
|
||||
var body struct {
|
||||
Query string `json:"query"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Scope string `json:"scope"`
|
||||
DocTypes []string `json:"doc_types"`
|
||||
MaxResults int `json:"max_results"`
|
||||
@@ -245,6 +267,10 @@ func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if body.MaxResults <= 0 {
|
||||
body.MaxResults = 10
|
||||
}
|
||||
// Body agent_id only used when path doesn't provide one (tenant-wide endpoint).
|
||||
if agentID == "" {
|
||||
agentID = body.AgentID
|
||||
}
|
||||
|
||||
searchOpts := store.VaultSearchOptions{
|
||||
Query: body.Query,
|
||||
@@ -374,7 +400,7 @@ func (h *VaultHandler) handleCreateDocument(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
doc := &store.VaultDocument{
|
||||
TenantID: tenantID.String(),
|
||||
AgentID: agentID,
|
||||
AgentID: &agentID,
|
||||
Path: body.Path,
|
||||
Title: body.Title,
|
||||
DocType: body.DocType,
|
||||
@@ -412,7 +438,7 @@ func (h *VaultHandler) handleUpdateDocument(w http.ResponseWriter, r *http.Reque
|
||||
docID := r.PathValue("docID")
|
||||
|
||||
existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID)
|
||||
if err != nil || existing == nil || existing.AgentID != agentID {
|
||||
if err != nil || existing == nil || (existing.AgentID != nil && *existing.AgentID != agentID) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
@@ -475,7 +501,7 @@ func (h *VaultHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Reque
|
||||
docID := r.PathValue("docID")
|
||||
|
||||
existing, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID)
|
||||
if err != nil || existing == nil || existing.AgentID != agentID {
|
||||
if err != nil || existing == nil || (existing.AgentID != nil && *existing.AgentID != agentID) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
@@ -489,7 +515,12 @@ func (h *VaultHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// DeleteDocument without RunContext applies no team_id filter (broad match on tenant+agent+path).
|
||||
// This is safe because we pre-validated team membership above and use server-derived existing.Path.
|
||||
if err := h.store.DeleteDocument(r.Context(), tenantID.String(), agentID, existing.Path); err != nil {
|
||||
// Use the doc's actual agent_id (may be empty for team/shared docs).
|
||||
deleteAgentID := ""
|
||||
if existing.AgentID != nil {
|
||||
deleteAgentID = *existing.AgentID
|
||||
}
|
||||
if err := h.store.DeleteDocument(r.Context(), tenantID.String(), deleteAgentID, existing.Path); err != nil {
|
||||
slog.Warn("vault.delete failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -527,7 +558,7 @@ func (h *VaultHandler) handleCreateLink(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "one or both documents not found"})
|
||||
return
|
||||
}
|
||||
if from.AgentID != agentID {
|
||||
if from.AgentID != nil && *from.AgentID != agentID {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "source document does not belong to this agent"})
|
||||
return
|
||||
}
|
||||
@@ -564,38 +595,38 @@ func (h *VaultHandler) handleDeleteLink(w http.ResponseWriter, r *http.Request)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRescan walks agent workspace and registers missing/changed files in vault.
|
||||
// handleRescan walks the entire tenant workspace and registers missing/changed files in vault.
|
||||
// Infers agent/team ownership from directory structure: agents/{key}/, teams/{uuid}/, or root shared.
|
||||
func (h *VaultHandler) handleRescan(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
if _, err := uuid.Parse(agentID); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
|
||||
return
|
||||
}
|
||||
tenantID := store.TenantIDFromContext(r.Context()).String()
|
||||
|
||||
// Per-agent concurrency guard: only one rescan at a time.
|
||||
if _, loaded := h.rescanMu.LoadOrStore(agentID, struct{}{}); loaded {
|
||||
// Per-tenant concurrency guard.
|
||||
if _, loaded := h.rescanMu.LoadOrStore(tenantID, struct{}{}); loaded {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "rescan already in progress"})
|
||||
return
|
||||
}
|
||||
defer h.rescanMu.Delete(agentID)
|
||||
defer h.rescanMu.Delete(tenantID)
|
||||
|
||||
// Resolve workspace path for this agent.
|
||||
wsPath := h.resolveAgentWorkspace(r.Context(), agentID)
|
||||
wsPath := h.resolveTenantWorkspace(r.Context())
|
||||
if wsPath == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build agent_key→UUID map and team UUID set for path inference.
|
||||
agentMap, teamSet := h.buildRescanMaps(r.Context())
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
result, err := vault.RescanWorkspace(ctx, vault.RescanParams{
|
||||
TenantID: store.TenantIDFromContext(r.Context()).String(),
|
||||
AgentID: agentID,
|
||||
TenantID: tenantID,
|
||||
Workspace: wsPath,
|
||||
AgentMap: agentMap,
|
||||
TeamSet: teamSet,
|
||||
}, h.store, h.eventBus)
|
||||
if err != nil {
|
||||
slog.Warn("vault.rescan failed", "agent", agentID, "error", err)
|
||||
slog.Warn("vault.rescan failed", "tenant", tenantID, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -603,16 +634,38 @@ func (h *VaultHandler) handleRescan(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// resolveAgentWorkspace returns the filesystem path to an agent's workspace.
|
||||
// Scopes by tenant to prevent cross-tenant file access.
|
||||
func (h *VaultHandler) resolveAgentWorkspace(ctx context.Context, agentID string) string {
|
||||
// resolveTenantWorkspace returns the tenant-scoped workspace root.
|
||||
func (h *VaultHandler) resolveTenantWorkspace(ctx context.Context) string {
|
||||
if h.workspace == "" {
|
||||
return ""
|
||||
}
|
||||
tenantID := store.TenantIDFromContext(ctx)
|
||||
slug := store.TenantSlugFromContext(ctx)
|
||||
ws := config.TenantWorkspace(h.workspace, tenantID, slug)
|
||||
return filepath.Join(ws, agentID)
|
||||
return config.TenantWorkspace(h.workspace, tenantID, slug)
|
||||
}
|
||||
|
||||
// buildRescanMaps pre-loads agent_key→UUID and team UUID sets for the current tenant.
|
||||
func (h *VaultHandler) buildRescanMaps(ctx context.Context) (map[string]string, map[string]bool) {
|
||||
agentMap := make(map[string]string)
|
||||
teamSet := make(map[string]bool)
|
||||
|
||||
if h.agents != nil {
|
||||
agents, err := h.agents.List(ctx, "")
|
||||
if err == nil {
|
||||
for _, a := range agents {
|
||||
agentMap[a.AgentKey] = a.ID.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.teams != nil {
|
||||
teams, err := h.teams.ListTeams(ctx)
|
||||
if err == nil {
|
||||
for _, t := range teams {
|
||||
teamSet[t.ID.String()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return agentMap, teamSet
|
||||
}
|
||||
|
||||
var allowedDocTypes = map[string]bool{"context": true, "memory": true, "note": true, "skill": true, "episodic": true, "media": true}
|
||||
|
||||
@@ -54,10 +54,19 @@ func (s *PGVaultStore) SetEmbeddingProvider(provider store.EmbeddingProvider) {
|
||||
|
||||
func (s *PGVaultStore) Close() error { return nil }
|
||||
|
||||
// optAgentUUID converts a nullable *string agent_id to *uuid.UUID for SQL.
|
||||
func optAgentUUID(agentID *string) *uuid.UUID {
|
||||
if agentID == nil || *agentID == "" {
|
||||
return nil
|
||||
}
|
||||
u := mustParseUUID(*agentID)
|
||||
return &u
|
||||
}
|
||||
|
||||
// UpsertDocument inserts or updates a vault document.
|
||||
func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocument) error {
|
||||
tid := mustParseUUID(doc.TenantID)
|
||||
aid := mustParseUUID(doc.AgentID)
|
||||
aid := optAgentUUID(doc.AgentID)
|
||||
now := time.Now().UTC()
|
||||
|
||||
meta, err := json.Marshal(doc.Metadata)
|
||||
@@ -91,7 +100,7 @@ func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocum
|
||||
INSERT INTO vault_documents
|
||||
(id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, embedding, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $14)
|
||||
ON CONFLICT (agent_id, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'), scope, path) DO UPDATE SET
|
||||
ON CONFLICT (tenant_id, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(team_id, '00000000-0000-0000-0000-000000000000'::uuid), scope, path) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
doc_type = EXCLUDED.doc_type,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
@@ -112,15 +121,21 @@ func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocum
|
||||
}
|
||||
|
||||
// GetDocument retrieves a vault document by tenant, agent, and path.
|
||||
// Empty agentID means no agent filter (match any agent).
|
||||
// Team scoping via RunContext: present+TeamID → filter; present+empty → personal; nil → any match.
|
||||
func (s *PGVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path string) (*store.VaultDocument, error) {
|
||||
tid := mustParseUUID(tenantID)
|
||||
aid := mustParseUUID(agentID)
|
||||
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents WHERE tenant_id = $1 AND agent_id = $2 AND path = $3`
|
||||
args := []any{tid, aid, path}
|
||||
p := 4
|
||||
FROM vault_documents WHERE tenant_id = $1 AND path = $2`
|
||||
args := []any{tid, path}
|
||||
p := 3
|
||||
|
||||
if agentID != "" {
|
||||
q += fmt.Sprintf(" AND agent_id = $%d", p)
|
||||
args = append(args, mustParseUUID(agentID))
|
||||
p++
|
||||
}
|
||||
|
||||
if rc := store.RunContextFromCtx(ctx); rc != nil {
|
||||
if rc.TeamID != "" {
|
||||
@@ -162,14 +177,20 @@ func (s *PGVaultStore) GetDocumentByID(ctx context.Context, tenantID, id string)
|
||||
}
|
||||
|
||||
// DeleteDocument removes a vault document by tenant, agent, and path.
|
||||
// Empty agentID means no agent filter.
|
||||
// Team scoping via RunContext (same rules as GetDocument).
|
||||
func (s *PGVaultStore) DeleteDocument(ctx context.Context, tenantID, agentID, path string) error {
|
||||
tid := mustParseUUID(tenantID)
|
||||
aid := mustParseUUID(agentID)
|
||||
|
||||
q := `DELETE FROM vault_documents WHERE tenant_id = $1 AND agent_id = $2 AND path = $3`
|
||||
args := []any{tid, aid, path}
|
||||
p := 4
|
||||
q := `DELETE FROM vault_documents WHERE tenant_id = $1 AND path = $2`
|
||||
args := []any{tid, path}
|
||||
p := 3
|
||||
|
||||
if agentID != "" {
|
||||
q += fmt.Sprintf(" AND agent_id = $%d", p)
|
||||
args = append(args, mustParseUUID(agentID))
|
||||
p++
|
||||
}
|
||||
|
||||
if rc := store.RunContextFromCtx(ctx); rc != nil {
|
||||
if rc.TeamID != "" {
|
||||
@@ -287,7 +308,7 @@ func (s *PGVaultStore) UpdateHash(ctx context.Context, tenantID, id, newHash str
|
||||
// Search performs hybrid FTS + vector search on vault_documents.
|
||||
func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions) ([]store.VaultSearchResult, error) {
|
||||
tid := mustParseUUID(opts.TenantID)
|
||||
aid := mustParseUUID(opts.AgentID)
|
||||
aid := optAgentUUID(&opts.AgentID) // empty string → nil → no agent filter
|
||||
|
||||
// Build team filter for search sub-queries.
|
||||
tf := buildSearchTeamFilter(opts.TeamID, opts.TeamIDs)
|
||||
@@ -381,13 +402,19 @@ func (tf searchTeamFilter) append(q string, args []any, p int) (string, []any, i
|
||||
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) {
|
||||
func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uuid.UUID, 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
|
||||
WHERE tenant_id = $2 AND agent_id = $3 AND tsv @@ plainto_tsquery('simple', $1)`
|
||||
args := []any{query, tenantID, agentID}
|
||||
p := 4
|
||||
WHERE tenant_id = $2 AND tsv @@ plainto_tsquery('simple', $1)`
|
||||
args := []any{query, tenantID}
|
||||
p := 3
|
||||
|
||||
if agentID != nil {
|
||||
q += fmt.Sprintf(" AND agent_id = $%d", p)
|
||||
args = append(args, *agentID)
|
||||
p++
|
||||
}
|
||||
|
||||
q, args, p = tf.append(q, args, p)
|
||||
|
||||
@@ -412,14 +439,20 @@ 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, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
|
||||
func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID uuid.UUID, 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
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = $2 AND agent_id = $3 AND embedding IS NOT NULL`
|
||||
args := []any{vecStr, tenantID, agentID}
|
||||
p := 4
|
||||
WHERE tenant_id = $2 AND embedding IS NOT NULL`
|
||||
args := []any{vecStr, tenantID}
|
||||
p := 3
|
||||
|
||||
if agentID != nil {
|
||||
q += fmt.Sprintf(" AND agent_id = $%d", p)
|
||||
args = append(args, *agentID)
|
||||
p++
|
||||
}
|
||||
|
||||
q, args, p = tf.append(q, args, p)
|
||||
|
||||
|
||||
@@ -44,9 +44,10 @@ func (s *PGVaultStore) UpdateSummaryAndReembed(ctx context.Context, tenantID, do
|
||||
|
||||
// FindSimilarDocs finds documents with similar embeddings to the given docID.
|
||||
// Returns top-N neighbors excluding the source doc itself.
|
||||
// Empty agentID means no agent filter.
|
||||
func (s *PGVaultStore) FindSimilarDocs(ctx context.Context, tenantID, agentID, docID string, limit int) ([]store.VaultSearchResult, error) {
|
||||
tid := mustParseUUID(tenantID)
|
||||
aid := mustParseUUID(agentID)
|
||||
aid := optAgentUUID(&agentID)
|
||||
did := mustParseUUID(docID)
|
||||
|
||||
// Fetch source embedding.
|
||||
@@ -63,12 +64,20 @@ func (s *PGVaultStore) FindSimilarDocs(ctx context.Context, tenantID, agentID, d
|
||||
content_hash, summary, metadata, created_at, updated_at,
|
||||
1 - (embedding <=> $1::vector) AS score
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = $2 AND agent_id = $3 AND id != $4 AND embedding IS NOT NULL
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $5`
|
||||
WHERE tenant_id = $2 AND id != $3 AND embedding IS NOT NULL`
|
||||
args := []any{*embStr, tid, did}
|
||||
p := 4
|
||||
|
||||
if aid != nil {
|
||||
q += fmt.Sprintf(" AND agent_id = $%d", p)
|
||||
args = append(args, *aid)
|
||||
p++
|
||||
}
|
||||
q += fmt.Sprintf(" ORDER BY embedding <=> $1::vector LIMIT $%d", p)
|
||||
args = append(args, limit)
|
||||
|
||||
var scanned []vaultSearchRow
|
||||
if err := pkgSqlxDB.SelectContext(ctx, &scanned, q, *embStr, tid, aid, did, limit); err != nil {
|
||||
if err := pkgSqlxDB.SelectContext(ctx, &scanned, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("vault.find_similar: %w", err)
|
||||
}
|
||||
return vaultSearchRowsToResults(scanned, "vault"), nil
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
type vaultDocRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
TenantID uuid.UUID `db:"tenant_id"`
|
||||
AgentID uuid.UUID `db:"agent_id"`
|
||||
AgentID *uuid.UUID `db:"agent_id"`
|
||||
TeamID *uuid.UUID `db:"team_id"`
|
||||
Scope string `db:"scope"`
|
||||
CustomScope *string `db:"custom_scope"`
|
||||
@@ -33,7 +33,6 @@ func (r *vaultDocRow) toVaultDocument() store.VaultDocument {
|
||||
doc := store.VaultDocument{
|
||||
ID: r.ID.String(),
|
||||
TenantID: r.TenantID.String(),
|
||||
AgentID: r.AgentID.String(),
|
||||
Scope: r.Scope,
|
||||
CustomScope: r.CustomScope,
|
||||
Path: r.Path,
|
||||
@@ -44,6 +43,10 @@ func (r *vaultDocRow) toVaultDocument() store.VaultDocument {
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
if r.AgentID != nil {
|
||||
s := r.AgentID.String()
|
||||
doc.AgentID = &s
|
||||
}
|
||||
if r.TeamID != nil {
|
||||
s := r.TeamID.String()
|
||||
doc.TeamID = &s
|
||||
|
||||
@@ -15,7 +15,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 13
|
||||
const SchemaVersion = 14
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -374,6 +374,36 @@ ALTER TABLE episodic_summaries ADD COLUMN recall_score REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE episodic_summaries ADD COLUMN last_recalled_at TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_episodic_recall_unpromoted ON episodic_summaries(agent_id, user_id, recall_score DESC)
|
||||
WHERE promoted_at IS NULL;`,
|
||||
|
||||
// Version 13 → 14: vault_documents agent_id nullable + unique index with tenant_id.
|
||||
// SQLite requires table recreation to drop NOT NULL. Preserve all data.
|
||||
13: `CREATE TABLE vault_documents_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||||
team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'personal',
|
||||
custom_scope TEXT,
|
||||
path TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
doc_type TEXT NOT NULL DEFAULT 'note',
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
INSERT INTO vault_documents_new SELECT * FROM vault_documents;
|
||||
DROP TABLE vault_documents;
|
||||
ALTER TABLE vault_documents_new RENAME TO vault_documents;
|
||||
DROP INDEX IF EXISTS idx_vault_docs_unique_path;
|
||||
CREATE UNIQUE INDEX idx_vault_docs_unique_path
|
||||
ON vault_documents(tenant_id, COALESCE(agent_id, ''), COALESCE(team_id, ''), scope, path);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_tenant ON vault_documents(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_agent_scope ON vault_documents(agent_id, scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_type);
|
||||
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);`,
|
||||
}
|
||||
|
||||
// EnsureSchema creates tables if they don't exist and applies incremental migrations.
|
||||
|
||||
@@ -1516,7 +1516,7 @@ CREATE INDEX IF NOT EXISTS idx_scuc_binary ON secure_cli_user_credentials(binary
|
||||
CREATE TABLE IF NOT EXISTS vault_documents (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||||
team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'personal',
|
||||
custom_scope TEXT,
|
||||
@@ -1531,7 +1531,7 @@ CREATE TABLE IF NOT EXISTS vault_documents (
|
||||
);
|
||||
-- SQLite prohibits expressions in inline UNIQUE constraints; use a unique index instead.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_docs_unique_path
|
||||
ON vault_documents(agent_id, COALESCE(team_id, ''), scope, path);
|
||||
ON vault_documents(tenant_id, COALESCE(agent_id, ''), COALESCE(team_id, ''), scope, path);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_tenant ON vault_documents(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_agent_scope ON vault_documents(agent_id, scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_type);
|
||||
|
||||
@@ -58,11 +58,16 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
|
||||
meta = []byte("{}")
|
||||
}
|
||||
|
||||
// Convert nullable *string AgentID to nil for SQL.
|
||||
var agentIDVal any
|
||||
if doc.AgentID != nil && *doc.AgentID != "" {
|
||||
agentIDVal = *doc.AgentID
|
||||
}
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO vault_documents
|
||||
(id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (agent_id, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'), scope, path) DO UPDATE SET
|
||||
ON CONFLICT (tenant_id, COALESCE(agent_id,''), COALESCE(team_id,''), scope, path) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
doc_type = excluded.doc_type,
|
||||
content_hash = excluded.content_hash,
|
||||
@@ -71,7 +76,7 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
|
||||
tenant_id = excluded.tenant_id,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id`,
|
||||
id, doc.TenantID, doc.AgentID, doc.TeamID, doc.Scope, doc.CustomScope,
|
||||
id, doc.TenantID, agentIDVal, doc.TeamID, doc.Scope, doc.CustomScope,
|
||||
doc.Path, doc.Title, doc.DocType, doc.ContentHash, doc.Summary, string(meta), now, now,
|
||||
).Scan(&doc.ID)
|
||||
if err != nil {
|
||||
@@ -81,11 +86,17 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
|
||||
}
|
||||
|
||||
// GetDocument retrieves a vault document by tenant, agent, and path.
|
||||
// Empty agentID means no agent filter.
|
||||
// Team scoping via RunContext: present+TeamID → filter; present+empty → personal; nil → any match.
|
||||
func (s *SQLiteVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path string) (*store.VaultDocument, 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
|
||||
FROM vault_documents WHERE tenant_id = ? AND agent_id = ? AND path = ?`
|
||||
args := []any{tenantID, agentID, path}
|
||||
FROM vault_documents WHERE tenant_id = ? AND path = ?`
|
||||
args := []any{tenantID, path}
|
||||
|
||||
if agentID != "" {
|
||||
q += " AND agent_id = ?"
|
||||
args = append(args, agentID)
|
||||
}
|
||||
|
||||
if rc := store.RunContextFromCtx(ctx); rc != nil {
|
||||
if rc.TeamID != "" {
|
||||
@@ -109,10 +120,16 @@ func (s *SQLiteVaultStore) GetDocumentByID(ctx context.Context, tenantID, id str
|
||||
}
|
||||
|
||||
// DeleteDocument removes a vault document (FK cascades delete vault_links).
|
||||
// Empty agentID means no agent filter.
|
||||
// Team scoping via RunContext (same rules as GetDocument).
|
||||
func (s *SQLiteVaultStore) DeleteDocument(ctx context.Context, tenantID, agentID, path string) error {
|
||||
q := `DELETE FROM vault_documents WHERE tenant_id = ? AND agent_id = ? AND path = ?`
|
||||
args := []any{tenantID, agentID, path}
|
||||
q := `DELETE FROM vault_documents WHERE tenant_id = ? AND path = ?`
|
||||
args := []any{tenantID, path}
|
||||
|
||||
if agentID != "" {
|
||||
q += " AND agent_id = ?"
|
||||
args = append(args, agentID)
|
||||
}
|
||||
|
||||
if rc := store.RunContextFromCtx(ctx); rc != nil {
|
||||
if rc.TeamID != "" {
|
||||
@@ -249,9 +266,14 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt
|
||||
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = ? AND agent_id = ?
|
||||
WHERE tenant_id = ?
|
||||
AND (title LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\')`
|
||||
args := []any{opts.TenantID, opts.AgentID, pattern, pattern}
|
||||
args := []any{opts.TenantID, pattern, pattern}
|
||||
|
||||
if opts.AgentID != "" {
|
||||
q += " AND agent_id = ?"
|
||||
args = append(args, opts.AgentID)
|
||||
}
|
||||
|
||||
q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs)
|
||||
|
||||
@@ -304,12 +326,14 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt
|
||||
func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) {
|
||||
var doc store.VaultDocument
|
||||
var meta []byte
|
||||
var agentID *string
|
||||
ca, ua := &sqliteTime{}, &sqliteTime{}
|
||||
err := row.Scan(&doc.ID, &doc.TenantID, &doc.AgentID, &doc.TeamID, &doc.Scope, &doc.CustomScope,
|
||||
err := row.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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.AgentID = agentID
|
||||
doc.CreatedAt = ca.Time
|
||||
doc.UpdatedAt = ua.Time
|
||||
if len(meta) > 2 {
|
||||
@@ -321,12 +345,14 @@ func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) {
|
||||
func scanVaultDocRow(rows *sql.Rows) (*store.VaultDocument, error) {
|
||||
var doc store.VaultDocument
|
||||
var meta []byte
|
||||
var agentID *string
|
||||
ca, ua := &sqliteTime{}, &sqliteTime{}
|
||||
err := rows.Scan(&doc.ID, &doc.TenantID, &doc.AgentID, &doc.TeamID, &doc.Scope, &doc.CustomScope,
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.AgentID = agentID
|
||||
doc.CreatedAt = ca.Time
|
||||
doc.UpdatedAt = ua.Time
|
||||
if len(meta) > 2 {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
type VaultDocument struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
AgentID string `json:"agent_id" db:"agent_id"`
|
||||
AgentID *string `json:"agent_id,omitempty" db:"agent_id"`
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
Scope string `json:"scope" db:"scope"` // personal, team, shared
|
||||
CustomScope *string `json:"custom_scope,omitempty" db:"custom_scope"`
|
||||
|
||||
@@ -25,14 +25,15 @@ func NewVaultInterceptor(vs store.VaultStore, workspace string, bus eventbus.Dom
|
||||
return &VaultInterceptor{vaultStore: vs, workspace: workspace, eventBus: bus}
|
||||
}
|
||||
|
||||
// inferScopeFromContext returns scope and team_id based on RunContext.
|
||||
// TeamID present → scope="team", teamID=&rc.TeamID. Absent → "personal", nil.
|
||||
func inferScopeFromContext(ctx context.Context) (scope string, teamID *string) {
|
||||
// inferScopeFromContext returns scope, team_id, and whether agent_id should be set.
|
||||
// TeamID present → scope="team", teamID=&rc.TeamID, agentOwned=false.
|
||||
// Absent → "personal", nil, agentOwned=true.
|
||||
func inferScopeFromContext(ctx context.Context) (scope string, teamID *string, agentOwned bool) {
|
||||
rc := store.RunContextFromCtx(ctx)
|
||||
if rc != nil && rc.TeamID != "" {
|
||||
return "team", &rc.TeamID
|
||||
return "team", &rc.TeamID, false
|
||||
}
|
||||
return "personal", nil
|
||||
return "personal", nil, true
|
||||
}
|
||||
|
||||
// AfterWrite registers or updates a vault document after a file write.
|
||||
@@ -58,11 +59,19 @@ func (v *VaultInterceptor) AfterWrite(ctx context.Context, resolvedPath, content
|
||||
hash := vault.ContentHash([]byte(content))
|
||||
title := vault.InferTitle(relPath)
|
||||
docType := vault.InferDocType(relPath)
|
||||
scope, teamID := inferScopeFromContext(ctx)
|
||||
scope, teamID, agentOwned := inferScopeFromContext(ctx)
|
||||
|
||||
// Team-scoped files belong to the team, not the creating agent.
|
||||
var agentIDPtr *string
|
||||
eventAgentID := ""
|
||||
if agentOwned {
|
||||
agentIDPtr = &agentID
|
||||
eventAgentID = agentID
|
||||
}
|
||||
|
||||
doc := &store.VaultDocument{
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
AgentID: agentIDPtr,
|
||||
TeamID: teamID,
|
||||
Scope: scope,
|
||||
Path: relPath,
|
||||
@@ -80,14 +89,14 @@ func (v *VaultInterceptor) AfterWrite(ctx context.Context, resolvedPath, content
|
||||
v.eventBus.Publish(eventbus.DomainEvent{
|
||||
ID: uuid.Must(uuid.NewV7()).String(),
|
||||
Type: eventbus.EventVaultDocUpserted,
|
||||
SourceID: doc.ID + ":" + hash, // unique per content version, avoids bus-level dedup suppression
|
||||
SourceID: doc.ID + ":" + hash,
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
AgentID: eventAgentID,
|
||||
Timestamp: time.Now(),
|
||||
Payload: eventbus.VaultDocUpsertedPayload{
|
||||
DocID: doc.ID,
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
AgentID: eventAgentID,
|
||||
Path: relPath,
|
||||
ContentHash: hash,
|
||||
Workspace: v.workspace,
|
||||
@@ -124,11 +133,18 @@ func (v *VaultInterceptor) AfterWriteMedia(ctx context.Context, resolvedPath, su
|
||||
}
|
||||
|
||||
title := vault.InferTitle(relPath)
|
||||
scope, teamID := inferScopeFromContext(ctx)
|
||||
scope, teamID, agentOwned := inferScopeFromContext(ctx)
|
||||
|
||||
var agentIDPtr *string
|
||||
eventAgentID := ""
|
||||
if agentOwned {
|
||||
agentIDPtr = &agentID
|
||||
eventAgentID = agentID
|
||||
}
|
||||
|
||||
doc := &store.VaultDocument{
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
AgentID: agentIDPtr,
|
||||
TeamID: teamID,
|
||||
Scope: scope,
|
||||
Path: relPath,
|
||||
@@ -150,12 +166,12 @@ func (v *VaultInterceptor) AfterWriteMedia(ctx context.Context, resolvedPath, su
|
||||
Type: eventbus.EventVaultDocUpserted,
|
||||
SourceID: doc.ID + ":" + hash,
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
AgentID: eventAgentID,
|
||||
Timestamp: time.Now(),
|
||||
Payload: eventbus.VaultDocUpsertedPayload{
|
||||
DocID: doc.ID,
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
AgentID: eventAgentID,
|
||||
Path: relPath,
|
||||
ContentHash: hash,
|
||||
Workspace: v.workspace,
|
||||
@@ -183,7 +199,11 @@ func (v *VaultInterceptor) BeforeRead(ctx context.Context, resolvedPath string)
|
||||
return
|
||||
}
|
||||
|
||||
// Try agent-scoped first, then tenant-wide (team/shared docs have no agent_id).
|
||||
doc, err := v.vaultStore.GetDocument(ctx, tenantID, agentID, relPath)
|
||||
if err != nil {
|
||||
doc, err = v.vaultStore.GetDocument(ctx, tenantID, "", relPath)
|
||||
}
|
||||
if err != nil {
|
||||
return // not registered yet — skip
|
||||
}
|
||||
|
||||
@@ -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 = 45
|
||||
const RequiredSchemaVersion uint = 46
|
||||
@@ -74,7 +74,13 @@ func (w *enrichWorker) Handle(ctx context.Context, event eventbus.DomainEvent) e
|
||||
}
|
||||
w.dedupMu.Unlock()
|
||||
|
||||
key := payload.TenantID + ":" + payload.AgentID
|
||||
// Batch key: tenant + agent for agent-scoped docs. For team/shared docs
|
||||
// (empty AgentID), use tenant + docID to avoid collapsing all into one queue.
|
||||
batchScope := payload.AgentID
|
||||
if batchScope == "" {
|
||||
batchScope = payload.DocID
|
||||
}
|
||||
key := payload.TenantID + ":" + batchScope
|
||||
if !w.queue.Enqueue(key, payload) {
|
||||
return nil // another goroutine already processing this agent's queue
|
||||
}
|
||||
@@ -145,7 +151,6 @@ func (w *enrichWorker) processBatch(ctx context.Context, key string) {
|
||||
}
|
||||
|
||||
// Phase 3 — Classify links (replaces autoLink).
|
||||
// All items share same tenantID:agentID (batch queue key guarantees this).
|
||||
if len(embedded) > 0 {
|
||||
first := embedded[0].payload
|
||||
w.classifyLinks(ctx, first.TenantID, first.AgentID, embedded)
|
||||
|
||||
+75
-29
@@ -12,11 +12,13 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// RescanParams holds input for workspace rescan.
|
||||
// RescanParams holds input for tenant-wide workspace rescan.
|
||||
// AgentMap and TeamSet are pre-loaded by the caller to avoid per-file DB lookups.
|
||||
type RescanParams struct {
|
||||
TenantID string
|
||||
AgentID string
|
||||
Workspace string // absolute path to agent's workspace root
|
||||
Workspace string // absolute path to tenant's workspace root
|
||||
AgentMap map[string]string // agent_key → agent UUID
|
||||
TeamSet map[string]bool // team UUID → exists (for validation)
|
||||
}
|
||||
|
||||
// RescanResult holds the outcome of a workspace rescan.
|
||||
@@ -30,9 +32,10 @@ type RescanResult struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// RescanWorkspace walks the agent workspace and registers missing or changed
|
||||
// files in vault_documents. Publishes EventVaultDocUpserted for each new or
|
||||
// updated file so the enrichment worker can process them asynchronously.
|
||||
// RescanWorkspace walks the tenant workspace and registers missing or changed
|
||||
// files in vault_documents. Ownership (agent/team/scope) is inferred from path.
|
||||
// Publishes EventVaultDocUpserted for each new or updated file so the
|
||||
// enrichment worker can process them asynchronously.
|
||||
func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultStore, bus eventbus.DomainEventBus) (*RescanResult, error) {
|
||||
entries, walkStats, err := SafeWalkWorkspace(ctx, params.Workspace, DefaultWalkOptions())
|
||||
if err != nil {
|
||||
@@ -46,28 +49,40 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
agentID, teamID, scope, strippedPath := inferOwnerFromPath(entry.RelPath, params.AgentMap, params.TeamSet)
|
||||
if scope == "" {
|
||||
// Unknown agent key or invalid team UUID — skip.
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
hash, hashErr := ContentHashFile(entry.AbsPath)
|
||||
if hashErr != nil {
|
||||
result.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve the agent ID string for store lookup (empty string = no agent filter).
|
||||
agentIDStr := ""
|
||||
if agentID != nil {
|
||||
agentIDStr = *agentID
|
||||
}
|
||||
|
||||
// Check if document already exists with same hash.
|
||||
existing, _ := vs.GetDocument(ctx, params.TenantID, params.AgentID, entry.RelPath)
|
||||
existing, _ := vs.GetDocument(ctx, params.TenantID, agentIDStr, strippedPath)
|
||||
if existing != nil && existing.ContentHash == hash {
|
||||
result.Unchanged++
|
||||
continue
|
||||
}
|
||||
|
||||
scope, teamID := inferScopeFromPath(entry.RelPath)
|
||||
doc := &store.VaultDocument{
|
||||
TenantID: params.TenantID,
|
||||
AgentID: params.AgentID,
|
||||
AgentID: agentID,
|
||||
TeamID: teamID,
|
||||
Scope: scope,
|
||||
Path: entry.RelPath,
|
||||
Title: InferTitle(entry.RelPath),
|
||||
DocType: InferDocType(entry.RelPath),
|
||||
Path: strippedPath,
|
||||
Title: InferTitle(strippedPath),
|
||||
DocType: InferDocType(strippedPath),
|
||||
ContentHash: hash,
|
||||
}
|
||||
|
||||
@@ -83,20 +98,20 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto
|
||||
result.New++
|
||||
}
|
||||
|
||||
// Publish enrichment event.
|
||||
// Publish enrichment event. AgentID in payload stays string for serialization.
|
||||
if bus != nil {
|
||||
bus.Publish(eventbus.DomainEvent{
|
||||
ID: uuid.Must(uuid.NewV7()).String(),
|
||||
Type: eventbus.EventVaultDocUpserted,
|
||||
SourceID: doc.ID + ":" + hash,
|
||||
TenantID: params.TenantID,
|
||||
AgentID: params.AgentID,
|
||||
AgentID: agentIDStr,
|
||||
Timestamp: time.Now(),
|
||||
Payload: eventbus.VaultDocUpsertedPayload{
|
||||
DocID: doc.ID,
|
||||
TenantID: params.TenantID,
|
||||
AgentID: params.AgentID,
|
||||
Path: entry.RelPath,
|
||||
AgentID: agentIDStr,
|
||||
Path: strippedPath,
|
||||
ContentHash: hash,
|
||||
Workspace: params.Workspace,
|
||||
},
|
||||
@@ -104,26 +119,57 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("vault.rescan", "agent", params.AgentID,
|
||||
slog.Info("vault.rescan",
|
||||
"tenant", params.TenantID,
|
||||
"scanned", result.Scanned, "new", result.New,
|
||||
"updated", result.Updated, "unchanged", result.Unchanged,
|
||||
"errors", result.Errors, "truncated", result.Truncated)
|
||||
"skipped", result.Skipped, "errors", result.Errors,
|
||||
"truncated", result.Truncated)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// inferScopeFromPath detects scope and team from workspace-relative path.
|
||||
// Paths starting with "teams/{id}/" are team-scoped; everything else is personal.
|
||||
func inferScopeFromPath(relPath string) (scope string, teamID *string) {
|
||||
if !strings.HasPrefix(relPath, "teams/") {
|
||||
return "personal", nil
|
||||
// inferOwnerFromPath parses a tenant-relative path to determine ownership.
|
||||
// Returns: agentID (*string), teamID (*string), scope (string), strippedPath (string).
|
||||
//
|
||||
// Path patterns:
|
||||
//
|
||||
// agents/{agent_key}/rest/of/path → agentID=lookup(key), scope="personal", path="rest/of/path"
|
||||
// teams/{team_uuid}/rest/of/path → teamID=uuid, scope="team", path="rest/of/path"
|
||||
// anything/else → scope="shared", path unchanged
|
||||
//
|
||||
// Returns scope="" to signal the file should be skipped (unknown agent or invalid team).
|
||||
func inferOwnerFromPath(relPath string, agentMap map[string]string, teamSet map[string]bool) (agentID *string, teamID *string, scope string, strippedPath string) {
|
||||
switch {
|
||||
case strings.HasPrefix(relPath, "agents/"):
|
||||
rest := relPath[len("agents/"):]
|
||||
key, remainder, hasSlash := strings.Cut(rest, "/")
|
||||
if !hasSlash || key == "" || strings.Contains(remainder, "..") {
|
||||
return nil, nil, "", relPath // malformed or path traversal
|
||||
}
|
||||
agentUUID, ok := agentMap[key]
|
||||
if !ok {
|
||||
return nil, nil, "", relPath // unknown agent key → skip
|
||||
}
|
||||
return &agentUUID, nil, "personal", remainder
|
||||
|
||||
case strings.HasPrefix(relPath, "teams/"):
|
||||
rest := relPath[len("teams/"):]
|
||||
id, remainder, hasSlash := strings.Cut(rest, "/")
|
||||
if !hasSlash || id == "" || strings.Contains(remainder, "..") {
|
||||
return nil, nil, "", relPath // malformed or path traversal
|
||||
}
|
||||
if _, parseErr := uuid.Parse(id); parseErr != nil {
|
||||
return nil, nil, "", relPath // not a valid UUID → skip
|
||||
}
|
||||
if !teamSet[id] {
|
||||
return nil, nil, "", relPath // team not found → skip
|
||||
}
|
||||
return nil, &id, "team", remainder
|
||||
|
||||
default:
|
||||
return nil, nil, "shared", relPath
|
||||
}
|
||||
rest := relPath[len("teams/"):]
|
||||
id, _, hasSlash := strings.Cut(rest, "/")
|
||||
if !hasSlash || id == "" {
|
||||
return "personal", nil
|
||||
}
|
||||
return "team", &id
|
||||
}
|
||||
|
||||
// InferDocType guesses doc_type from path conventions.
|
||||
|
||||
+103
-23
@@ -2,34 +2,112 @@ package vault
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInferScopeFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
scope string
|
||||
hasTeam bool
|
||||
teamID string
|
||||
}{
|
||||
{"notes/doc.md", "personal", false, ""},
|
||||
{"web-fetch/page.txt", "personal", false, ""},
|
||||
{"report.md", "personal", false, ""},
|
||||
{"teams/abc-def-123/report.md", "team", true, "abc-def-123"},
|
||||
{"teams/abc-def-123/deep/nested.md", "team", true, "abc-def-123"},
|
||||
{"teams/", "personal", false, ""}, // malformed, no team ID
|
||||
{"teams", "personal", false, ""}, // no slash
|
||||
{"teamsfoo/bar.md", "personal", false, ""}, // not teams/ prefix
|
||||
{"telegram/123/teams/x/y.md", "personal", false, ""}, // teams not at root
|
||||
// TestInferOwnerFromPath covers the new tenant-wide path parser.
|
||||
func TestInferOwnerFromPath(t *testing.T) {
|
||||
agentMap := map[string]string{
|
||||
"my-bot": "uuid-1",
|
||||
"other-bot": "uuid-2",
|
||||
}
|
||||
validUUID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
teamSet := map[string]bool{
|
||||
validUUID: true,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantAgentID *string
|
||||
wantTeamID *string
|
||||
wantScope string
|
||||
wantStrippedPath string
|
||||
}{
|
||||
// agents/{key}/... → personal scope, strip prefix
|
||||
{
|
||||
path: "agents/my-bot/notes/todo.md",
|
||||
wantAgentID: strPtr("uuid-1"),
|
||||
wantScope: "personal",
|
||||
wantStrippedPath: "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",
|
||||
},
|
||||
// teams/{uuid}/... → team scope, strip prefix
|
||||
{
|
||||
path: "teams/" + validUUID + "/doc.md",
|
||||
wantTeamID: strPtr(validUUID),
|
||||
wantScope: "team",
|
||||
wantStrippedPath: "doc.md",
|
||||
},
|
||||
// teams/{uuid}/deep/nested → team scope
|
||||
{
|
||||
path: "teams/" + validUUID + "/deep/nested.md",
|
||||
wantTeamID: strPtr(validUUID),
|
||||
wantScope: "team",
|
||||
wantStrippedPath: "deep/nested.md",
|
||||
},
|
||||
// root-level file → shared scope, path unchanged
|
||||
{
|
||||
path: "README.md",
|
||||
wantScope: "shared",
|
||||
wantStrippedPath: "README.md",
|
||||
},
|
||||
// nested file not under agents/ or teams/ → shared
|
||||
{
|
||||
path: "docs/guide.md",
|
||||
wantScope: "shared",
|
||||
wantStrippedPath: "docs/guide.md",
|
||||
},
|
||||
// unknown agent → skip (scope="")
|
||||
{
|
||||
path: "agents/unknown-bot/file.md",
|
||||
wantScope: "",
|
||||
},
|
||||
// invalid team UUID → skip
|
||||
{
|
||||
path: "teams/not-a-uuid/file.md",
|
||||
wantScope: "",
|
||||
},
|
||||
// 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
|
||||
{
|
||||
path: "agents/unknown/",
|
||||
wantScope: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
scope, teamID := inferScopeFromPath(tt.path)
|
||||
if scope != tt.scope {
|
||||
t.Errorf("scope = %q, want %q", scope, tt.scope)
|
||||
gotAgentID, gotTeamID, gotScope, gotPath := inferOwnerFromPath(tt.path, agentMap, teamSet)
|
||||
|
||||
if gotScope != tt.wantScope {
|
||||
t.Errorf("scope = %q, want %q", gotScope, tt.wantScope)
|
||||
}
|
||||
if tt.hasTeam && (teamID == nil || *teamID != tt.teamID) {
|
||||
t.Errorf("teamID = %v, want %q", teamID, tt.teamID)
|
||||
if tt.wantScope == "" {
|
||||
return // skip is signaled; remaining fields don't matter
|
||||
}
|
||||
if !tt.hasTeam && teamID != nil {
|
||||
t.Errorf("teamID = %v, want nil", teamID)
|
||||
if tt.wantStrippedPath != "" && gotPath != tt.wantStrippedPath {
|
||||
t.Errorf("strippedPath = %q, want %q", gotPath, tt.wantStrippedPath)
|
||||
}
|
||||
if tt.wantAgentID != nil {
|
||||
if gotAgentID == nil || *gotAgentID != *tt.wantAgentID {
|
||||
t.Errorf("agentID = %v, want %q", gotAgentID, *tt.wantAgentID)
|
||||
}
|
||||
} else if gotAgentID != nil {
|
||||
t.Errorf("agentID = %v, want nil", gotAgentID)
|
||||
}
|
||||
if tt.wantTeamID != nil {
|
||||
if gotTeamID == nil || *gotTeamID != *tt.wantTeamID {
|
||||
t.Errorf("teamID = %v, want %q", gotTeamID, *tt.wantTeamID)
|
||||
}
|
||||
} else if gotTeamID != nil {
|
||||
t.Errorf("teamID = %v, want nil", gotTeamID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -78,3 +156,5 @@ func TestInferTitle(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
+23
-16
@@ -158,16 +158,23 @@ func SafeWalkWorkspace(ctx context.Context, root string, opts WalkOptions) ([]Wa
|
||||
}
|
||||
|
||||
// isExcludedDir returns true if an entire directory subtree should be skipped.
|
||||
// relPath is the walk-relative path of the directory (e.g. "agents/my-bot/web-fetch").
|
||||
func isExcludedDir(relPath string) bool {
|
||||
first, _, _ := strings.Cut(relPath, "/")
|
||||
// Get the directory's own name (last segment).
|
||||
dirName := filepath.Base(relPath)
|
||||
|
||||
// Skip memory/ subtree entirely.
|
||||
if first == "memory" {
|
||||
// Skip memory/ at any depth.
|
||||
if dirName == "memory" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip hidden dirs (. prefix) EXCEPT .uploads (user content).
|
||||
if strings.HasPrefix(first, ".") && first != ".uploads" {
|
||||
if strings.HasPrefix(dirName, ".") && dirName != ".uploads" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip web-fetch/ at any depth — content from external URLs may be dangerous.
|
||||
if dirName == "web-fetch" {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -189,19 +196,19 @@ var contextFiles = map[string]bool{
|
||||
}
|
||||
|
||||
// isExcludedPath returns true if a file should be excluded from vault registration.
|
||||
// Defense-in-depth: also checks parent directory exclusions for callers that bypass dir walk.
|
||||
// Defense-in-depth: checks ALL parent directory segments for exclusions.
|
||||
func isExcludedPath(relPath string) bool {
|
||||
// Check first path segment for excluded directories.
|
||||
first, _, _ := strings.Cut(relPath, "/")
|
||||
|
||||
// memory/ prefix.
|
||||
if first == "memory" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Hidden dirs (. prefix) except .uploads.
|
||||
if strings.HasPrefix(first, ".") && first != ".uploads" {
|
||||
return true
|
||||
// Check every directory segment in the path.
|
||||
dir := filepath.Dir(relPath)
|
||||
for dir != "." && dir != "/" {
|
||||
seg := filepath.Base(dir)
|
||||
if seg == "memory" || seg == "web-fetch" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(seg, ".") && seg != ".uploads" {
|
||||
return true
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
|
||||
base := filepath.Base(relPath)
|
||||
|
||||
@@ -73,9 +73,9 @@ func TestSafeWalkWorkspace_ExcludedPaths(t *testing.T) {
|
||||
writeFile(t, dir, "data.db", "x")
|
||||
writeFile(t, dir, "data.db-wal", "x")
|
||||
writeFile(t, dir, "data.db-shm", "x")
|
||||
writeFile(t, dir, "web-fetch/page.txt", "x") // excluded: external content
|
||||
// Should NOT be excluded
|
||||
writeFile(t, dir, "notes/meeting.md", "x")
|
||||
writeFile(t, dir, "web-fetch/page.txt", "x")
|
||||
writeFile(t, dir, ".uploads/photo.jpg", "x")
|
||||
writeFile(t, dir, "soul-notes.md", "x")
|
||||
writeFile(t, dir, "deep/SOUL.md", "x") // not root level
|
||||
@@ -85,7 +85,7 @@ func TestSafeWalkWorkspace_ExcludedPaths(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 6 {
|
||||
if len(entries) != 5 {
|
||||
names := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
names[i] = e.RelPath
|
||||
@@ -201,7 +201,8 @@ func TestIsExcludedPath(t *testing.T) {
|
||||
{"data.db-shm", true},
|
||||
// NOT excluded:
|
||||
{"notes/meeting.md", false},
|
||||
{"web-fetch/page.txt", false},
|
||||
{"web-fetch/page.txt", true},
|
||||
{"agents/my-bot/web-fetch/data.txt", true},
|
||||
{"images/screenshot.png", false},
|
||||
{"teams/abc-123/doc.md", false},
|
||||
{"soul-notes.md", false},
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Revert: delete orphaned docs, restore NOT NULL + CASCADE.
|
||||
DELETE FROM vault_documents WHERE agent_id IS NULL;
|
||||
|
||||
ALTER TABLE vault_documents ALTER COLUMN agent_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE vault_documents DROP CONSTRAINT vault_documents_agent_id_fkey;
|
||||
ALTER TABLE vault_documents ADD CONSTRAINT vault_documents_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
DROP INDEX IF EXISTS uq_vault_docs_agent_team_scope_path;
|
||||
CREATE UNIQUE INDEX uq_vault_docs_agent_team_scope_path
|
||||
ON vault_documents (agent_id, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'), scope, path);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vault_docs_agent_null_scope ON vault_documents;
|
||||
DROP FUNCTION IF EXISTS vault_docs_agent_null_scope_fix();
|
||||
|
||||
DROP INDEX IF EXISTS idx_vault_docs_agent_scope;
|
||||
CREATE INDEX idx_vault_docs_agent_scope ON vault_documents(agent_id, scope);
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Make agent_id nullable so team-scoped and tenant-shared files can exist
|
||||
-- without an owning agent.
|
||||
|
||||
-- 1. Drop NOT NULL on agent_id.
|
||||
ALTER TABLE vault_documents ALTER COLUMN agent_id DROP NOT NULL;
|
||||
|
||||
-- 2. Change FK from CASCADE to SET NULL (agent deletion preserves docs).
|
||||
ALTER TABLE vault_documents DROP CONSTRAINT vault_documents_agent_id_fkey;
|
||||
ALTER TABLE vault_documents ADD CONSTRAINT vault_documents_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
-- 3. Replace unique index: add tenant_id as leading column, COALESCE both nullable cols.
|
||||
DROP INDEX IF EXISTS uq_vault_docs_agent_team_scope_path;
|
||||
CREATE UNIQUE INDEX uq_vault_docs_agent_team_scope_path
|
||||
ON vault_documents (
|
||||
tenant_id,
|
||||
COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'),
|
||||
COALESCE(team_id, '00000000-0000-0000-0000-000000000000'),
|
||||
scope,
|
||||
path
|
||||
);
|
||||
|
||||
-- 4. Trigger: when agent deleted (SET NULL) and no team -> scope='shared'.
|
||||
CREATE OR REPLACE FUNCTION vault_docs_agent_null_scope_fix()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.agent_id IS NULL AND OLD.agent_id IS NOT NULL AND NEW.team_id IS NULL THEN
|
||||
NEW.scope := 'shared';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_vault_docs_agent_null_scope
|
||||
BEFORE UPDATE OF agent_id ON vault_documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION vault_docs_agent_null_scope_fix();
|
||||
|
||||
-- 5. Partial index for agent-scoped queries (skip NULLs).
|
||||
DROP INDEX IF EXISTS idx_vault_docs_agent_scope;
|
||||
CREATE INDEX idx_vault_docs_agent_scope ON vault_documents(agent_id, scope) WHERE agent_id IS NOT NULL;
|
||||
@@ -56,6 +56,8 @@ export function SigmaGraphContainer({
|
||||
}: SigmaGraphContainerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const internalSigmaRef = useRef<Sigma | null>(null);
|
||||
// Incremented when sigma instance changes — used to trigger event handler registration.
|
||||
const [sigmaVersion, setSigmaVersion] = useState(0);
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||
// Pulse phase for animated highlighted edges (0..1, cycles)
|
||||
const [pulsePhase, setPulsePhase] = useState(0);
|
||||
@@ -64,6 +66,7 @@ export function SigmaGraphContainer({
|
||||
const setSigmaRef = useCallback(
|
||||
(instance: Sigma | null) => {
|
||||
internalSigmaRef.current = instance;
|
||||
setSigmaVersion((v) => v + 1);
|
||||
onSigmaReady?.(instance);
|
||||
},
|
||||
[onSigmaReady],
|
||||
@@ -337,7 +340,15 @@ export function SigmaGraphContainer({
|
||||
sigma.refresh({ skipIndexation: true });
|
||||
}, [pulsePhase]);
|
||||
|
||||
// --- Event handlers: use Sigma v3 native doubleClickNode (snappier than 280ms timer) ---
|
||||
// --- Event handlers: use refs for values that change frequently to avoid
|
||||
// re-registering Sigma listeners (which drops in-flight double-click events) ---
|
||||
const selectedNodeIdRef = useRef(selectedNodeId);
|
||||
selectedNodeIdRef.current = selectedNodeId;
|
||||
const onNodeSelectRef = useRef(onNodeSelect);
|
||||
onNodeSelectRef.current = onNodeSelect;
|
||||
const onNodeDoubleClickRef = useRef(onNodeDoubleClick);
|
||||
onNodeDoubleClickRef.current = onNodeDoubleClick;
|
||||
|
||||
useEffect(() => {
|
||||
const sigma = internalSigmaRef.current;
|
||||
if (!sigma) return;
|
||||
@@ -353,17 +364,17 @@ export function SigmaGraphContainer({
|
||||
};
|
||||
|
||||
const handleClickNode = ({ node }: { node: string }) => {
|
||||
onNodeSelect?.(node === selectedNodeId ? null : node);
|
||||
onNodeSelectRef.current?.(node === selectedNodeIdRef.current ? null : node);
|
||||
};
|
||||
|
||||
const handleDoubleClickNode = ({ node, event }: { node: string; event: { preventSigmaDefault?: () => void } }) => {
|
||||
// Prevent Sigma's default zoom-in behavior on double-click
|
||||
event.preventSigmaDefault?.();
|
||||
onNodeDoubleClick?.(node);
|
||||
onNodeDoubleClickRef.current?.(node);
|
||||
};
|
||||
|
||||
const handleClickStage = () => {
|
||||
onNodeSelect?.(null);
|
||||
onNodeSelectRef.current?.(null);
|
||||
};
|
||||
|
||||
sigma.on("enterNode", handleEnterNode);
|
||||
@@ -379,7 +390,7 @@ export function SigmaGraphContainer({
|
||||
sigma.off("doubleClickNode", handleDoubleClickNode);
|
||||
sigma.off("clickStage", handleClickStage);
|
||||
};
|
||||
}, [selectedNodeId, onNodeSelect, onNodeDoubleClick]);
|
||||
}, [sigmaVersion]); // re-register only when sigma instance changes
|
||||
|
||||
// NOTE: Click on node NO LONGER moves camera.
|
||||
// Camera only animates for explicit user actions (search, fit-to-view, keyboard F).
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface InitState {
|
||||
kgProvider: string;
|
||||
kgModel: string;
|
||||
kgMinConfidence: string;
|
||||
bgProvider: string;
|
||||
bgModel: string;
|
||||
}
|
||||
|
||||
export const DEFAULTS: InitState = {
|
||||
@@ -51,6 +53,7 @@ export const DEFAULTS: InitState = {
|
||||
compProvider: "", compModel: "",
|
||||
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
|
||||
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
|
||||
bgProvider: "", bgModel: "",
|
||||
};
|
||||
|
||||
export function parseBool(v: string | undefined, fallback: boolean): boolean {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network } from "lucide-react";
|
||||
import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network, Cog } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -57,6 +57,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
const [kgModel, setKgModel] = useState("");
|
||||
const [kgMinConfidence, setKgMinConfidence] = useState("0.75");
|
||||
|
||||
// Background Workers
|
||||
const [bgProvider, setBgProvider] = useState("");
|
||||
const [bgModel, setBgModel] = useState("");
|
||||
|
||||
const applyConfigs = useCallback((
|
||||
configs: Record<string, string>,
|
||||
kgSettings?: { extraction_provider?: string; extraction_model?: string; min_confidence?: number },
|
||||
@@ -71,12 +75,14 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
compMaxTokens: configs["compaction.max_tokens"] ?? "",
|
||||
kgProvider: kgSettings?.extraction_provider ?? "", kgModel: kgSettings?.extraction_model ?? "",
|
||||
kgMinConfidence: String(kgSettings?.min_confidence ?? 0.75),
|
||||
bgProvider: configs["background.provider"] ?? "", bgModel: configs["background.model"] ?? "",
|
||||
};
|
||||
setInit(s);
|
||||
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
|
||||
setToolStatus(s.toolStatus); setBlockReply(s.blockReply); setIntentClassify(s.intentClassify);
|
||||
setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens);
|
||||
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
|
||||
setBgProvider(s.bgProvider); setBgModel(s.bgModel);
|
||||
resetEmb();
|
||||
}, [resetEmb]);
|
||||
|
||||
@@ -118,6 +124,8 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
if (compThreshold !== init.compThreshold) updates["compaction.threshold"] = compThreshold;
|
||||
if (compKeepRecent !== init.compKeepRecent) updates["compaction.keep_recent"] = compKeepRecent;
|
||||
if (compMaxTokens !== init.compMaxTokens) updates["compaction.max_tokens"] = compMaxTokens;
|
||||
if (bgProvider !== init.bgProvider) updates["background.provider"] = bgProvider;
|
||||
if (bgModel !== init.bgModel) updates["background.model"] = bgModel;
|
||||
for (const [key, value] of Object.entries(updates)) await http.put(`/v1/system-configs/${key}`, { value });
|
||||
const kgChanged = kgProvider !== init.kgProvider || kgModel !== init.kgModel || kgMinConfidence !== init.kgMinConfidence;
|
||||
if (kgChanged) {
|
||||
@@ -179,6 +187,20 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Background Workers */}
|
||||
<Card className="border-slate-200 dark:border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><Cog className="h-4 w-4 text-slate-500" />{t("bg.title")}</CardTitle>
|
||||
<CardDescription>{t("bg.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<ProviderModelSelect provider={bgProvider} onProviderChange={(v) => { setBgProvider(v); setBgModel(""); }} model={bgModel} onModelChange={setBgModel} allowEmpty providerLabel={t("bg.provider")} modelLabel={t("bg.model")} providerTip={t("bg.providerTip")} modelTip={t("bg.modelTip")} providerPlaceholder={t("bg.providerPlaceholder")} modelPlaceholder={t("bg.modelPlaceholder")} />
|
||||
<div className="flex items-start gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-950/30 dark:text-slate-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{t("bg.info")}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FeatureSwitchGroup title={t("ux.title")} description={t("ux.description")} items={uxItems} />
|
||||
|
||||
<SystemSettingsCompactionCard
|
||||
|
||||
@@ -273,7 +273,19 @@
|
||||
"confirmRollback": "Revert this applied suggestion?",
|
||||
"confirmDescription": "This action will update the suggestion status.",
|
||||
"notEnabled": "Evolution not enabled",
|
||||
"notEnabledHint": "Enable 'Evolution Metrics' in the Evolution section of the Agent tab to start recording metrics."
|
||||
"notEnabledHint": "Enable 'Evolution Metrics' in the Evolution section of the Agent tab to start recording metrics.",
|
||||
"colType": "Type",
|
||||
"colSuggestion": "Suggestion",
|
||||
"colStatus": "Status",
|
||||
"colCreated": "Created",
|
||||
"colActions": "Actions",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"confirming": "...",
|
||||
"successRate": "Success Rate",
|
||||
"usageRate": "Usage Rate",
|
||||
"tooltipCalls": "{{count}} calls, {{ms}}ms avg",
|
||||
"tooltipQueries": "{{count}} queries, score {{score}}"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
|
||||
@@ -53,6 +53,17 @@
|
||||
"minConfidenceHint": "Minimum confidence threshold (0–1) for extracted entities.",
|
||||
"info": "Automatically extracts entities and relationships when agents write to memory. Leave provider empty to disable."
|
||||
},
|
||||
"bg": {
|
||||
"title": "Background Workers",
|
||||
"description": "Provider and model for background tasks (vault enrichment, consolidation, dreaming).",
|
||||
"provider": "Provider",
|
||||
"model": "Model",
|
||||
"providerTip": "LLM provider for background tasks. Leave empty to use agent default.",
|
||||
"modelTip": "Model for background tasks. Leave empty to use provider default.",
|
||||
"providerPlaceholder": "(agent default)",
|
||||
"modelPlaceholder": "(default)",
|
||||
"info": "Used for vault enrichment (document summarization), consolidation (session summaries), and dreaming. Leave empty to fall back to the agent default provider."
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Pending Message Compaction",
|
||||
"description": "Summarize long pending message history to stay within context limits.",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"searchPlaceholder": "Search documents...",
|
||||
"filterAgent": "Agent",
|
||||
"allAgents": "All agents",
|
||||
"allTypes": "All",
|
||||
"noDocuments": "No documents in vault",
|
||||
"noResults": "No search results",
|
||||
"searchFailed": "Search failed",
|
||||
@@ -36,7 +37,8 @@
|
||||
"noLinks": "No links",
|
||||
"contentPreview": "Content Preview",
|
||||
"fileNotFound": "File not found or inaccessible",
|
||||
"emptyContent": "No content available"
|
||||
"emptyContent": "No content available",
|
||||
"binaryFile": "Binary file — preview not available"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Title",
|
||||
@@ -62,7 +64,8 @@
|
||||
"memory": "Memory",
|
||||
"note": "Note",
|
||||
"skill": "Skill",
|
||||
"episodic": "Episodic"
|
||||
"episodic": "Episodic",
|
||||
"media": "Media"
|
||||
},
|
||||
"rescanTooltip": "Rescan workspace",
|
||||
"rescanNew": "{{count}} new",
|
||||
|
||||
@@ -181,7 +181,46 @@
|
||||
"personality": "Cá tính Agent",
|
||||
"modelBudget": "Mô hình & Ngân sách",
|
||||
"skills": "Kỹ năng",
|
||||
"evolution": { "title": "Tiến hóa" },
|
||||
"evolution": {
|
||||
"title": "Tiến hóa",
|
||||
"metricsLabel": "Số liệu Tiến hóa",
|
||||
"metricsHint": "Ghi lại hiệu quả tool, chất lượng truy xuất và phản hồi phản hồi",
|
||||
"suggestionsLabel": "Đề xuất Tiến hóa",
|
||||
"suggestionsHint": "Tạo đề xuất cải thiện dựa trên dữ liệu từ số liệu đã ghi",
|
||||
"toolSuccess": "Tỉ lệ thành công Tool",
|
||||
"retrievalQuality": "Chất lượng truy xuất",
|
||||
"feedback": "Phản hồi",
|
||||
"noMetrics": "Chưa có số liệu. Số liệu sẽ xuất hiện khi agent xử lý yêu cầu.",
|
||||
"noSuggestions": "Chưa có đề xuất. Đề xuất được tạo bởi cron phân tích hàng ngày.",
|
||||
"suggestions": "Đề xuất",
|
||||
"guardrails": "Rào chắn thích nghi",
|
||||
"maxDelta": "Delta tối đa / chu kỳ",
|
||||
"minDataPoints": "Điểm dữ liệu tối thiểu",
|
||||
"rollbackDrop": "Rollback khi giảm",
|
||||
"lockedParams": "Tham số bị khóa",
|
||||
"timeRange": "Khoảng thời gian",
|
||||
"approve": "Duyệt",
|
||||
"reject": "Từ chối",
|
||||
"rollback": "Hoàn tác",
|
||||
"confirmApprove": "Áp dụng đề xuất này cho agent?",
|
||||
"confirmReject": "Từ chối đề xuất này?",
|
||||
"confirmRollback": "Hoàn tác đề xuất đã áp dụng này?",
|
||||
"confirmDescription": "Hành động này sẽ cập nhật trạng thái đề xuất.",
|
||||
"notEnabled": "Tiến hóa chưa bật",
|
||||
"notEnabledHint": "Bật 'Số liệu Tiến hóa' trong phần Tiến hóa của tab Agent để bắt đầu ghi số liệu.",
|
||||
"colType": "Loại",
|
||||
"colSuggestion": "Đề xuất",
|
||||
"colStatus": "Trạng thái",
|
||||
"colCreated": "Tạo lúc",
|
||||
"colActions": "Thao tác",
|
||||
"cancel": "Hủy",
|
||||
"confirm": "Xác nhận",
|
||||
"confirming": "...",
|
||||
"successRate": "Tỉ lệ thành công",
|
||||
"usageRate": "Tỉ lệ sử dụng",
|
||||
"tooltipCalls": "{{count}} lệnh, trung bình {{ms}}ms",
|
||||
"tooltipQueries": "{{count}} truy vấn, điểm {{score}}"
|
||||
},
|
||||
"capabilities": "Khả năng",
|
||||
"llmSeesAs": "LLM nhìn thấy đây là",
|
||||
"prompt": {
|
||||
@@ -211,9 +250,9 @@
|
||||
"memory": "Memory",
|
||||
"memorySm": "Memory (sm)",
|
||||
"sandbox": "Sandbox",
|
||||
"evolution": "Evolution",
|
||||
"channelHints": "Channel Hints",
|
||||
"pinnedSkills": "Pinned Skills",
|
||||
"evolution": "Tiến hóa",
|
||||
"channelHints": "Gợi ý kênh",
|
||||
"pinnedSkills": "Skill ghim",
|
||||
"skillsHybrid": "Skills (hybrid)",
|
||||
"memoryMin": "Memory (min)",
|
||||
"domainCtx": "Domain Context",
|
||||
|
||||
@@ -53,6 +53,17 @@
|
||||
"minConfidenceHint": "Ngưỡng độ tin cậy (0–1) cho thực thể được trích xuất.",
|
||||
"info": "Tự động trích xuất thực thể và quan hệ khi agent ghi vào bộ nhớ. Để trống provider để tắt."
|
||||
},
|
||||
"bg": {
|
||||
"title": "Background Workers",
|
||||
"description": "Provider và model cho các tác vụ nền (vault enrichment, consolidation, dreaming).",
|
||||
"provider": "Provider",
|
||||
"model": "Model",
|
||||
"providerTip": "Provider LLM cho tác vụ nền. Để trống sẽ dùng agent default.",
|
||||
"modelTip": "Model cho tác vụ nền. Để trống sẽ dùng model mặc định của provider.",
|
||||
"providerPlaceholder": "(agent default)",
|
||||
"modelPlaceholder": "(mặc định)",
|
||||
"info": "Dùng cho vault enrichment (tóm tắt tài liệu), consolidation (tóm tắt phiên) và dreaming. Để trống sẽ fallback về agent default provider."
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Nén tin nhắn chờ",
|
||||
"description": "Tóm tắt lịch sử tin nhắn chờ dài để nằm trong giới hạn context.",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"searchPlaceholder": "Tìm tài liệu...",
|
||||
"filterAgent": "Agent",
|
||||
"allAgents": "Tất cả agent",
|
||||
"allTypes": "Tất cả",
|
||||
"noDocuments": "Không có tài liệu trong kho",
|
||||
"noResults": "Không có kết quả",
|
||||
"searchFailed": "Tìm kiếm thất bại",
|
||||
@@ -36,7 +37,8 @@
|
||||
"noLinks": "Không có liên kết",
|
||||
"contentPreview": "Xem nội dung",
|
||||
"fileNotFound": "Không tìm thấy tệp",
|
||||
"emptyContent": "Không có nội dung"
|
||||
"emptyContent": "Không có nội dung",
|
||||
"binaryFile": "Tệp nhị phân — không thể xem trước"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Tiêu đề",
|
||||
@@ -62,7 +64,8 @@
|
||||
"memory": "Bộ nhớ",
|
||||
"note": "Ghi chú",
|
||||
"skill": "Kỹ năng",
|
||||
"episodic": "Giai thoại"
|
||||
"episodic": "Giai thoại",
|
||||
"media": "Media"
|
||||
},
|
||||
"rescanTooltip": "Quét lại workspace",
|
||||
"rescanNew": "{{count}} mới",
|
||||
|
||||
@@ -181,7 +181,46 @@
|
||||
"personality": "Agent个性",
|
||||
"modelBudget": "模型与预算",
|
||||
"skills": "技能",
|
||||
"evolution": { "title": "进化" },
|
||||
"evolution": {
|
||||
"title": "进化",
|
||||
"metricsLabel": "进化指标",
|
||||
"metricsHint": "记录工具效果、检索质量和响应反馈",
|
||||
"suggestionsLabel": "进化建议",
|
||||
"suggestionsHint": "根据已记录的指标生成数据驱动的改进建议",
|
||||
"toolSuccess": "工具成功率",
|
||||
"retrievalQuality": "检索质量",
|
||||
"feedback": "反馈",
|
||||
"noMetrics": "尚无指标记录。Agent 处理请求后指标将出现。",
|
||||
"noSuggestions": "尚无建议。建议由每日分析 cron 生成。",
|
||||
"suggestions": "建议",
|
||||
"guardrails": "适应护栏",
|
||||
"maxDelta": "每周期最大变化",
|
||||
"minDataPoints": "最少数据点",
|
||||
"rollbackDrop": "下降时回滚",
|
||||
"lockedParams": "锁定参数",
|
||||
"timeRange": "时间范围",
|
||||
"approve": "批准",
|
||||
"reject": "拒绝",
|
||||
"rollback": "回滚",
|
||||
"confirmApprove": "将此建议应用到 Agent?",
|
||||
"confirmReject": "拒绝此建议?",
|
||||
"confirmRollback": "撤销此已应用的建议?",
|
||||
"confirmDescription": "此操作将更新建议状态。",
|
||||
"notEnabled": "进化未启用",
|
||||
"notEnabledHint": "在 Agent 选项卡的进化部分启用「进化指标」以开始记录指标。",
|
||||
"colType": "类型",
|
||||
"colSuggestion": "建议",
|
||||
"colStatus": "状态",
|
||||
"colCreated": "创建时间",
|
||||
"colActions": "操作",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"confirming": "...",
|
||||
"successRate": "成功率",
|
||||
"usageRate": "使用率",
|
||||
"tooltipCalls": "{{count}} 次调用,平均 {{ms}}ms",
|
||||
"tooltipQueries": "{{count}} 次查询,得分 {{score}}"
|
||||
},
|
||||
"capabilities": "能力",
|
||||
"llmSeesAs": "LLM会看到这个为",
|
||||
"prompt": {
|
||||
@@ -211,9 +250,9 @@
|
||||
"memory": "Memory",
|
||||
"memorySm": "Memory (sm)",
|
||||
"sandbox": "Sandbox",
|
||||
"evolution": "Evolution",
|
||||
"channelHints": "Channel Hints",
|
||||
"pinnedSkills": "Pinned Skills",
|
||||
"evolution": "进化",
|
||||
"channelHints": "频道提示",
|
||||
"pinnedSkills": "固定技能",
|
||||
"skillsHybrid": "Skills (hybrid)",
|
||||
"memoryMin": "Memory (min)",
|
||||
"domainCtx": "Domain Context",
|
||||
|
||||
@@ -53,6 +53,17 @@
|
||||
"minConfidenceHint": "提取实体的最低置信度阈值(0–1)。",
|
||||
"info": "当Agent写入记忆时自动提取实体和关系。留空Provider以禁用。"
|
||||
},
|
||||
"bg": {
|
||||
"title": "后台工作者",
|
||||
"description": "用于后台任务(知识库摘要、会话整合、梦境)的Provider和模型。",
|
||||
"provider": "Provider",
|
||||
"model": "模型",
|
||||
"providerTip": "后台任务使用的LLM Provider。留空则使用Agent默认。",
|
||||
"modelTip": "后台任务使用的模型。留空则使用Provider默认。",
|
||||
"providerPlaceholder": "(Agent默认)",
|
||||
"modelPlaceholder": "(默认)",
|
||||
"info": "用于知识库文档摘要、会话整合和梦境。留空则回退到Agent默认Provider。"
|
||||
},
|
||||
"compaction": {
|
||||
"title": "待处理消息压缩",
|
||||
"description": "压缩长待处理消息历史以保持在上下文限制内。",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"searchPlaceholder": "搜索文档...",
|
||||
"filterAgent": "智能体",
|
||||
"allAgents": "所有智能体",
|
||||
"allTypes": "全部",
|
||||
"noDocuments": "知识库中没有文档",
|
||||
"noResults": "没有搜索结果",
|
||||
"searchFailed": "搜索失败",
|
||||
@@ -36,7 +37,8 @@
|
||||
"noLinks": "无链接",
|
||||
"contentPreview": "内容预览",
|
||||
"fileNotFound": "文件未找到",
|
||||
"emptyContent": "无内容"
|
||||
"emptyContent": "无内容",
|
||||
"binaryFile": "二进制文件 — 无法预览"
|
||||
},
|
||||
"fields": {
|
||||
"title": "标题",
|
||||
@@ -62,7 +64,8 @@
|
||||
"memory": "记忆",
|
||||
"note": "笔记",
|
||||
"skill": "技能",
|
||||
"episodic": "情景记忆"
|
||||
"episodic": "情景记忆",
|
||||
"media": "Media"
|
||||
},
|
||||
"rescanTooltip": "重新扫描工作区",
|
||||
"rescanNew": "{{count}} 个新文件",
|
||||
|
||||
@@ -367,3 +367,12 @@
|
||||
.dark .hljs-template-variable { color: #ffa657; }
|
||||
.dark .hljs-deletion { color: #ffa198; background: rgba(255,129,130,0.15); }
|
||||
.dark .hljs-addition { background: rgba(63,185,80,0.15); }
|
||||
|
||||
/* Hide scrollbar but keep scrolling */
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
|
||||
const currentDescKey = CONFIG_TYPES.find((c) => c.value === configType)?.descKey ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="space-y-4 rounded-lg border p-3 sm:p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
@@ -305,6 +305,6 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -18,16 +18,20 @@ export function EvolutionMetricsCharts({ toolAggs, retrievalAggs, loading }: Evo
|
||||
return <div className="h-[200px] animate-pulse rounded-md bg-muted" />;
|
||||
}
|
||||
|
||||
// API returns rates as 0-1 fractions; convert to 0-100 for chart display.
|
||||
const toolData = toolAggs.map((a) => ({ ...a, success_rate: a.success_rate * 100 }));
|
||||
const retrievalData = retrievalAggs.map((a) => ({ ...a, usage_rate: a.usage_rate * 100 }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tool Success Rate */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{t("detail.evolution.toolSuccess")}</h4>
|
||||
{toolAggs.length === 0 ? (
|
||||
{toolData.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("detail.evolution.noMetrics")}</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={toolAggs} margin={{ top: 4, right: 20, left: 0, bottom: 0 }}>
|
||||
<BarChart data={toolData} margin={{ top: 4, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="tool_name" tick={{ fontSize: 11 }} tickLine={false} />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} width={36} tickFormatter={(v) => `${v}%`} />
|
||||
@@ -35,12 +39,12 @@ export function EvolutionMetricsCharts({ toolAggs, retrievalAggs, loading }: Evo
|
||||
formatter={(value, _name, props) => {
|
||||
const v = Number(value ?? 0);
|
||||
const p = props?.payload as ToolAggregate | undefined;
|
||||
return [`${v.toFixed(1)}%`, p ? `${p.call_count} calls, ${p.avg_duration_ms.toFixed(0)}ms avg` : ""];
|
||||
return [`${v.toFixed(1)}%`, p ? t("detail.evolution.tooltipCalls", { count: p.call_count, ms: p.avg_duration_ms.toFixed(0) }) : ""];
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="success_rate"
|
||||
name="Success Rate"
|
||||
name={t("detail.evolution.successRate")}
|
||||
radius={[3, 3, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
fill="#22c55e"
|
||||
@@ -53,11 +57,11 @@ export function EvolutionMetricsCharts({ toolAggs, retrievalAggs, loading }: Evo
|
||||
{/* Retrieval Quality */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{t("detail.evolution.retrievalQuality")}</h4>
|
||||
{retrievalAggs.length === 0 ? (
|
||||
{retrievalData.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("detail.evolution.noMetrics")}</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={retrievalAggs} margin={{ top: 4, right: 20, left: 0, bottom: 0 }}>
|
||||
<BarChart data={retrievalData} margin={{ top: 4, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="source" tick={{ fontSize: 11 }} tickLine={false} />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} width={36} tickFormatter={(v) => `${v}%`} />
|
||||
@@ -65,12 +69,12 @@ export function EvolutionMetricsCharts({ toolAggs, retrievalAggs, loading }: Evo
|
||||
formatter={(value, _name, props) => {
|
||||
const v = Number(value ?? 0);
|
||||
const p = props?.payload as RetrievalAggregate | undefined;
|
||||
return [`${v.toFixed(1)}%`, p ? `${p.query_count} queries, score ${p.avg_score.toFixed(2)}` : ""];
|
||||
return [`${v.toFixed(1)}%`, p ? t("detail.evolution.tooltipQueries", { count: p.query_count, score: p.avg_score.toFixed(2) }) : ""];
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="usage_rate"
|
||||
name="Usage Rate"
|
||||
name={t("detail.evolution.usageRate")}
|
||||
fill="#3b82f6"
|
||||
radius={[3, 3, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
|
||||
@@ -61,11 +61,11 @@ export function EvolutionSuggestionsTable({ suggestions, loading, onUpdateStatus
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-left">
|
||||
<th className="px-3 py-2 font-medium">Type</th>
|
||||
<th className="px-3 py-2 font-medium">Suggestion</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Created</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Actions</th>
|
||||
<th className="px-3 py-2 font-medium">{t("detail.evolution.colType")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("detail.evolution.colSuggestion")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("detail.evolution.colStatus")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("detail.evolution.colCreated")}</th>
|
||||
<th className="px-3 py-2 font-medium text-right">{t("detail.evolution.colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -144,10 +144,10 @@ export function EvolutionSuggestionsTable({ suggestions, loading, onUpdateStatus
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirm(null)} disabled={acting}>
|
||||
Cancel
|
||||
{t("detail.evolution.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={acting}>
|
||||
{acting ? "..." : "Confirm"}
|
||||
{acting ? t("detail.evolution.confirming") : t("detail.evolution.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -107,17 +107,16 @@ export function useCreateLink(agentId: string) {
|
||||
return { create };
|
||||
}
|
||||
|
||||
/** Rescan workspace to sync vault documents from filesystem. */
|
||||
export function useRescanWorkspace(agentId: string) {
|
||||
/** Rescan workspace to sync vault documents from filesystem (tenant-wide). */
|
||||
export function useRescanWorkspace() {
|
||||
const http = useHttp();
|
||||
const queryClient = useQueryClient();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const rescan = useCallback(async () => {
|
||||
if (!agentId) return;
|
||||
setIsPending(true);
|
||||
try {
|
||||
const result = await http.post<RescanResult>(`/v1/agents/${agentId}/vault/rescan`, {});
|
||||
const result = await http.post<RescanResult>(`/v1/vault/rescan`, {});
|
||||
await queryClient.invalidateQueries({ queryKey: [VAULT_KEY] });
|
||||
|
||||
const parts: string[] = [];
|
||||
@@ -140,7 +139,7 @@ export function useRescanWorkspace(agentId: string) {
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}, [http, agentId, queryClient]);
|
||||
}, [http, queryClient]);
|
||||
|
||||
return { rescan, isPending };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import type { VaultDocument, VaultLink, VaultSearchResult } from "@/types/vault";
|
||||
@@ -72,6 +72,26 @@ export function useVaultSearch(agentId: string) {
|
||||
return { search, invalidate };
|
||||
}
|
||||
|
||||
/** Tenant-wide vault search (agent_id optional). */
|
||||
export function useVaultSearchAll() {
|
||||
const http = useHttp();
|
||||
|
||||
const search = useCallback(
|
||||
async (query: string, opts?: { agentId?: string; docTypes?: string[]; teamId?: string; maxResults?: number }) => {
|
||||
return http.post<VaultSearchResult[]>("/v1/vault/search", {
|
||||
query,
|
||||
agent_id: opts?.agentId || undefined,
|
||||
doc_types: opts?.docTypes,
|
||||
team_id: opts?.teamId || undefined,
|
||||
max_results: opts?.maxResults ?? 20,
|
||||
});
|
||||
},
|
||||
[http],
|
||||
);
|
||||
|
||||
return { search };
|
||||
}
|
||||
|
||||
/** Fetch all links for a set of vault documents (for graph view). */
|
||||
export function useVaultAllLinks(agentId: string, documents: { id: string }[]) {
|
||||
const http = useHttp();
|
||||
@@ -219,6 +239,25 @@ export function useVaultFileContent(path: string | null) {
|
||||
return { content: data?.content ?? null, size: data?.size ?? 0, loading: isLoading, error: !!error };
|
||||
}
|
||||
|
||||
/** Fetch an image file as blob URL for authenticated rendering in <img> tags. */
|
||||
export function useVaultImageUrl(path: string | null): { url: string | null; error: boolean } {
|
||||
const http = useHttp();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) { setUrl(null); setError(false); return; }
|
||||
let revoke: string | null = null;
|
||||
setError(false);
|
||||
http.downloadBlob(`/v1/storage/files/${encodeURIComponent(path)}?raw=true`)
|
||||
.then((blob) => { revoke = URL.createObjectURL(blob); setUrl(revoke); })
|
||||
.catch(() => { setUrl(null); setError(true); });
|
||||
return () => { if (revoke) URL.revokeObjectURL(revoke); };
|
||||
}, [path, http]);
|
||||
|
||||
return { url, error };
|
||||
}
|
||||
|
||||
// Re-export mutations for convenience — consumers can import from this single file
|
||||
export {
|
||||
useCreateDocument,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Plus, FileText, Link2 } from "lucide-react";
|
||||
import { Pencil, Plus, FileText, Link2, FileQuestion } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
import { useVaultLinks, useVaultFileContent, useUpdateDocument, useDeleteDocument } from "./hooks/use-vault";
|
||||
import { useVaultLinks, useVaultFileContent, useVaultImageUrl, useUpdateDocument, useDeleteDocument } from "./hooks/use-vault";
|
||||
import { VaultLinkDialog } from "./vault-link-dialog";
|
||||
import {
|
||||
VaultEditControls, DocTypeSelect, ScopeSelect, LinkBadge,
|
||||
@@ -34,10 +34,22 @@ export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
|
||||
// Content always loaded when dialog is open
|
||||
// Determine if this is an image that can be rendered.
|
||||
const isImage = useMemo(() => {
|
||||
const mime = doc?.metadata?.mime_type as string | undefined;
|
||||
if (mime?.startsWith("image/")) return true;
|
||||
const ext = doc?.path.split(".").pop()?.toLowerCase() ?? "";
|
||||
return ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico"].includes(ext);
|
||||
}, [doc?.metadata, doc?.path]);
|
||||
|
||||
const isMedia = doc?.doc_type === "media";
|
||||
|
||||
// Only fetch text content for non-media files (media/binary cannot be rendered as markdown).
|
||||
const { content: fileContent, loading: contentLoading, error: contentError } = useVaultFileContent(
|
||||
open && doc ? doc.path : null,
|
||||
open && doc && !isMedia ? doc.path : null,
|
||||
);
|
||||
// Fetch image as authenticated blob URL for <img> rendering.
|
||||
const { url: imageUrl, error: imageError } = useVaultImageUrl(open && isMedia && isImage && doc ? doc.path : null);
|
||||
const { update } = useUpdateDocument(doc?.agent_id ?? "", doc?.id ?? "");
|
||||
const { remove: removeDoc } = useDeleteDocument(doc?.agent_id ?? "", doc?.id ?? "");
|
||||
|
||||
@@ -149,7 +161,37 @@ export function VaultDetailDialog({ doc, open, onOpenChange, onDeleted }: Props)
|
||||
|
||||
{/* Content preview — always visible, scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border bg-muted/30 p-4">
|
||||
{contentLoading ? (
|
||||
{isMedia ? (
|
||||
isImage ? (
|
||||
<div className="flex items-center justify-center">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={doc.title || doc.path}
|
||||
className="max-w-full max-h-[60vh] object-contain rounded"
|
||||
/>
|
||||
) : imageError ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>{t("detail.fileNotFound")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-32 w-32 animate-pulse rounded bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8 text-muted-foreground">
|
||||
<FileQuestion className="h-10 w-10" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium">{t("detail.binaryFile")}</p>
|
||||
<p className="text-xs">{(doc.metadata?.mime_type as string) || doc.path.split(".").pop()?.toUpperCase()}</p>
|
||||
</div>
|
||||
{doc.summary && (
|
||||
<p className="text-xs text-center max-w-md mt-2">{doc.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : contentLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Link2, ChevronLeft, ChevronRight,
|
||||
Link2, ChevronLeft, ChevronRight, Search, X, Loader2,
|
||||
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";
|
||||
import type { VaultDocument, VaultSearchResult } from "@/types/vault";
|
||||
import { useVaultSearchAll } from "./hooks/use-vault";
|
||||
|
||||
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 DOC_TYPE_CONFIG: Record<string, { color: string; bg: string; icon: typeof FileText; dotColor: string }> = {
|
||||
context: { color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", icon: FileText, dotColor: "bg-blue-500" },
|
||||
memory: { color: "text-purple-600 dark:text-purple-400", bg: "bg-purple-500/10", icon: Brain, dotColor: "bg-purple-500" },
|
||||
note: { color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-500/10", icon: StickyNote, dotColor: "bg-amber-500" },
|
||||
skill: { color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", icon: Sparkles, dotColor: "bg-emerald-500" },
|
||||
episodic: { color: "text-orange-600 dark:text-orange-400", bg: "bg-orange-500/10", icon: Clock, dotColor: "bg-orange-500" },
|
||||
media: { color: "text-rose-600 dark:text-rose-400", bg: "bg-rose-500/10", icon: Image, dotColor: "bg-rose-500" },
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG = { color: "text-muted-foreground", bg: "bg-muted", icon: FileText };
|
||||
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"] as const;
|
||||
|
||||
interface Props {
|
||||
documents: VaultDocument[];
|
||||
@@ -30,6 +32,10 @@ interface Props {
|
||||
totalPages: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
docType: string;
|
||||
onDocTypeChange: (type: string) => void;
|
||||
agentId: string;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
function DocCard({ doc, selected, linkCount, onClick }: {
|
||||
@@ -54,17 +60,13 @@ function DocCard({ doc, selected, linkCount, onClick }: {
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
@@ -87,21 +89,125 @@ function DocCard({ doc, selected, linkCount, onClick }: {
|
||||
}
|
||||
|
||||
export function VaultDocumentSidebar({
|
||||
documents, selectedId, linkCounts, onSelect, loading, page, totalPages, total, onPageChange,
|
||||
documents, selectedId, linkCounts, onSelect, loading,
|
||||
page, totalPages, total, onPageChange,
|
||||
docType, onDocTypeChange, agentId, teamId,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("vault");
|
||||
const [query, setQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<VaultSearchResult[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const { search } = useVaultSearchAll();
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const results = await search(q, {
|
||||
agentId: agentId || undefined,
|
||||
docTypes: docType ? [docType] : undefined,
|
||||
teamId: teamId || undefined,
|
||||
maxResults: 30,
|
||||
});
|
||||
setSearchResults(results);
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
},
|
||||
[search, agentId, docType, teamId],
|
||||
);
|
||||
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
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 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">
|
||||
{/* Header */}
|
||||
<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>
|
||||
{/* 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("")}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{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)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<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)}
|
||||
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"
|
||||
/>
|
||||
{query && (
|
||||
<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 && documents.length === 0 ? (
|
||||
{(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" />
|
||||
@@ -111,13 +217,13 @@ export function VaultDocumentSidebar({
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : documents.length === 0 ? (
|
||||
) : 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">{t("noDocuments")}</span>
|
||||
<span className="text-sm">{isSearchMode ? t("noResults") : t("noDocuments")}</span>
|
||||
</div>
|
||||
) : (
|
||||
documents.map((doc) => (
|
||||
displayDocs.map((doc) => (
|
||||
<DocCard
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
@@ -129,8 +235,8 @@ export function VaultDocumentSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination footer */}
|
||||
{totalPages > 1 && (
|
||||
{/* 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" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback, lazy, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, FileArchive, Plus, PanelLeftOpen, FolderSync } from "lucide-react";
|
||||
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 { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
@@ -31,6 +31,7 @@ export function VaultPage() {
|
||||
|
||||
const [selectedAgent, setSelectedAgent] = useState("");
|
||||
const [selectedTeam, setSelectedTeam] = useState("");
|
||||
const [docType, setDocType] = useState("");
|
||||
const [detailDoc, setDetailDoc] = useState<VaultDocument | null>(null);
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
@@ -38,10 +39,11 @@ export function VaultPage() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const { rescan, isPending: rescanPending } = useRescanWorkspace(selectedAgent);
|
||||
const { rescan, isPending: rescanPending } = useRescanWorkspace();
|
||||
|
||||
const { documents, total, loading } = useVaultDocuments(selectedAgent, {
|
||||
teamId: selectedTeam || undefined,
|
||||
docType: docType || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
});
|
||||
@@ -60,6 +62,7 @@ export function VaultPage() {
|
||||
|
||||
const handleAgentChange = (v: string) => { setSelectedAgent(v); setPage(0); };
|
||||
const handleTeamChange = (v: string) => { setSelectedTeam(v); setPage(0); };
|
||||
const handleDocTypeChange = (v: string) => { setDocType(v); setPage(0); };
|
||||
|
||||
// Sidebar click → open detail modal + highlight graph node
|
||||
const handleSidebarSelect = (doc: VaultDocument) => {
|
||||
@@ -69,15 +72,15 @@ export function VaultPage() {
|
||||
};
|
||||
|
||||
// Graph single-click → highlight only
|
||||
const handleNodeSelect = (docId: string | null) => {
|
||||
const handleNodeSelect = useCallback((docId: string | null) => {
|
||||
setSelectedDocId(docId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Graph double-click → open detail modal + highlight
|
||||
const handleNodeDoubleClick = (doc: VaultDocument) => {
|
||||
const handleNodeDoubleClick = useCallback((doc: VaultDocument) => {
|
||||
setDetailDoc(doc);
|
||||
setSelectedDocId(doc.id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCloseDetail = () => { setDetailDoc(null); };
|
||||
|
||||
@@ -104,6 +107,10 @@ export function VaultPage() {
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
docType={docType}
|
||||
onDocTypeChange={handleDocTypeChange}
|
||||
agentId={selectedAgent}
|
||||
teamId={selectedTeam}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -136,13 +143,11 @@ export function VaultPage() {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button size="sm" variant="outline" onClick={() => rescan()} disabled={!selectedAgent || rescanPending}>
|
||||
<FolderSync className={`h-3.5 w-3.5${rescanPending ? " animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => rescan()} disabled={rescanPending}>
|
||||
{rescanPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderSync className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{!selectedAgent ? t("selectAgentFirst", "Select an agent first") : t("rescanTooltip", "Rescan workspace")}</TooltipContent>
|
||||
<TooltipContent>{t("rescanTooltip", "Rescan workspace")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
|
||||
Reference in new issue
Block a user