mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): scope agent knowledge base access to a bound subset (#261)
This commit is contained in:
parent
86cb449bd5
commit
5ebdccb1f6
@ -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<List<AgentWikiKbBinding>> 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<Void> setKbs(@PathVariable Long agentId, @RequestBody List<Long> 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) {
|
||||
|
||||
@ -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.
|
||||
* <p>
|
||||
* 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;
|
||||
}
|
||||
@ -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<AgentWikiKbBinding> {
|
||||
}
|
||||
@ -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<AgentWikiKbBinding> listKbBindings(Long agentId) {
|
||||
return kbBindingMapper.selectList(
|
||||
new LambdaQueryWrapper<AgentWikiKbBinding>()
|
||||
.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<Long> 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<Long> 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<AgentWikiKbBinding>()
|
||||
.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) ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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 可访问的知识库。
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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<WikiKnowledgeBaseEntity> listByAgentId(Long agentId) {
|
||||
AgentEntity agent = getAgentOrNull(agentId);
|
||||
if (agent == null || agent.getWorkspaceId() == null) {
|
||||
return listAll();
|
||||
List<WikiKnowledgeBaseEntity> workspaceKbs = (agent == null || agent.getWorkspaceId() == null)
|
||||
? listAll()
|
||||
: listByWorkspace(agent.getWorkspaceId());
|
||||
Set<Long> 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<Long> scopedKbIds(Long agentId) {
|
||||
if (agentId == null || kbBindingMapper == null) {
|
||||
return null;
|
||||
}
|
||||
List<AgentWikiKbBinding> rows = kbBindingMapper.selectList(
|
||||
new LambdaQueryWrapper<AgentWikiKbBinding>()
|
||||
.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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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<AgentWikiKbBinding> 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<WikiKnowledgeBaseEntity> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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} 页',
|
||||
|
||||
@ -227,7 +227,7 @@
|
||||
</button>
|
||||
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'wiki' }" @click="modalTab = 'wiki'">
|
||||
{{ t('agents.tabs.wiki', 'Wiki') }}
|
||||
<span v-if="selectedKBId" class="tab-badge">1</span>
|
||||
<span v-if="selectedKbIds.length" class="tab-badge">{{ selectedKbIds.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -565,33 +565,41 @@
|
||||
</div>
|
||||
<p class="binding-hint">{{ t('agents.binding.wikiHint') }}</p>
|
||||
<div v-if="availableKBs.length === 0" class="binding-empty">{{ t('agents.binding.noKBs') }}</div>
|
||||
<div v-else class="binding-list">
|
||||
<label
|
||||
class="binding-item"
|
||||
:class="{ selected: selectedKBId === null }"
|
||||
>
|
||||
<input type="radio" name="kb-select" :checked="selectedKBId === null" class="binding-checkbox" @change="selectedKBId = null" />
|
||||
<span class="binding-icon">🚫</span>
|
||||
<div class="binding-info">
|
||||
<span class="binding-name">{{ t('agents.binding.noKB') }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
v-for="kb in availableKBs"
|
||||
:key="kb.id"
|
||||
class="binding-item"
|
||||
:class="{ selected: selectedKBId === String(kb.id) }"
|
||||
>
|
||||
<input type="radio" name="kb-select" :checked="selectedKBId === String(kb.id)" class="binding-checkbox" @change="selectedKBId = String(kb.id)" />
|
||||
<span class="binding-icon">📚</span>
|
||||
<div class="binding-info">
|
||||
<span class="binding-name">{{ kb.name }}</span>
|
||||
<span v-if="kb.description" class="binding-desc">{{ kb.description?.slice(0, 80) }}</span>
|
||||
</div>
|
||||
<!-- binding-version class reused for pageCount badge (same positioning as skill version) -->
|
||||
<span v-if="kb.pageCount != null" class="binding-version">{{ t('agents.binding.wikiPages', { count: kb.pageCount }, `${kb.pageCount} pages`) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p class="binding-hint" :class="{ 'binding-hint--warn': selectedKbIds.length > 0 }">
|
||||
{{ selectedKbIds.length === 0 ? t('agents.binding.wikiScopeAll') : t('agents.binding.wikiScopeLimited', { count: selectedKbIds.length }) }}
|
||||
</p>
|
||||
<div class="binding-list">
|
||||
<label
|
||||
v-for="kb in availableKBs"
|
||||
:key="kb.id"
|
||||
class="binding-item"
|
||||
:class="{ selected: isKbInScope(kb.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="binding-checkbox"
|
||||
:checked="isKbInScope(kb.id)"
|
||||
@change="toggleKbScope(kb.id)"
|
||||
/>
|
||||
<span class="binding-icon">📚</span>
|
||||
<div class="binding-info">
|
||||
<span class="binding-name">{{ kb.name }}</span>
|
||||
<span v-if="kb.description" class="binding-desc">{{ kb.description?.slice(0, 80) }}</span>
|
||||
</div>
|
||||
<!-- Primary toggle: only meaningful for in-scope KBs. -->
|
||||
<button
|
||||
type="button"
|
||||
class="kb-primary-toggle"
|
||||
:class="{ 'kb-primary-toggle--active': selectedKBId === String(kb.id) }"
|
||||
:title="t('agents.binding.wikiSetPrimary')"
|
||||
@click.prevent.stop="setPrimaryKb(kb.id)"
|
||||
>{{ selectedKBId === String(kb.id) ? t('agents.binding.wikiPrimary') : t('agents.binding.wikiSetPrimary') }}</button>
|
||||
<!-- binding-version class reused for pageCount badge (same positioning as skill version) -->
|
||||
<span v-if="kb.pageCount != null" class="binding-version">{{ t('agents.binding.wikiPages', { count: kb.pageCount }, `${kb.pageCount} pages`) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@ -793,9 +801,45 @@ function onSkillToggle(skillId: number | string, event: Event) {
|
||||
}
|
||||
const selectedSkillIds = ref<number[]>([])
|
||||
const selectedToolNames = ref<string[]>([])
|
||||
// Agent-level primary wiki KB. KB visibility remains workspace-wide.
|
||||
// Agent wiki KB binding. `selectedKbIds` is the access scope: when non-empty
|
||||
// the agent can only reach those KBs; empty = unrestricted (every KB in the
|
||||
// workspace). `selectedKBId` is the default/primary KB among the scope, used
|
||||
// by wiki tools when no kbId/kbName is given. IDs are strings (Snowflake).
|
||||
const availableKBs = ref<any[]>([])
|
||||
const selectedKBId = ref<string | null>(null)
|
||||
const selectedKbIds = ref<string[]>([])
|
||||
|
||||
function isKbInScope(id: string | number): boolean {
|
||||
return selectedKbIds.value.includes(String(id))
|
||||
}
|
||||
|
||||
/** Toggle a KB in/out of the access scope, keeping the primary consistent. */
|
||||
function toggleKbScope(id: string | number) {
|
||||
const sid = String(id)
|
||||
const idx = selectedKbIds.value.indexOf(sid)
|
||||
if (idx >= 0) {
|
||||
selectedKbIds.value.splice(idx, 1)
|
||||
// Dropping the primary out of scope: fall back to another scoped KB.
|
||||
if (selectedKBId.value === sid) {
|
||||
selectedKBId.value = selectedKbIds.value[0] ?? null
|
||||
}
|
||||
} else {
|
||||
selectedKbIds.value.push(sid)
|
||||
// First KB added becomes the default primary automatically.
|
||||
if (selectedKBId.value === null) {
|
||||
selectedKBId.value = sid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark a KB as the default/primary; auto-adds it to the scope if needed. */
|
||||
function setPrimaryKb(id: string | number) {
|
||||
const sid = String(id)
|
||||
if (!selectedKbIds.value.includes(sid)) {
|
||||
selectedKbIds.value.push(sid)
|
||||
}
|
||||
selectedKBId.value = sid
|
||||
}
|
||||
// RFC-009 PR-3: per-agent provider preference order
|
||||
const availableProviders = ref<{ id: string; name: string }[]>([])
|
||||
const selectedProviderIds = ref<string[]>([])
|
||||
@ -961,6 +1005,7 @@ function openBlankCreateModal() {
|
||||
selectedProviderIds.value = []
|
||||
availableKBs.value = []
|
||||
selectedKBId.value = null
|
||||
selectedKbIds.value = []
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@ -1075,11 +1120,20 @@ async function openEditModal(agent: Agent) {
|
||||
|
||||
// KB request is caught separately so its error message is accurate.
|
||||
try {
|
||||
const kbsRes: any = await wikiApi.listBindableKBs()
|
||||
const [kbsRes, kbBindRes]: any = await Promise.all([
|
||||
wikiApi.listBindableKBs(),
|
||||
agentBindingApi.listKbs(agent.id),
|
||||
])
|
||||
const bindableKBs = (kbsRes.data || []) as any[]
|
||||
availableKBs.value = bindableKBs
|
||||
const bindableIds = new Set(bindableKBs.map((kb: any) => String(kb.id)))
|
||||
// Access scope: keep only enabled rows that still resolve to a visible KB.
|
||||
selectedKbIds.value = ((kbBindRes.data || []) as any[])
|
||||
.filter((b: any) => b.enabled)
|
||||
.map((b: any) => String(b.kbId))
|
||||
.filter((id: string) => bindableIds.has(id))
|
||||
const primaryKbId = agent.primaryKbId != null ? String(agent.primaryKbId) : null
|
||||
selectedKBId.value = primaryKbId && bindableKBs.some((kb: any) => String(kb.id) === primaryKbId)
|
||||
selectedKBId.value = primaryKbId && bindableIds.has(primaryKbId)
|
||||
? primaryKbId
|
||||
: null
|
||||
} catch {
|
||||
@ -1094,6 +1148,7 @@ function closeModal() {
|
||||
toolBindingSearch.value = ''
|
||||
availableKBs.value = []
|
||||
selectedKBId.value = null
|
||||
selectedKbIds.value = []
|
||||
}
|
||||
|
||||
async function saveAgent() {
|
||||
@ -1137,6 +1192,9 @@ async function saveAgent() {
|
||||
await agentBindingApi.setSkills(agentId, skillIdsToSave)
|
||||
await agentBindingApi.setTools(agentId, toolNamesToSave)
|
||||
await agentBindingApi.setProviderPreferences(agentId, selectedProviderIds.value)
|
||||
// KB access scope. Empty = unrestricted (workspace-wide). Sent as
|
||||
// strings per the Snowflake-precision contract.
|
||||
await agentBindingApi.setKbs(agentId, selectedKbIds.value)
|
||||
} catch (bindingError: any) {
|
||||
mcToast.error(bindingError?.message || t('agents.messages.saveFailed'))
|
||||
// Pull the authoritative server state back into the editing form so
|
||||
@ -1593,6 +1651,26 @@ html.dark .seg-count.warn {
|
||||
.binding-desc { font-size: 12px; color: var(--mc-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* reused for KB pageCount badge in wiki tab */
|
||||
.binding-version { font-size: 11px; color: var(--mc-text-tertiary); flex-shrink: 0; }
|
||||
/* Highlighted scope hint when the agent is restricted to a KB subset */
|
||||
.binding-hint--warn { color: var(--mc-warning, #b8860b); }
|
||||
/* "Set default" / "Default" toggle for the primary KB in the wiki tab */
|
||||
.kb-primary-toggle {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--mc-border);
|
||||
background: transparent;
|
||||
color: var(--mc-text-tertiary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kb-primary-toggle:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||
.kb-primary-toggle--active {
|
||||
background: var(--mc-primary);
|
||||
border-color: var(--mc-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.binding-type-badge {
|
||||
font-size: 10px; padding: 2px 6px; border-radius: 4px; flex-shrink: 0;
|
||||
background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); text-transform: uppercase;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user