feat(agent): add knowledge base binding tab to agent editor (#237)

Agents now have a per-agent primary wiki KB stored on
mate_agent.primary_kb_id. KBs remain workspace-shared — selecting one in
the agent editor only chooses the default wiki target for that agent, it
does not change the KB's ownership or visibility.

Backend
- AgentEntity: add primary_kb_id field (FieldStrategy.ALWAYS so the UI
  can clear it back to "no primary")
- AgentController#update: switch body to Map<String, Object> so we can
  tell "field missing" apart from "explicit null" via containsKey, then
  convertValue back to AgentEntity
- WikiKnowledgeBaseService:
  - new resolvePrimaryKb(agentId): prefers agent.primary_kb_id when it
    points to a workspace-visible KB; falls back to legacy
    kb.agent_id marker, then to most-recently-updated workspace KB
  - listByAgentId now returns the full workspace set (KBs are
    workspace-shared under the new model)
  - update(id, name, description) no longer touches agent_id
- WikiController: new GET /knowledge-bases/bindable for the UI picker;
  PUT /knowledge-bases/{id} no longer reads agentId
- WikiKnowledgeBaseEntity: add FieldStrategy.ALWAYS on embeddingModelId
  and configContent so explicit nulls actually unbind/clear instead of
  being silently skipped by MyBatis-Plus's NOT_NULL default
- Migrations V129 (H2 + MySQL): add primary_kb_id column + index, backfill
  from legacy kb.agent_id, MySQL uses INFORMATION_SCHEMA guard +
  PREPARE/EXECUTE for idempotency
- WikiKnowledgeBaseServiceTest: 13 cases, all passing

Frontend
- Agents.vue: new "Knowledge Base" tab, radio-select bindable KBs
- API: listBindableKBs() + Agent.primaryKbId typed string | number | null
- IDs handled as strings throughout (Snowflake-safe)
- i18n keys for the new tab in zh-CN and en-US
This commit is contained in:
lichuan 2026-05-29 06:00:52 +08:00 committed by matevip
parent 0ef735588a
commit bd02734d61
15 changed files with 291 additions and 36 deletions

View File

@ -1,5 +1,6 @@
package vip.mate.agent.controller; package vip.mate.agent.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -27,6 +28,7 @@ import vip.mate.workspace.core.service.WorkspaceService;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@ -49,6 +51,7 @@ public class AgentController {
private final ModelConfigService modelConfigService; private final ModelConfigService modelConfigService;
private final ModelCapabilityService modelCapabilityService; private final ModelCapabilityService modelCapabilityService;
private final SystemSettingService systemSettingService; private final SystemSettingService systemSettingService;
private final ObjectMapper objectMapper;
private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@Operation(summary = "获取Agent列表") @Operation(summary = "获取Agent列表")
@ -144,10 +147,14 @@ public class AgentController {
@Operation(summary = "更新Agent") @Operation(summary = "更新Agent")
@PutMapping("/{id}") @PutMapping("/{id}")
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<AgentEntity> update(@PathVariable Long id, @RequestBody AgentEntity agent, public R<AgentEntity> update(@PathVariable Long id, @RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity existing = agentService.getAgent(id); AgentEntity existing = agentService.getAgent(id);
verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId); verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId);
AgentEntity agent = objectMapper.convertValue(body, AgentEntity.class);
if (!body.containsKey("primaryKbId")) {
agent.setPrimaryKbId(existing.getPrimaryKbId());
}
agent.setId(id); agent.setId(id);
agent.setWorkspaceId(existing.getWorkspaceId()); // 不允许跨 workspace 迁移 agent.setWorkspaceId(existing.getWorkspaceId()); // 不允许跨 workspace 迁移
AgentEntity updated = agentService.updateAgent(agent); AgentEntity updated = agentService.updateAgent(agent);

View File

@ -86,6 +86,15 @@ public class AgentEntity {
@TableField(value = "workspace_base_path", updateStrategy = FieldStrategy.ALWAYS) @TableField(value = "workspace_base_path", updateStrategy = FieldStrategy.ALWAYS)
private String workspaceBasePath; private String workspaceBasePath;
/**
* Agent's primary wiki knowledge base. This is a per-agent default target
* for wiki tools; it does not affect KB visibility or ownership.
* Null means no explicit primary KB, so wiki resolution falls back to the
* workspace's most recently updated KB.
*/
@TableField(value = "primary_kb_id", updateStrategy = FieldStrategy.ALWAYS)
private Long primaryKbId;
/** /**
* Explicit opt-out from every skill. When {@code true}, the binding service * Explicit opt-out from every skill. When {@code true}, the binding service
* returns {@link java.util.Collections#emptySet()} from * returns {@link java.util.Collections#emptySet()} from

View File

@ -67,6 +67,15 @@ public class WikiController {
return R.ok(withLivePageCount(kbService.listByWorkspace(wsId))); return R.ok(withLivePageCount(kbService.listByWorkspace(wsId)));
} }
@RequireWorkspaceRole("viewer")
@Operation(summary = "列出可绑定到指定 Agent 的知识库")
@GetMapping("/knowledge-bases/bindable")
public R<List<WikiKnowledgeBaseEntity>> listBindableKBs(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
long wsId = workspaceId != null ? workspaceId : 1L;
return R.ok(withLivePageCount(kbService.listByWorkspace(wsId)));
}
@RequireWorkspaceRole("viewer") @RequireWorkspaceRole("viewer")
@Operation(summary = "获取知识库详情") @Operation(summary = "获取知识库详情")
@GetMapping("/knowledge-bases/{id}") @GetMapping("/knowledge-bases/{id}")
@ -131,8 +140,7 @@ public class WikiController {
verifyKBWorkspace(id, workspaceId); verifyKBWorkspace(id, workspaceId);
String name = (String) body.get("name"); String name = (String) body.get("name");
String description = (String) body.get("description"); String description = (String) body.get("description");
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null; kbService.update(id, name, description);
kbService.update(id, name, description, agentId);
// RFC Embedding UI: 允许通过此接口绑定 / 解绑 embedding 模型 // RFC Embedding UI: 允许通过此接口绑定 / 解绑 embedding 模型
if (body.containsKey("embeddingModelId")) { if (body.containsKey("embeddingModelId")) {
Object v = body.get("embeddingModelId"); Object v = body.get("embeddingModelId");

View File

@ -51,6 +51,7 @@ public class WikiKnowledgeBaseEntity {
* NULL = 使用系统默认mate_system_setting embedding.default.model.id * NULL = 使用系统默认mate_system_setting embedding.default.model.id
* 再无则取任意 enabled embedding 模型最终全无则语义搜索降级为不可用 * 再无则取任意 enabled embedding 模型最终全无则语义搜索降级为不可用
*/ */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long embeddingModelId; private Long embeddingModelId;
@TableField(fill = FieldFill.INSERT) @TableField(fill = FieldFill.INSERT)

View File

@ -5,6 +5,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiChunkEntity; import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
@ -36,6 +38,7 @@ public class WikiKnowledgeBaseService {
private final WikiChunkMapper chunkMapper; private final WikiChunkMapper chunkMapper;
private final WikiPageCitationMapper citationMapper; private final WikiPageCitationMapper citationMapper;
private final WikiProcessingJobMapper processingJobMapper; private final WikiProcessingJobMapper processingJobMapper;
private final AgentMapper agentMapper;
/** /**
* RFC-051 PR-2: optional system-page scaffold (overview / log). Marked * RFC-051 PR-2: optional system-page scaffold (overview / log). Marked
@ -102,32 +105,42 @@ public class WikiKnowledgeBaseService {
} }
/** /**
* 获取 Agent 可访问的知识库Agent 专属 KB + 公共 KBagent_id IS NULL * 获取 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.
*/ */
public List<WikiKnowledgeBaseEntity> listByAgentId(Long agentId) { public List<WikiKnowledgeBaseEntity> listByAgentId(Long agentId) {
return kbMapper.selectList( AgentEntity agent = getAgentOrNull(agentId);
new LambdaQueryWrapper<WikiKnowledgeBaseEntity>() if (agent == null || agent.getWorkspaceId() == null) {
.and(w -> w.eq(WikiKnowledgeBaseEntity::getAgentId, agentId) return listAll();
.or().isNull(WikiKnowledgeBaseEntity::getAgentId)) }
.orderByDesc(WikiKnowledgeBaseEntity::getUpdateTime)); return listByWorkspace(agent.getWorkspaceId());
} }
/** /**
* Resolve the single knowledge base an agent's wiki tools should operate on. * Resolve the single knowledge base an agent's wiki tools should operate on.
* <p> * <p>
* Prefers a KB explicitly bound to the agent; a shared (agent-less) KB is * Prefers mate_agent.primary_kb_id when it points to a KB in the same
* only used as a fallback when the agent has no bound KB of its own. This * workspace. For legacy rows that predate primary_kb_id, falls back to the
* matters because {@link #listByAgentId} also returns shared KBs, and a * old mate_wiki_knowledge_base.agent_id marker only when no primary is set.
* shared KB with a more recent {@code update_time} would otherwise win the * Otherwise the most recently updated workspace KB wins.
* {@code get(0)} pick over the agent's own KB. Within each tier the most
* recently updated KB wins. Returns {@code null} when the agent can reach
* no knowledge base at all.
*/ */
public WikiKnowledgeBaseEntity resolvePrimaryKb(Long agentId) { public WikiKnowledgeBaseEntity resolvePrimaryKb(Long agentId) {
AgentEntity agent = getAgentOrNull(agentId);
List<WikiKnowledgeBaseEntity> kbs = listByAgentId(agentId); List<WikiKnowledgeBaseEntity> kbs = listByAgentId(agentId);
if (kbs.isEmpty()) { if (kbs.isEmpty()) {
return null; return null;
} }
Long primaryKbId = agent != null ? agent.getPrimaryKbId() : null;
if (primaryKbId != null) {
for (WikiKnowledgeBaseEntity kb : kbs) {
if (primaryKbId.equals(kb.getId()) && sameWorkspace(agent, kb)) {
return kb;
}
}
return kbs.get(0);
}
if (agentId != null) { if (agentId != null) {
for (WikiKnowledgeBaseEntity kb : kbs) { for (WikiKnowledgeBaseEntity kb : kbs) {
if (agentId.equals(kb.getAgentId())) { if (agentId.equals(kb.getAgentId())) {
@ -138,9 +151,23 @@ public class WikiKnowledgeBaseService {
return kbs.get(0); return kbs.get(0);
} }
private AgentEntity getAgentOrNull(Long agentId) {
if (agentId == null || agentMapper == null) {
return null;
}
return agentMapper.selectById(agentId);
}
private boolean sameWorkspace(AgentEntity agent, WikiKnowledgeBaseEntity kb) {
if (agent == null || kb == null || agent.getWorkspaceId() == null) {
return true;
}
return kb.getWorkspaceId() == null || agent.getWorkspaceId().equals(kb.getWorkspaceId());
}
/** /**
* Resolve a specific knowledge base by name, restricted to the agent's * Resolve a specific knowledge base by name, restricted to the agent's
* visibility set (agent-bound KBs + shared NULL KBs). Used by wiki tools * workspace-visible KB set. Used by wiki tools
* that accept an optional {@code kbName} parameter so the LLM can target * that accept an optional {@code kbName} parameter so the LLM can target
* a non-primary KB when the agent reaches more than one. * a non-primary KB when the agent reaches more than one.
* <p> * <p>
@ -222,14 +249,13 @@ public class WikiKnowledgeBaseService {
} }
@Transactional @Transactional
public WikiKnowledgeBaseEntity update(Long id, String name, String description, Long agentId) { public WikiKnowledgeBaseEntity update(Long id, String name, String description) {
WikiKnowledgeBaseEntity entity = kbMapper.selectById(id); WikiKnowledgeBaseEntity entity = kbMapper.selectById(id);
if (entity == null) { if (entity == null) {
throw new IllegalArgumentException("Knowledge base not found: " + id); throw new IllegalArgumentException("Knowledge base not found: " + id);
} }
if (name != null) entity.setName(name); if (name != null) entity.setName(name);
if (description != null) entity.setDescription(description); if (description != null) entity.setDescription(description);
if (agentId != null) entity.setAgentId(agentId);
kbMapper.updateById(entity); kbMapper.updateById(entity);
return entity; return entity;
} }

View File

@ -0,0 +1,25 @@
-- V129: Store the per-agent primary wiki KB on mate_agent.
--
-- Knowledge bases remain workspace-shared; this field only chooses the
-- default KB for wiki tools when no kbName/kbId is specified.
ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS primary_kb_id BIGINT DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_agent_primary_kb ON mate_agent(primary_kb_id);
UPDATE mate_agent a
SET primary_kb_id = (
SELECT kb.id
FROM mate_wiki_knowledge_base kb
WHERE kb.agent_id = a.id
AND (kb.workspace_id IS NULL OR kb.workspace_id = a.workspace_id)
AND kb.deleted = 0
ORDER BY kb.update_time DESC
LIMIT 1
)
WHERE a.primary_kb_id IS NULL
AND EXISTS (
SELECT 1
FROM mate_wiki_knowledge_base kb
WHERE kb.agent_id = a.id
AND (kb.workspace_id IS NULL OR kb.workspace_id = a.workspace_id)
AND kb.deleted = 0
);

View File

@ -0,0 +1,45 @@
-- V129: Store the per-agent primary wiki KB on mate_agent (MySQL).
--
-- Knowledge bases remain workspace-shared; this field only chooses the
-- default KB for wiki tools when no kbName/kbId is specified.
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_agent'
AND COLUMN_NAME = 'primary_kb_id'
);
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_agent ADD COLUMN primary_kb_id BIGINT DEFAULT NULL',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @idx_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_agent'
AND INDEX_NAME = 'idx_agent_primary_kb'
);
SET @stmt := IF(@idx_exists = 0,
'CREATE INDEX idx_agent_primary_kb ON mate_agent(primary_kb_id)',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
UPDATE mate_agent a
SET primary_kb_id = (
SELECT kb.id
FROM mate_wiki_knowledge_base kb
WHERE kb.agent_id = a.id
AND (kb.workspace_id IS NULL OR kb.workspace_id = a.workspace_id)
AND kb.deleted = 0
ORDER BY kb.update_time DESC
LIMIT 1
)
WHERE a.primary_kb_id IS NULL
AND EXISTS (
SELECT 1
FROM mate_wiki_knowledge_base kb
WHERE kb.agent_id = a.id
AND (kb.workspace_id IS NULL OR kb.workspace_id = a.workspace_id)
AND kb.deleted = 0
);

View File

@ -28,9 +28,11 @@ CREATE TABLE IF NOT EXISTS mate_agent (
icon VARCHAR(256), icon VARCHAR(256),
tags VARCHAR(256), tags VARCHAR(256),
workspace_id BIGINT NOT NULL DEFAULT 1, workspace_id BIGINT NOT NULL DEFAULT 1,
primary_kb_id BIGINT DEFAULT NULL,
create_time DATETIME NOT NULL, create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL, update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0 deleted INT NOT NULL DEFAULT 0,
INDEX idx_agent_primary_kb (primary_kb_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 模型配置表 -- 模型配置表

View File

@ -30,10 +30,12 @@ CREATE TABLE IF NOT EXISTS mate_agent (
tags VARCHAR(256), tags VARCHAR(256),
workspace_id BIGINT NOT NULL DEFAULT 1, workspace_id BIGINT NOT NULL DEFAULT 1,
default_thinking_level VARCHAR(32) DEFAULT NULL, default_thinking_level VARCHAR(32) DEFAULT NULL,
primary_kb_id BIGINT DEFAULT NULL,
create_time DATETIME NOT NULL, create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL, update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0 deleted INT NOT NULL DEFAULT 0
); );
CREATE INDEX IF NOT EXISTS idx_agent_primary_kb ON mate_agent(primary_kb_id);
-- 模型配置表 -- 模型配置表
CREATE TABLE IF NOT EXISTS mate_model_config ( CREATE TABLE IF NOT EXISTS mate_model_config (

View File

@ -2,6 +2,8 @@ package vip.mate.wiki.service;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; import vip.mate.wiki.repository.WikiKnowledgeBaseMapper;
@ -10,6 +12,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
/** /**
@ -24,8 +27,9 @@ import static org.mockito.Mockito.when;
class WikiKnowledgeBaseServiceTest { class WikiKnowledgeBaseServiceTest {
private final WikiKnowledgeBaseMapper kbMapper = mock(WikiKnowledgeBaseMapper.class); private final WikiKnowledgeBaseMapper kbMapper = mock(WikiKnowledgeBaseMapper.class);
private final AgentMapper agentMapper = mock(AgentMapper.class);
private final WikiKnowledgeBaseService service = new WikiKnowledgeBaseService( private final WikiKnowledgeBaseService service = new WikiKnowledgeBaseService(
kbMapper, null, null, null, null, null); kbMapper, null, null, null, null, null, agentMapper);
private static WikiKnowledgeBaseEntity kb(long id, Long agentId) { private static WikiKnowledgeBaseEntity kb(long id, Long agentId) {
return kb(id, agentId, null); return kb(id, agentId, null);
@ -39,14 +43,29 @@ class WikiKnowledgeBaseServiceTest {
return entity; return entity;
} }
private static WikiKnowledgeBaseEntity kb(long id, Long agentId, Long workspaceId, String name) {
WikiKnowledgeBaseEntity entity = kb(id, agentId, name);
entity.setWorkspaceId(workspaceId);
return entity;
}
private static AgentEntity agent(long id, Long workspaceId, Long primaryKbId) {
AgentEntity entity = new AgentEntity();
entity.setId(id);
entity.setWorkspaceId(workspaceId);
entity.setPrimaryKbId(primaryKbId);
return entity;
}
@Test @Test
@DisplayName("prefers the agent's bound KB even when a shared KB was updated more recently") @DisplayName("prefers the agent's bound KB even when a shared KB was updated more recently")
void prefersBoundKbOverNewerSharedKb() { void prefersBoundKbOverNewerSharedKb() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L));
// listByAgentId order is update_time DESC: two shared KBs precede the bound one. // listByAgentId order is update_time DESC: two shared KBs precede the bound one.
when(kbMapper.selectList(any())).thenReturn(List.of( when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, null), kb(900L, null, 1L, "Shared"),
kb(800L, null), kb(800L, 8L, 1L, "Legacy Other"),
kb(100L, 7L))); kb(100L, null, 1L, "Primary")));
assertThat(service.resolvePrimaryKb(7L)).isNotNull(); assertThat(service.resolvePrimaryKb(7L)).isNotNull();
assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(100L); assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(100L);
@ -55,9 +74,34 @@ class WikiKnowledgeBaseServiceTest {
@Test @Test
@DisplayName("falls back to the most recent shared KB when the agent has no bound KB") @DisplayName("falls back to the most recent shared KB when the agent has no bound KB")
void fallsBackToSharedKbWhenNoneBound() { void fallsBackToSharedKbWhenNoneBound() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, null));
when(kbMapper.selectList(any())).thenReturn(List.of( when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, null), kb(900L, 8L, 1L, "Most Recent"),
kb(800L, null))); kb(800L, null, 1L, "Older")));
assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(900L);
}
@Test
@DisplayName("two agents can share the same primary KB")
void twoAgentsCanSharePrimaryKb() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L));
when(agentMapper.selectById(8L)).thenReturn(agent(8L, 1L, 100L));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, null, 1L, "Shared Primary"),
kb(900L, null, 1L, "Fallback")));
assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(100L);
assertThat(service.resolvePrimaryKb(8L).getId()).isEqualTo(100L);
}
@Test
@DisplayName("primary KB pointing outside the agent workspace falls back")
void primaryKbOutsideWorkspaceFallsBack() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 200L));
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, null, 1L, "Workspace Fallback"),
kb(200L, null, 2L, "Wrong Workspace")));
assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(900L); assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(900L);
} }
@ -167,13 +211,16 @@ class WikiKnowledgeBaseServiceTest {
@Test @Test
@DisplayName("findVisibleById returns the KB only when it is in the agent's visibility set") @DisplayName("findVisibleById returns the KB only when it is in the agent's visibility set")
void findVisibleByIdGate() { void findVisibleByIdGate() {
when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, null));
when(kbMapper.selectList(any())).thenReturn(List.of( when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, 7L, "Bound KB"), kb(100L, 7L, "Bound KB"),
kb(900L, null, "Shared KB"))); kb(900L, null, "Shared KB"),
kb(800L, 8L, "Other Agent Primary KB")));
// Visible: returned. // Visible: returned.
assertThat(service.findVisibleById(7L, 100L)).isNotNull(); assertThat(service.findVisibleById(7L, 100L)).isNotNull();
assertThat(service.findVisibleById(7L, 900L)).isNotNull(); assertThat(service.findVisibleById(7L, 900L)).isNotNull();
assertThat(service.findVisibleById(7L, 800L)).isNotNull();
// Not in visibility set: deliberate fail-closed gate so an LLM // Not in visibility set: deliberate fail-closed gate so an LLM
// can't pivot to an arbitrary KB by guessing an id. // can't pivot to an arbitrary KB by guessing an id.

View File

@ -745,11 +745,12 @@ export const cronJobApi = {
export const wikiApi = { export const wikiApi = {
// Knowledge Base // Knowledge Base
listKBs: () => http.get('/wiki/knowledge-bases'), listKBs: () => http.get('/wiki/knowledge-bases'),
getKB: (id: number) => http.get(`/wiki/knowledge-bases/${id}`), getKB: (id: string | number) => http.get(`/wiki/knowledge-bases/${id}`),
listKBsByAgent: (agentId: number) => http.get(`/wiki/knowledge-bases/agent/${agentId}`), listKBsByAgent: (agentId: string | number) => http.get(`/wiki/knowledge-bases/agent/${agentId}`),
createKB: (data: { name: string; description?: string; agentId?: number }) => listBindableKBs: () => http.get('/wiki/knowledge-bases/bindable'),
createKB: (data: { name: string; description?: string; agentId?: string | number }) =>
http.post('/wiki/knowledge-bases', data), http.post('/wiki/knowledge-bases', data),
updateKB: (id: number, data: { name?: string; description?: string; agentId?: number; embeddingModelId?: string | number | null }) => updateKB: (id: string | number, data: { name?: string; description?: string; embeddingModelId?: string | number | null }) =>
http.put(`/wiki/knowledge-bases/${id}`, data), http.put(`/wiki/knowledge-bases/${id}`, data),
deleteKB: (id: number) => http.delete(`/wiki/knowledge-bases/${id}`), deleteKB: (id: number) => http.delete(`/wiki/knowledge-bases/${id}`),
getConfig: (id: number) => http.get(`/wiki/knowledge-bases/${id}/config`), getConfig: (id: number) => http.get(`/wiki/knowledge-bases/${id}/config`),

View File

@ -1133,6 +1133,7 @@ export default {
skills: 'Skills', skills: 'Skills',
tools: 'Tools', tools: 'Tools',
providers: 'Providers', providers: 'Providers',
wiki: 'Knowledge Base',
context: 'Context', context: 'Context',
}, },
columns: { columns: {
@ -1258,6 +1259,13 @@ export default {
noMatchingSkills: 'No matching skills', noMatchingSkills: 'No matching skills',
noMatchingTools: 'No matching tools', noMatchingTools: 'No matching tools',
noProviderPreferences: 'No preferences set — the agent uses the global fallback chain order.', 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.',
noKBs: 'No knowledge bases available',
noKB: 'No primary KB',
wikiPages: '{count} pages',
wikiLoadFailed: 'Failed to load knowledge bases',
contextHint: 'Manage context files (e.g. AGENT.md) that define this agent\'s behavior, knowledge, and instructions.', contextHint: 'Manage context files (e.g. AGENT.md) that define this agent\'s behavior, knowledge, and instructions.',
goToContext: 'Edit Context Files', goToContext: 'Edit Context Files',
}, },

View File

@ -1025,6 +1025,7 @@ export default {
skills: '技能', skills: '技能',
tools: '工具', tools: '工具',
providers: '偏好提供商', providers: '偏好提供商',
wiki: '知识库',
context: '上下文', context: '上下文',
}, },
columns: { columns: {
@ -1150,6 +1151,13 @@ export default {
noMatchingSkills: '没有匹配的技能', noMatchingSkills: '没有匹配的技能',
noMatchingTools: '没有匹配的工具', noMatchingTools: '没有匹配的工具',
noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。', noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。',
wikiKicker: '主知识库',
wikiTagline: '为智能体指定默认优先使用的知识库,所有知识库仍保持工作区共享。',
wikiHint: '选择此智能体的主知识库。未指定时会按工作区知识库回退。',
noKBs: '暂无可用知识库',
noKB: '未指定主库',
wikiPages: '{count} 页',
wikiLoadFailed: '加载知识库列表失败',
contextHint: '管理此智能体的上下文文件(如 AGENT.md定义智能体的行为、知识和指令。', contextHint: '管理此智能体的上下文文件(如 AGENT.md定义智能体的行为、知识和指令。',
goToContext: '前往编辑上下文', goToContext: '前往编辑上下文',
}, },

View File

@ -43,7 +43,9 @@ export interface Agent {
enabled: boolean enabled: boolean
icon?: string icon?: string
tags?: string tags?: string
workspaceBasePath?: string workspaceBasePath?: string | null
/** Agent-level primary wiki KB. Null means use workspace fallback. */
primaryKbId?: string | number | null
/** /**
* Explicit opt-out: drop every SKILL.md catalog entry from the system * Explicit opt-out: drop every SKILL.md catalog entry from the system
* prompt and exclude skill-expanded tools. Independent of binding rows * prompt and exclude skill-expanded tools. Independent of binding rows

View File

@ -225,6 +225,10 @@
{{ t('agents.tabs.providers', 'Providers') }} {{ t('agents.tabs.providers', 'Providers') }}
<span v-if="selectedProviderIds.length" class="tab-badge">{{ selectedProviderIds.length }}</span> <span v-if="selectedProviderIds.length" class="tab-badge">{{ selectedProviderIds.length }}</span>
</button> </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>
</button>
</div> </div>
<!-- Basic Tab --> <!-- Basic Tab -->
@ -552,6 +556,43 @@
>+ {{ p.name }}</button> >+ {{ p.name }}</button>
</div> </div>
</div> </div>
<!-- Wiki / Knowledge Base Tab -->
<div v-if="modalTab === 'wiki'" class="binding-tab">
<div class="binding-intro">
<span class="binding-intro__kicker">{{ t('agents.binding.wikiKicker') }}</span>
<p class="binding-intro__tagline">{{ t('agents.binding.wikiTagline') }}</p>
</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>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn-secondary" @click="closeModal">{{ t('common.cancel') }}</button> <button class="btn-secondary" @click="closeModal">{{ t('common.cancel') }}</button>
@ -570,7 +611,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast' import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm' import { mcConfirm } from '@/components/common/useConfirm'
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, liveApi } from '@/api/index' import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, liveApi, wikiApi } from '@/api/index'
import type { Agent } from '@/types/index' import type { Agent } from '@/types/index'
import SkillIcon from '@/components/common/SkillIcon.vue' import SkillIcon from '@/components/common/SkillIcon.vue'
import SkillIconPicker from '@/components/common/SkillIconPicker.vue' import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
@ -597,7 +638,7 @@ const searchText = ref('')
const activeFilter = ref('all') const activeFilter = ref('all')
const showModal = ref(false) const showModal = ref(false)
const editingAgent = ref<Agent | null>(null) const editingAgent = ref<Agent | null>(null)
const modalTab = ref<'basic' | 'skills' | 'tools' | 'providers'>('basic') const modalTab = ref<'basic' | 'skills' | 'tools' | 'providers' | 'wiki'>('basic')
/** RFC-090 §9.2 B Tool picker is an Advanced bypass; collapsed by /** RFC-090 §9.2 B Tool picker is an Advanced bypass; collapsed by
* default but stays open as soon as the agent has any direct tool * default but stays open as soon as the agent has any direct tool
* bindings, so existing users don't lose visibility on their picks. */ * bindings, so existing users don't lose visibility on their picks. */
@ -752,6 +793,9 @@ function onSkillToggle(skillId: number | string, event: Event) {
} }
const selectedSkillIds = ref<number[]>([]) const selectedSkillIds = ref<number[]>([])
const selectedToolNames = ref<string[]>([]) const selectedToolNames = ref<string[]>([])
// Agent-level primary wiki KB. KB visibility remains workspace-wide.
const availableKBs = ref<any[]>([])
const selectedKBId = ref<string | null>(null)
// RFC-009 PR-3: per-agent provider preference order // RFC-009 PR-3: per-agent provider preference order
const availableProviders = ref<{ id: string; name: string }[]>([]) const availableProviders = ref<{ id: string; name: string }[]>([])
const selectedProviderIds = ref<string[]>([]) const selectedProviderIds = ref<string[]>([])
@ -789,6 +833,7 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
// Agent type declares this as `string | undefined`; using `undefined` keeps // Agent type declares this as `string | undefined`; using `undefined` keeps
// the Partial<Agent> shape happy without widening the type to allow null. // the Partial<Agent> shape happy without widening the type to allow null.
workspaceBasePath: undefined, workspaceBasePath: undefined,
primaryKbId: null,
// Issue #184 explicit opt-out flags. Default false matches the legacy // Issue #184 explicit opt-out flags. Default false matches the legacy
// "zero rows = inherit global default" contract for newly-created agents. // "zero rows = inherit global default" contract for newly-created agents.
skillsDisabled: false, skillsDisabled: false,
@ -914,6 +959,8 @@ function openBlankCreateModal() {
selectedSkillIds.value = [] selectedSkillIds.value = []
selectedToolNames.value = [] selectedToolNames.value = []
selectedProviderIds.value = [] selectedProviderIds.value = []
availableKBs.value = []
selectedKBId.value = null
showModal.value = true showModal.value = true
} }
@ -981,6 +1028,7 @@ async function openEditModal(agent: Agent) {
enabled: agent.enabled, enabled: agent.enabled,
defaultThinkingLevel: (agent as any).defaultThinkingLevel || null, defaultThinkingLevel: (agent as any).defaultThinkingLevel || null,
workspaceBasePath: agent.workspaceBasePath || undefined, workspaceBasePath: agent.workspaceBasePath || undefined,
primaryKbId: agent.primaryKbId != null ? String(agent.primaryKbId) : null,
skillsDisabled: agent.skillsDisabled === true, skillsDisabled: agent.skillsDisabled === true,
toolsDisabled: agent.toolsDisabled === true, toolsDisabled: agent.toolsDisabled === true,
} }
@ -1022,7 +1070,20 @@ async function openEditModal(agent: Agent) {
.filter((b: any) => b.enabled) .filter((b: any) => b.enabled)
.map((b: any) => b.providerId) .map((b: any) => b.providerId)
} catch { } catch {
// Non-blocking: binding data load failure doesn't prevent editing basic info mcToast.error(t('agents.messages.loadFailed'))
}
// KB request is caught separately so its error message is accurate.
try {
const kbsRes: any = await wikiApi.listBindableKBs()
const bindableKBs = (kbsRes.data || []) as any[]
availableKBs.value = bindableKBs
const primaryKbId = agent.primaryKbId != null ? String(agent.primaryKbId) : null
selectedKBId.value = primaryKbId && bindableKBs.some((kb: any) => String(kb.id) === primaryKbId)
? primaryKbId
: null
} catch {
mcToast.error(t('agents.binding.wikiLoadFailed'))
} }
} }
@ -1031,6 +1092,8 @@ function closeModal() {
editingAgent.value = null editingAgent.value = null
skillBindingSearch.value = '' skillBindingSearch.value = ''
toolBindingSearch.value = '' toolBindingSearch.value = ''
availableKBs.value = []
selectedKBId.value = null
} }
async function saveAgent() { async function saveAgent() {
@ -1039,7 +1102,7 @@ async function saveAgent() {
// sending to the backend the schema is unchanged, only the editor // sending to the backend the schema is unchanged, only the editor
// exposes the H2 sections to the user. // exposes the H2 sections to the user.
const serialized = serializePrompt(profileForm.value) const serialized = serializePrompt(profileForm.value)
const payload = { ...form.value, systemPrompt: serialized } const payload = { ...form.value, systemPrompt: serialized, primaryKbId: selectedKBId.value }
let agentId: string | number let agentId: string | number
if (editingAgent.value) { if (editingAgent.value) {
@ -1528,6 +1591,7 @@ html.dark .seg-count.warn {
.binding-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; } .binding-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.binding-name { font-size: 14px; font-weight: 500; color: var(--mc-text-primary); } .binding-name { font-size: 14px; font-weight: 500; color: var(--mc-text-primary); }
.binding-desc { font-size: 12px; color: var(--mc-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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; } .binding-version { font-size: 11px; color: var(--mc-text-tertiary); flex-shrink: 0; }
.binding-type-badge { .binding-type-badge {
font-size: 10px; padding: 2px 6px; border-radius: 4px; flex-shrink: 0; font-size: 10px; padding: 2px 6px; border-radius: 4px; flex-shrink: 0;