diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java index a4172b93..fccea4a5 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java @@ -8,6 +8,7 @@ import vip.mate.agent.AgentService; import vip.mate.agent.binding.model.AgentProviderPreference; import vip.mate.agent.binding.model.AgentSkillBinding; import vip.mate.agent.binding.model.AgentToolBinding; +import vip.mate.agent.binding.model.AgentWikiKbBinding; import vip.mate.agent.binding.service.AgentBindingService; import vip.mate.agent.model.AgentEntity; import vip.mate.audit.service.AuditEventService; @@ -136,6 +137,32 @@ public class AgentBindingController { return R.ok(); } + // ==================== Knowledge Base Access Scope ==================== + + @Operation(summary = "获取 Agent 的知识库访问范围") + @GetMapping("/kbs") + @RequireWorkspaceRole("viewer") + public R> listKbs(@PathVariable Long agentId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyAgentWorkspace(agentId, workspaceId); + return R.ok(bindingService.listKbBindings(agentId)); + } + + @Operation(summary = "批量设置 Agent 的知识库访问范围(替换模式,空表示不限制)") + @PutMapping("/kbs") + @RequireWorkspaceRole("member") + public R setKbs(@PathVariable Long agentId, @RequestBody List kbIds, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyAgentWorkspace(agentId, workspaceId); + bindingService.setKbBindings(agentId, kbIds); + agentService.invalidateAgentCache(agentId); + // A non-Vue caller can POST a bare `null`; the service tolerates it. + int count = kbIds == null ? 0 : kbIds.size(); + auditEventService.record("UPDATE", "AGENT_WIKI_KB", String.valueOf(agentId), + "kbs=" + count, null); + return R.ok(); + } + // ==================== Workspace Verification ==================== private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentWikiKbBinding.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentWikiKbBinding.java new file mode 100644 index 00000000..aeedb8be --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentWikiKbBinding.java @@ -0,0 +1,27 @@ +package vip.mate.agent.binding.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * Agent ↔ knowledge base access scope row. + *

