feat(agent): scope agent knowledge base access to a bound subset (#261)

This commit is contained in:
matevip 2026-06-07 22:41:14 +08:00
parent 86cb449bd5
commit 5ebdccb1f6
14 changed files with 481 additions and 42 deletions

View File

@ -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) {

View File

@ -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;
}

View File

@ -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> {
}

View File

@ -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) ====================
/**

View File

@ -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());
}
/**

View File

@ -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);

View File

@ -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;

View File

@ -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,

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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 ====================

View File

@ -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',

View File

@ -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} 页',

View File

@ -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;