mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
0ef735588a
commit
bd02734d61
@ -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<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) {
|
||||
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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<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")
|
||||
@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");
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 可访问的知识库。
|
||||
* <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) {
|
||||
return kbMapper.selectList(
|
||||
new LambdaQueryWrapper<WikiKnowledgeBaseEntity>()
|
||||
.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.
|
||||
* <p>
|
||||
* 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<WikiKnowledgeBaseEntity> 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.
|
||||
* <p>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
@ -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
|
||||
);
|
||||
@ -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;
|
||||
|
||||
-- 模型配置表
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`),
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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: '前往编辑上下文',
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -225,6 +225,10 @@
|
||||
{{ t('agents.tabs.providers', 'Providers') }}
|
||||
<span v-if="selectedProviderIds.length" class="tab-badge">{{ selectedProviderIds.length }}</span>
|
||||
</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>
|
||||
|
||||
<!-- Basic Tab -->
|
||||
@ -552,6 +556,43 @@
|
||||
>+ {{ p.name }}</button>
|
||||
</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 class="modal-footer">
|
||||
<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 { mcToast } from '@/composables/useMcToast'
|
||||
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 SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
||||
@ -597,7 +638,7 @@ const searchText = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const showModal = ref(false)
|
||||
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
|
||||
* default but stays open as soon as the agent has any direct tool
|
||||
* 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 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
|
||||
const availableProviders = ref<{ id: string; name: 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
|
||||
// the Partial<Agent> shape happy without widening the type to allow null.
|
||||
workspaceBasePath: undefined,
|
||||
primaryKbId: null,
|
||||
// Issue #184 — explicit opt-out flags. Default false matches the legacy
|
||||
// "zero rows = inherit global default" contract for newly-created agents.
|
||||
skillsDisabled: false,
|
||||
@ -914,6 +959,8 @@ function openBlankCreateModal() {
|
||||
selectedSkillIds.value = []
|
||||
selectedToolNames.value = []
|
||||
selectedProviderIds.value = []
|
||||
availableKBs.value = []
|
||||
selectedKBId.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@ -981,6 +1028,7 @@ async function openEditModal(agent: Agent) {
|
||||
enabled: agent.enabled,
|
||||
defaultThinkingLevel: (agent as any).defaultThinkingLevel || null,
|
||||
workspaceBasePath: agent.workspaceBasePath || undefined,
|
||||
primaryKbId: agent.primaryKbId != null ? String(agent.primaryKbId) : null,
|
||||
skillsDisabled: agent.skillsDisabled === true,
|
||||
toolsDisabled: agent.toolsDisabled === true,
|
||||
}
|
||||
@ -1022,7 +1070,20 @@ async function openEditModal(agent: Agent) {
|
||||
.filter((b: any) => b.enabled)
|
||||
.map((b: any) => b.providerId)
|
||||
} 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
|
||||
skillBindingSearch.value = ''
|
||||
toolBindingSearch.value = ''
|
||||
availableKBs.value = []
|
||||
selectedKBId.value = null
|
||||
}
|
||||
|
||||
async function saveAgent() {
|
||||
@ -1039,7 +1102,7 @@ async function saveAgent() {
|
||||
// sending to the backend — the schema is unchanged, only the editor
|
||||
// exposes the H2 sections to the user.
|
||||
const serialized = serializePrompt(profileForm.value)
|
||||
const payload = { ...form.value, systemPrompt: serialized }
|
||||
const payload = { ...form.value, systemPrompt: serialized, primaryKbId: selectedKBId.value }
|
||||
|
||||
let agentId: string | number
|
||||
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-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; }
|
||||
/* reused for KB pageCount badge in wiki tab */
|
||||
.binding-version { font-size: 11px; color: var(--mc-text-tertiary); flex-shrink: 0; }
|
||||
.binding-type-badge {
|
||||
font-size: 10px; padding: 2px 6px; border-radius: 4px; flex-shrink: 0;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user