From bd02734d61300aa2e101f31ae78196e20b16439c Mon Sep 17 00:00:00 2001 From: lichuan <643079302@qq.com> Date: Fri, 29 May 2026 06:00:52 +0800 Subject: [PATCH] feat(agent): add knowledge base binding tab to agent editor (#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .../agent/controller/AgentController.java | 9 ++- .../vip/mate/agent/model/AgentEntity.java | 9 +++ .../mate/wiki/controller/WikiController.java | 12 +++- .../wiki/model/WikiKnowledgeBaseEntity.java | 1 + .../service/WikiKnowledgeBaseService.java | 58 ++++++++++----- .../migration/h2/V129__agent_primary_kb.sql | 25 +++++++ .../mysql/V129__agent_primary_kb.sql | 45 ++++++++++++ .../src/main/resources/db/schema-mysql.sql | 4 +- .../src/main/resources/db/schema.sql | 2 + .../service/WikiKnowledgeBaseServiceTest.java | 61 ++++++++++++++-- mateclaw-ui/src/api/index.ts | 9 +-- mateclaw-ui/src/i18n/locales/en-US.ts | 8 +++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 8 +++ mateclaw-ui/src/types/index.ts | 4 +- mateclaw-ui/src/views/Agents.vue | 72 +++++++++++++++++-- 15 files changed, 291 insertions(+), 36 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V129__agent_primary_kb.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V129__agent_primary_kb.sql diff --git a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java index 281035c9..c38412a8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java @@ -1,5 +1,6 @@ package vip.mate.agent.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -27,6 +28,7 @@ import vip.mate.workspace.core.service.WorkspaceService; import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -49,6 +51,7 @@ public class AgentController { private final ModelConfigService modelConfigService; private final ModelCapabilityService modelCapabilityService; private final SystemSettingService systemSettingService; + private final ObjectMapper objectMapper; private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); @Operation(summary = "获取Agent列表") @@ -144,10 +147,14 @@ public class AgentController { @Operation(summary = "更新Agent") @PutMapping("/{id}") @RequireWorkspaceRole("member") - public R update(@PathVariable Long id, @RequestBody AgentEntity agent, + public R update(@PathVariable Long id, @RequestBody Map body, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { AgentEntity existing = agentService.getAgent(id); verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId); + AgentEntity agent = objectMapper.convertValue(body, AgentEntity.class); + if (!body.containsKey("primaryKbId")) { + agent.setPrimaryKbId(existing.getPrimaryKbId()); + } agent.setId(id); agent.setWorkspaceId(existing.getWorkspaceId()); // 不允许跨 workspace 迁移 AgentEntity updated = agentService.updateAgent(agent); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java index f2ba494c..65437dca 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java @@ -86,6 +86,15 @@ public class AgentEntity { @TableField(value = "workspace_base_path", updateStrategy = FieldStrategy.ALWAYS) 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 * returns {@link java.util.Collections#emptySet()} from diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 9da79afa..00e0ca04 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -67,6 +67,15 @@ public class WikiController { return R.ok(withLivePageCount(kbService.listByWorkspace(wsId))); } + @RequireWorkspaceRole("viewer") + @Operation(summary = "列出可绑定到指定 Agent 的知识库") + @GetMapping("/knowledge-bases/bindable") + public R> 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") @Operation(summary = "获取知识库详情") @GetMapping("/knowledge-bases/{id}") @@ -131,8 +140,7 @@ public class WikiController { verifyKBWorkspace(id, workspaceId); String name = (String) body.get("name"); String description = (String) body.get("description"); - Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null; - kbService.update(id, name, description, agentId); + kbService.update(id, name, description); // RFC Embedding UI: 允许通过此接口绑定 / 解绑 embedding 模型 if (body.containsKey("embeddingModelId")) { Object v = body.get("embeddingModelId"); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java index b87846b3..3ced4646 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java @@ -51,6 +51,7 @@ public class WikiKnowledgeBaseEntity { * NULL = 使用系统默认(mate_system_setting 的 embedding.default.model.id), * 再无则取任意 enabled 的 embedding 模型,最终全无则语义搜索降级为不可用。 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Long embeddingModelId; @TableField(fill = FieldFill.INSERT) diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index 08448118..49259d1c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -5,6 +5,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.model.WikiChunkEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; @@ -36,6 +38,7 @@ public class WikiKnowledgeBaseService { private final WikiChunkMapper chunkMapper; private final WikiPageCitationMapper citationMapper; private final WikiProcessingJobMapper processingJobMapper; + private final AgentMapper agentMapper; /** * RFC-051 PR-2: optional system-page scaffold (overview / log). Marked @@ -102,32 +105,42 @@ public class WikiKnowledgeBaseService { } /** - * 获取 Agent 可访问的知识库:Agent 专属 KB + 公共 KB(agent_id IS NULL) + * 获取 Agent 可访问的知识库。 + *

+ * 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 listByAgentId(Long agentId) { - return kbMapper.selectList( - new LambdaQueryWrapper() - .and(w -> w.eq(WikiKnowledgeBaseEntity::getAgentId, agentId) - .or().isNull(WikiKnowledgeBaseEntity::getAgentId)) - .orderByDesc(WikiKnowledgeBaseEntity::getUpdateTime)); + AgentEntity agent = getAgentOrNull(agentId); + if (agent == null || agent.getWorkspaceId() == null) { + return listAll(); + } + return listByWorkspace(agent.getWorkspaceId()); } /** * Resolve the single knowledge base an agent's wiki tools should operate on. *

- * Prefers a KB explicitly bound to the agent; a shared (agent-less) KB is - * only used as a fallback when the agent has no bound KB of its own. This - * matters because {@link #listByAgentId} also returns shared KBs, and a - * shared KB with a more recent {@code update_time} would otherwise win the - * {@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. + * Prefers mate_agent.primary_kb_id when it points to a KB in the same + * workspace. For legacy rows that predate primary_kb_id, falls back to the + * old mate_wiki_knowledge_base.agent_id marker only when no primary is set. + * Otherwise the most recently updated workspace KB wins. */ public WikiKnowledgeBaseEntity resolvePrimaryKb(Long agentId) { + AgentEntity agent = getAgentOrNull(agentId); List kbs = listByAgentId(agentId); if (kbs.isEmpty()) { 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) { for (WikiKnowledgeBaseEntity kb : kbs) { if (agentId.equals(kb.getAgentId())) { @@ -138,9 +151,23 @@ public class WikiKnowledgeBaseService { 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 - * 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 * a non-primary KB when the agent reaches more than one. *

@@ -222,14 +249,13 @@ public class WikiKnowledgeBaseService { } @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); if (entity == null) { throw new IllegalArgumentException("Knowledge base not found: " + id); } if (name != null) entity.setName(name); if (description != null) entity.setDescription(description); - if (agentId != null) entity.setAgentId(agentId); kbMapper.updateById(entity); return entity; } diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V129__agent_primary_kb.sql b/mateclaw-server/src/main/resources/db/migration/h2/V129__agent_primary_kb.sql new file mode 100644 index 00000000..ab10c0b0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V129__agent_primary_kb.sql @@ -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 + ); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V129__agent_primary_kb.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V129__agent_primary_kb.sql new file mode 100644 index 00000000..e08fa00e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V129__agent_primary_kb.sql @@ -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 + ); diff --git a/mateclaw-server/src/main/resources/db/schema-mysql.sql b/mateclaw-server/src/main/resources/db/schema-mysql.sql index c00f11d0..7c864198 100644 --- a/mateclaw-server/src/main/resources/db/schema-mysql.sql +++ b/mateclaw-server/src/main/resources/db/schema-mysql.sql @@ -28,9 +28,11 @@ CREATE TABLE IF NOT EXISTS mate_agent ( icon VARCHAR(256), tags VARCHAR(256), workspace_id BIGINT NOT NULL DEFAULT 1, + primary_kb_id BIGINT DEFAULT NULL, create_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; -- 模型配置表 diff --git a/mateclaw-server/src/main/resources/db/schema.sql b/mateclaw-server/src/main/resources/db/schema.sql index 664c80c0..5b8b277f 100644 --- a/mateclaw-server/src/main/resources/db/schema.sql +++ b/mateclaw-server/src/main/resources/db/schema.sql @@ -30,10 +30,12 @@ CREATE TABLE IF NOT EXISTS mate_agent ( tags VARCHAR(256), workspace_id BIGINT NOT NULL DEFAULT 1, default_thinking_level VARCHAR(32) DEFAULT NULL, + primary_kb_id BIGINT DEFAULT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, 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 ( diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java index a965e820..6e445242 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java @@ -2,6 +2,8 @@ package vip.mate.wiki.service; import org.junit.jupiter.api.DisplayName; 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.repository.WikiKnowledgeBaseMapper; @@ -10,6 +12,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -24,8 +27,9 @@ import static org.mockito.Mockito.when; class WikiKnowledgeBaseServiceTest { private final WikiKnowledgeBaseMapper kbMapper = mock(WikiKnowledgeBaseMapper.class); + private final AgentMapper agentMapper = mock(AgentMapper.class); 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) { return kb(id, agentId, null); @@ -39,14 +43,29 @@ class WikiKnowledgeBaseServiceTest { 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 @DisplayName("prefers the agent's bound KB even when a shared KB was updated more recently") void prefersBoundKbOverNewerSharedKb() { + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, 100L)); // listByAgentId order is update_time DESC: two shared KBs precede the bound one. when(kbMapper.selectList(any())).thenReturn(List.of( - kb(900L, null), - kb(800L, null), - kb(100L, 7L))); + kb(900L, null, 1L, "Shared"), + kb(800L, 8L, 1L, "Legacy Other"), + kb(100L, null, 1L, "Primary"))); assertThat(service.resolvePrimaryKb(7L)).isNotNull(); assertThat(service.resolvePrimaryKb(7L).getId()).isEqualTo(100L); @@ -55,9 +74,34 @@ class WikiKnowledgeBaseServiceTest { @Test @DisplayName("falls back to the most recent shared KB when the agent has no bound KB") void fallsBackToSharedKbWhenNoneBound() { + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, null)); when(kbMapper.selectList(any())).thenReturn(List.of( - kb(900L, null), - kb(800L, null))); + kb(900L, 8L, 1L, "Most Recent"), + 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); } @@ -167,13 +211,16 @@ class WikiKnowledgeBaseServiceTest { @Test @DisplayName("findVisibleById returns the KB only when it is in the agent's visibility set") void findVisibleByIdGate() { + when(agentMapper.selectById(7L)).thenReturn(agent(7L, 1L, null)); when(kbMapper.selectList(any())).thenReturn(List.of( kb(100L, 7L, "Bound KB"), - kb(900L, null, "Shared KB"))); + kb(900L, null, "Shared KB"), + kb(800L, 8L, "Other Agent Primary KB"))); // Visible: returned. assertThat(service.findVisibleById(7L, 100L)).isNotNull(); assertThat(service.findVisibleById(7L, 900L)).isNotNull(); + assertThat(service.findVisibleById(7L, 800L)).isNotNull(); // Not in visibility set: deliberate fail-closed gate so an LLM // can't pivot to an arbitrary KB by guessing an id. diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index aa7aacad..0f7076f4 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -745,11 +745,12 @@ export const cronJobApi = { export const wikiApi = { // Knowledge Base listKBs: () => http.get('/wiki/knowledge-bases'), - getKB: (id: number) => http.get(`/wiki/knowledge-bases/${id}`), - listKBsByAgent: (agentId: number) => http.get(`/wiki/knowledge-bases/agent/${agentId}`), - createKB: (data: { name: string; description?: string; agentId?: number }) => + getKB: (id: string | number) => http.get(`/wiki/knowledge-bases/${id}`), + listKBsByAgent: (agentId: string | number) => http.get(`/wiki/knowledge-bases/agent/${agentId}`), + listBindableKBs: () => http.get('/wiki/knowledge-bases/bindable'), + createKB: (data: { name: string; description?: string; agentId?: string | number }) => 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), deleteKB: (id: number) => http.delete(`/wiki/knowledge-bases/${id}`), getConfig: (id: number) => http.get(`/wiki/knowledge-bases/${id}/config`), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 53f94585..df0bddb6 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1133,6 +1133,7 @@ export default { skills: 'Skills', tools: 'Tools', providers: 'Providers', + wiki: 'Knowledge Base', context: 'Context', }, columns: { @@ -1258,6 +1259,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.', + 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.', goToContext: 'Edit Context Files', }, diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 9a4f98a9..b225ef10 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1025,6 +1025,7 @@ export default { skills: '技能', tools: '工具', providers: '偏好提供商', + wiki: '知识库', context: '上下文', }, columns: { @@ -1150,6 +1151,13 @@ export default { noMatchingSkills: '没有匹配的技能', noMatchingTools: '没有匹配的工具', noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。', + wikiKicker: '主知识库', + wikiTagline: '为智能体指定默认优先使用的知识库,所有知识库仍保持工作区共享。', + wikiHint: '选择此智能体的主知识库。未指定时会按工作区知识库回退。', + noKBs: '暂无可用知识库', + noKB: '未指定主库', + wikiPages: '{count} 页', + wikiLoadFailed: '加载知识库列表失败', contextHint: '管理此智能体的上下文文件(如 AGENT.md),定义智能体的行为、知识和指令。', goToContext: '前往编辑上下文', }, diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 9bc3eb38..dfdec8c2 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -43,7 +43,9 @@ export interface Agent { enabled: boolean icon?: 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 * prompt and exclude skill-expanded tools. Independent of binding rows diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index 4dcd98a0..b561c280 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -225,6 +225,10 @@ {{ t('agents.tabs.providers', 'Providers') }} {{ selectedProviderIds.length }} + @@ -552,6 +556,43 @@ >+ {{ p.name }} + + +

+
+ {{ t('agents.binding.wikiKicker') }} +

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

+
+

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

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