+ * Each enabled row whitelists one KB for one agent. When an agent has at + * least one row the wiki tools restrict their visible KB set to the bound + * ones; an agent with no rows stays workspace-wide (legacy behavior). + */ +@Data +@TableName("mate_agent_wiki_kb") +public class AgentWikiKbBinding { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long agentId; + private Long kbId; + private Boolean enabled; + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentWikiKbBindingMapper.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentWikiKbBindingMapper.java new file mode 100644 index 00000000..0f7d911d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentWikiKbBindingMapper.java @@ -0,0 +1,9 @@ +package vip.mate.agent.binding.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.agent.binding.model.AgentWikiKbBinding; + +@Mapper +public interface AgentWikiKbBindingMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 834d9a62..d70fb2dc 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -9,9 +9,11 @@ import org.springframework.stereotype.Service; import vip.mate.agent.binding.model.AgentProviderPreference; import vip.mate.agent.binding.model.AgentSkillBinding; import vip.mate.agent.binding.model.AgentToolBinding; +import vip.mate.agent.binding.model.AgentWikiKbBinding; import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper; import vip.mate.agent.binding.repository.AgentSkillBindingMapper; import vip.mate.agent.binding.repository.AgentToolBindingMapper; +import vip.mate.agent.binding.repository.AgentWikiKbBindingMapper; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.exception.MateClawException; @@ -26,6 +28,8 @@ import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.tool.model.AvailableToolDTO; import vip.mate.tool.service.AvailableToolService; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; import java.time.Duration; import java.time.LocalDateTime; @@ -55,6 +59,17 @@ public class AgentBindingService implements AgentBindingResolver { private final AgentSkillBindingMapper skillBindingMapper; private final AgentToolBindingMapper toolBindingMapper; private final AgentProviderPreferenceMapper providerPreferenceMapper; + /** + * Agent ↔ KB access-scope rows. Plain mapper (no transitive deps), so it + * is safe to wire directly here without risking a boot-time cycle. + */ + private final AgentWikiKbBindingMapper kbBindingMapper; + /** + * Used only to verify a KB lives in the agent's workspace before pinning + * it. Like {@link #agentMapper}, a bare mapper avoids pulling the wiki + * service layer (and its dependency on agent binding) into this bean. + */ + private final WikiKnowledgeBaseMapper kbMapper; /** * {@code @Lazy} — SkillRuntimeService and AgentBindingService both sit * near the agent boot path; the lazy proxy avoids a circular bean @@ -93,6 +108,8 @@ public class AgentBindingService implements AgentBindingResolver { public AgentBindingService(AgentSkillBindingMapper skillBindingMapper, AgentToolBindingMapper toolBindingMapper, AgentProviderPreferenceMapper providerPreferenceMapper, + AgentWikiKbBindingMapper kbBindingMapper, + WikiKnowledgeBaseMapper kbMapper, @Lazy SkillRuntimeService skillRuntimeService, AvailableToolService availableToolService, AgentMapper agentMapper, @@ -101,6 +118,8 @@ public class AgentBindingService implements AgentBindingResolver { this.skillBindingMapper = skillBindingMapper; this.toolBindingMapper = toolBindingMapper; this.providerPreferenceMapper = providerPreferenceMapper; + this.kbBindingMapper = kbBindingMapper; + this.kbMapper = kbMapper; this.skillRuntimeService = skillRuntimeService; this.availableToolService = availableToolService; this.agentMapper = agentMapper; @@ -941,6 +960,81 @@ public class AgentBindingService implements AgentBindingResolver { } } + // ==================== Knowledge base access scope ==================== + + /** Raw scope rows for the agent edit form, oldest first. */ + public List listKbBindings(Long agentId) { + return kbBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(AgentWikiKbBinding::getAgentId, agentId) + .orderByAsc(AgentWikiKbBinding::getCreateTime)); + } + + /** + * Replace the agent's KB access scope. An empty / null list clears the + * scope, returning the agent to workspace-wide (unrestricted) access. + * Every incoming KB must live in the agent's workspace — pinning a KB + * from another tenancy is refused (403). + */ + public void setKbBindings(Long agentId, List kbIds) { + // De-dup defensively: the unique index is (agent_id, kb_id, deleted), + // so two identical ids in the incoming list would collide on insert. + Set distinct = new LinkedHashSet<>(); + if (kbIds != null) { + for (Long kbId : kbIds) { + if (kbId != null) { + distinct.add(kbId); + } + } + } + // Validate the whole set BEFORE deleting anything, so a rejected id + // can't leave the agent half-scoped. + for (Long kbId : distinct) { + requireKbInAgentWorkspace(agentId, kbId); + } + kbBindingMapper.delete( + new LambdaQueryWrapper() + .eq(AgentWikiKbBinding::getAgentId, agentId)); + for (Long kbId : distinct) { + AgentWikiKbBinding row = new AgentWikiKbBinding(); + row.setAgentId(agentId); + row.setKbId(kbId); + row.setEnabled(true); + kbBindingMapper.insert(row); + } + } + + /** + * Refuse to scope an agent to a KB outside its workspace. KBs are + * workspace-shared artifacts ({@code mate_wiki_knowledge_base.workspace_id}); + * letting workspace A's agent pin workspace B's KB would cross the + * tenancy boundary the same way a cross-workspace skill binding would. + * A {@code null} workspace on either side is normalized to the default + * workspace (1) to match the rest of the codebase. + */ + private void requireKbInAgentWorkspace(Long agentId, Long kbId) { + if (agentId == null) { + throw new MateClawException("err.agent.not_found", 404, "Agent ID is required"); + } + AgentEntity agent = agentMapper.selectById(agentId); + if (agent == null) { + throw new MateClawException("err.agent.not_found", 404, "Agent 不存在: " + agentId); + } + WikiKnowledgeBaseEntity kb = kbMapper.selectById(kbId); + if (kb == null) { + throw new MateClawException("err.wiki.kb_not_found", 404, + "Knowledge base 不存在: " + kbId); + } + long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId(); + long kbWs = kb.getWorkspaceId() == null ? 1L : kb.getWorkspaceId(); + if (agentWs != kbWs) { + throw new MateClawException("err.wiki.cross_workspace_kb_binding", 403, + "Knowledge base " + kbId + " (workspace=" + kbWs + + ") cannot be scoped to Agent " + agentId + + " (workspace=" + agentWs + ")"); + } + } + // ==================== Binding-mode flags (V126) ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index 49259d1c..74bc106b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -5,6 +5,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vip.mate.agent.binding.model.AgentWikiKbBinding; +import vip.mate.agent.binding.repository.AgentWikiKbBindingMapper; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.wiki.job.model.WikiProcessingJobEntity; @@ -21,6 +23,8 @@ import vip.mate.wiki.repository.WikiProcessingJobMapper; import vip.mate.wiki.repository.WikiRawMaterialMapper; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** * Wiki 知识库服务 @@ -50,6 +54,15 @@ public class WikiKnowledgeBaseService { @org.springframework.context.annotation.Lazy private WikiScaffoldService scaffoldService; + /** + * Per-agent KB access scope. Optional ({@code required=false}) so the + * older tests that hand-wire this service via {@code @RequiredArgsConstructor} + * still compile and run — a {@code null} mapper means "no scoping known", + * which falls through to the legacy workspace-wide visibility. + */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + private AgentWikiKbBindingMapper kbBindingMapper; + /** * Summary returned from cascade delete — used by callers (e.g. the * controller) to record an audit event with affected-row counts. @@ -107,15 +120,54 @@ public class WikiKnowledgeBaseService { /** * 获取 Agent 可访问的知识库。 *

- * Knowledge bases are workspace-shared. The agent's primary KB is stored - * on mate_agent.primary_kb_id and does not affect visibility. + * Knowledge bases are workspace-shared, so the baseline visible set is + * every KB in the agent's workspace. When the agent has been pinned to a + * subset via {@code mate_agent_wiki_kb}, the set is narrowed to those KBs + * (intersected with the workspace, so a stale binding to a moved/deleted + * KB just drops out). An agent with no scope rows stays workspace-wide, + * preserving the pre-scoping behavior for every existing agent. + *

+ * This is the single choke point for KB access: {@code wiki_list_kbs}, + * {@link #findVisibleById}, {@link #findAllByName} and + * {@link #resolvePrimaryKb} all read through here, so narrowing it scopes + * every wiki tool at once. */ public List listByAgentId(Long agentId) { AgentEntity agent = getAgentOrNull(agentId); - if (agent == null || agent.getWorkspaceId() == null) { - return listAll(); + List workspaceKbs = (agent == null || agent.getWorkspaceId() == null) + ? listAll() + : listByWorkspace(agent.getWorkspaceId()); + Set scope = scopedKbIds(agentId); + if (scope == null) { + return workspaceKbs; // unrestricted } - return listByWorkspace(agent.getWorkspaceId()); + return workspaceKbs.stream() + .filter(kb -> scope.contains(kb.getId())) + .collect(Collectors.toList()); + } + + /** + * Enabled KB ids this agent is pinned to, or {@code null} when the agent + * is unrestricted (no scope rows, or the binding mapper isn't wired — see + * {@link #kbBindingMapper}). Returning {@code null} rather than an empty + * set is deliberate: an empty set would mean "no KB visible", but a fresh + * agent must default to its whole workspace. + */ + private Set scopedKbIds(Long agentId) { + if (agentId == null || kbBindingMapper == null) { + return null; + } + List rows = kbBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(AgentWikiKbBinding::getAgentId, agentId) + .eq(AgentWikiKbBinding::getEnabled, true)); + if (rows.isEmpty()) { + return null; + } + return rows.stream() + .map(AgentWikiKbBinding::getKbId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); } /** diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V141__agent_wiki_kb_scope.sql b/mateclaw-server/src/main/resources/db/migration/h2/V141__agent_wiki_kb_scope.sql new file mode 100644 index 00000000..d9819621 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V141__agent_wiki_kb_scope.sql @@ -0,0 +1,26 @@ +-- V141: Per-agent knowledge base access scope for wiki tools. +-- +-- Knowledge bases are workspace-shared, so by default every agent in a +-- workspace can reach every KB in it. This table lets an operator pin an +-- agent to a subset of KBs: once at least one enabled row exists for an +-- agent, the wiki tools (list/search/read/write) can only see and target +-- those KBs. No rows for an agent = unrestricted (workspace-wide), which +-- keeps every pre-existing agent behaving exactly as before. +-- +-- The default KB an agent's wiki tools fall back to when no kbId/kbName is +-- given still lives on mate_agent.primary_kb_id; this table only narrows the +-- visible set, and the primary is expected to be one of the scoped KBs. + +CREATE TABLE IF NOT EXISTS mate_agent_wiki_kb ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_wiki_kb + ON mate_agent_wiki_kb (agent_id, kb_id, deleted); +CREATE INDEX IF NOT EXISTS idx_agent_wiki_kb_agent + ON mate_agent_wiki_kb (agent_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V141__agent_wiki_kb_scope.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V141__agent_wiki_kb_scope.sql new file mode 100644 index 00000000..9be7eaf8 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V141__agent_wiki_kb_scope.sql @@ -0,0 +1,24 @@ +-- V141: Per-agent knowledge base access scope for wiki tools (MySQL). +-- +-- Knowledge bases are workspace-shared, so by default every agent in a +-- workspace can reach every KB in it. This table lets an operator pin an +-- agent to a subset of KBs: once at least one enabled row exists for an +-- agent, the wiki tools (list/search/read/write) can only see and target +-- those KBs. No rows for an agent = unrestricted (workspace-wide), which +-- keeps every pre-existing agent behaving exactly as before. +-- +-- The default KB an agent's wiki tools fall back to when no kbId/kbName is +-- given still lives on mate_agent.primary_kb_id; this table only narrows the +-- visible set, and the primary is expected to be one of the scoped KBs. + +CREATE TABLE IF NOT EXISTS mate_agent_wiki_kb ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_agent_wiki_kb (agent_id, kb_id, deleted), + KEY idx_agent_wiki_kb_agent (agent_id, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java index dbc9a34e..266a2578 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java @@ -58,6 +58,8 @@ class AgentBindingServiceValidationTest { skillBindingMapper, toolBindingMapper, providerPreferenceMapper, + mock(vip.mate.agent.binding.repository.AgentWikiKbBindingMapper.class), + mock(vip.mate.wiki.repository.WikiKnowledgeBaseMapper.class), skillRuntimeService, availableToolService, agentMapper, diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java index 750bf133..01893457 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java @@ -46,6 +46,10 @@ class AgentBindingServiceCuratorTest { @Mock private AgentProviderPreferenceMapper providerPreferenceMapper; @Mock + private vip.mate.agent.binding.repository.AgentWikiKbBindingMapper kbBindingMapper; + @Mock + private vip.mate.wiki.repository.WikiKnowledgeBaseMapper kbMapper; + @Mock private SkillRuntimeService skillRuntimeService; @Mock private AvailableToolService availableToolService; @@ -73,6 +77,7 @@ class AgentBindingServiceCuratorTest { @BeforeEach void setUp() { service = new AgentBindingService(skillBindingMapper, toolBindingMapper, providerPreferenceMapper, + kbBindingMapper, kbMapper, skillRuntimeService, availableToolService, agentMapper, skillMapper, acpSkillBridge); } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java index 6e445242..7be62c78 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java @@ -2,6 +2,9 @@ package vip.mate.wiki.service; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.binding.model.AgentWikiKbBinding; +import vip.mate.agent.binding.repository.AgentWikiKbBindingMapper; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; @@ -227,4 +230,82 @@ class WikiKnowledgeBaseServiceTest { assertThat(service.findVisibleById(7L, 99999L)).isNull(); assertThat(service.findVisibleById(7L, null)).isNull(); } + + // ==================== Agent ↔ KB access scope (issue #261) ==================== + // + // When an agent has scope rows in mate_agent_wiki_kb, listByAgentId — the + // single choke point every wiki tool reads through — must narrow the + // workspace-wide KB set to the bound subset. No rows = unrestricted, so + // every pre-scoping agent keeps its old workspace-wide visibility. + + private AgentWikiKbBindingMapper bindScope(long... kbIds) { + AgentWikiKbBindingMapper mapper = mock(AgentWikiKbBindingMapper.class); + java.util.List rows = new java.util.ArrayList<>(); + for (long kbId : kbIds) { + AgentWikiKbBinding row = new AgentWikiKbBinding(); + row.setKbId(kbId); + row.setEnabled(true); + rows.add(row); + } + when(mapper.selectList(any())).thenReturn(rows); + ReflectionTestUtils.setField(service, "kbBindingMapper", mapper); + return mapper; + } + + @Test + @DisplayName("scope narrows listByAgentId to the bound KBs only") + void scopeRestrictsVisibleKbs() { + bindScope(100L, 300L); + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L)); + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(100L, null, 1L, "Business KB"), + kb(200L, null, 1L, "Unrelated KB"), + kb(300L, null, 1L, "Other Business KB"))); + + List visible = service.listByAgentId(7L); + assertThat(visible).extracting(WikiKnowledgeBaseEntity::getId) + .containsExactlyInAnyOrder(100L, 300L); + // The out-of-scope KB is invisible even when targeted by id directly. + assertThat(service.findVisibleById(7L, 200L)).isNull(); + assertThat(service.findVisibleById(7L, 300L)).isNotNull(); + } + + @Test + @DisplayName("no scope rows leaves the agent unrestricted (workspace-wide)") + void noScopeMeansUnrestricted() { + bindScope(); // empty → mapper returns no rows + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, null)); + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(100L, null, 1L, "KB A"), + kb(200L, null, 1L, "KB B"))); + + assertThat(service.listByAgentId(7L)).extracting(WikiKnowledgeBaseEntity::getId) + .containsExactlyInAnyOrder(100L, 200L); + } + + @Test + @DisplayName("a stale scope row for a removed/moved KB simply drops out") + void staleScopeRowIsIntersectedAway() { + bindScope(100L, 999L); // 999 no longer in the workspace + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L)); + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(100L, null, 1L, "Live KB"), + kb(200L, null, 1L, "Unrelated KB"))); + + assertThat(service.listByAgentId(7L)).extracting(WikiKnowledgeBaseEntity::getId) + .containsExactly(100L); + } + + @Test + @DisplayName("primary KB resolution respects the scope") + void primaryKbRespectsScope() { + bindScope(300L); + // primary points at an out-of-scope KB → falls back to a scoped one. + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L)); + when(kbMapper.selectList(any())).thenReturn(List.of( + kb(100L, null, 1L, "Out Of Scope Primary"), + kb(300L, null, 1L, "Scoped KB"))); + + assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(300L); + } } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index beedd7d2..9d97c59d 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -988,6 +988,12 @@ export const agentBindingApi = { http.get(`/agents/${agentId}/provider-preferences`), setProviderPreferences: (agentId: string | number, providerIds: string[]) => http.put(`/agents/${agentId}/provider-preferences`, providerIds), + // Per-agent knowledge base access scope. Empty array = unrestricted + // (agent can reach every KB in its workspace). IDs are kept as strings + // for the Snowflake-precision contract. + listKbs: (agentId: string | number) => http.get(`/agents/${agentId}/kbs`), + setKbs: (agentId: string | number, kbIds: (string | number)[]) => + http.put(`/agents/${agentId}/kbs`, kbIds), } // ==================== Dashboard ==================== diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 19596036..e5bf7160 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1311,9 +1311,13 @@ export default { noMatchingSkills: 'No matching skills', noMatchingTools: 'No matching tools', noProviderPreferences: 'No preferences set — the agent uses the global fallback chain order.', - wikiKicker: 'Primary Knowledge Base', - wikiTagline: 'Choose the default knowledge base this agent should prefer. Knowledge bases remain workspace-shared.', - wikiHint: 'Select this agent\'s primary knowledge base. When unset, workspace fallback applies.', + wikiKicker: 'Knowledge Base Access', + wikiTagline: 'Limit which knowledge bases this agent can reach, so it never reads content outside its scope.', + wikiHint: 'Tick the knowledge bases this agent may access; mark one as the default (used by wiki tools when no kbId/kbName is given).', + wikiScopeAll: 'No knowledge base selected: this agent can reach every KB in the current workspace.', + wikiScopeLimited: 'This agent can only reach the {count} selected knowledge base(s).', + wikiSetPrimary: 'Set default', + wikiPrimary: 'Default', noKBs: 'No knowledge bases available', noKB: 'No primary KB', wikiPages: '{count} pages', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b852c1d3..bb212e70 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1203,9 +1203,13 @@ export default { noMatchingSkills: '没有匹配的技能', noMatchingTools: '没有匹配的工具', noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。', - wikiKicker: '主知识库', - wikiTagline: '为智能体指定默认优先使用的知识库,所有知识库仍保持工作区共享。', - wikiHint: '选择此智能体的主知识库。未指定时会按工作区知识库回退。', + wikiKicker: '知识库访问范围', + wikiTagline: '限定此智能体可访问的知识库,防止它读取与业务无关的内容。', + wikiHint: '勾选此智能体允许访问的知识库;可将其中一个设为默认(未指定 kbId/kbName 时优先使用)。', + wikiScopeAll: '未勾选任何知识库:此智能体可访问当前工作区内的全部知识库。', + wikiScopeLimited: '此智能体仅能访问已勾选的 {count} 个知识库。', + wikiSetPrimary: '设为默认', + wikiPrimary: '默认', noKBs: '暂无可用知识库', noKB: '未指定主库', wikiPages: '{count} 页', diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index b561c280..760fc1b3 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -227,7 +227,7 @@ @@ -565,33 +565,41 @@

{{ t('agents.binding.wikiHint') }}

{{ t('agents.binding.noKBs') }}
-
- - -
+