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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.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 + 公共 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) {
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;
}

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),
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;
-- 模型配置表

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '前往编辑上下文',
},

View File

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

View File

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