feat(skill): scope skill catalog and runtime by workspace (#135)

This commit is contained in:
matevip 2026-05-15 20:01:25 +08:00
parent 1f9adeb31d
commit 3b9b4d79d5
34 changed files with 1103 additions and 138 deletions

View File

@ -1000,10 +1000,10 @@ public class AgentGraphBuilder {
} }
String basePrompt = basePromptBuilder.toString(); String basePrompt = basePromptBuilder.toString();
// 使用 skill runtime 构建技能增强per-agent 绑定过滤 // 使用 skill runtime 构建技能增强per-agent 绑定过滤 + 工作区隔离
Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId()); Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement( String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(
boundSkillIds, boundTools, maxInputTokens, entity.getId()); boundSkillIds, boundTools, maxInputTokens, entity.getId(), entity.getWorkspaceId());
// 工具调用指导 // 工具调用指导
String toolGuidance = """ String toolGuidance = """

View File

@ -200,11 +200,11 @@ public class AgentBindingService {
* then apply the same workspace comparison.</li> * then apply the same workspace comparison.</li>
* </ul> * </ul>
* *
* <p>Most {@code mate_skill} rows currently sit in the default workspace * <p>Builtin skills are exempt: they are global capabilities seeded
* (id=1) because skill creation doesn't yet honor the * once into the default workspace and shared with every workspace, so
* {@code X-Workspace-Id} header; the real-skill branch is therefore * any agent may bind them regardless of its own workspace. Only
* defense-in-depth right now and flips on automatically the moment * workspace-owned skills (dynamic / installed / synthesized) are
* workspace-scoped skill creation lands. ACP enforcement is live today. * tenancy-checked.
* *
* @throws MateClawException 404 if the agent or skill doesn't exist; * @throws MateClawException 404 if the agent or skill doesn't exist;
* 403 on a workspace mismatch. * 403 on a workspace mismatch.
@ -241,6 +241,12 @@ public class AgentBindingService {
throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId); throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId);
} }
} }
// Builtin skills are global shared across every workspace, so any
// agent in any workspace may bind them (same stance as MCP virtuals
// above). Only workspace-owned skills are tenancy-checked.
if (Boolean.TRUE.equals(skill.getBuiltin())) {
return;
}
long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId(); long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId(); long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId();
if (agentWs != skillWs) { if (agentWs != skillWs) {

View File

@ -127,6 +127,41 @@ public class CronJobLifecycleService {
.set(CronJobRunEntity::getErrorMessage, StrUtil.maxLength(message, 1000))); .set(CronJobRunEntity::getErrorMessage, StrUtil.maxLength(message, 1000)));
} }
/**
* Insert a {@code running} run row for a task type that does not produce
* a conversation (e.g. {@code wiki_process}). No header / user message is
* saved and no conversation row is touched, because there is no recipient
* to surface the run to.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public CronJobRunEntity startSystemRun(CronJobEntity job, String triggerType) {
CronJobRunEntity run = new CronJobRunEntity();
run.setCronJobId(job.getId());
run.setConversationId(null);
run.setStatus("running");
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
run.setStartedAt(LocalDateTime.now());
run.setDeliveryStatus("NONE");
runMapper.insert(run);
return run;
}
/**
* Mark a {@link #startSystemRun system run} as succeeded with a short
* description of what was done (e.g. "queued 5 raw materials"). The
* description lands in {@code error_message} so the dashboard's existing
* "last run" line surfaces it without a schema change.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markRunSucceeded(CronJobRunEntity run, String description) {
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getId, run.getId())
.set(CronJobRunEntity::getStatus, "succeeded")
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
.set(CronJobRunEntity::getErrorMessage,
description != null ? StrUtil.maxLength(description, 1000) : null));
}
/** /**
* T2 short transaction: persist the assistant reply, mark the run * T2 short transaction: persist the assistant reply, mark the run
* succeeded, then publish the two domain events. The * succeeded, then publish the two domain events. The

View File

@ -1,5 +1,7 @@
package vip.mate.cron.service; package vip.mate.cron.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.AssistantMessage;
@ -9,6 +11,7 @@ import vip.mate.agent.context.ChatOrigin;
import vip.mate.cron.CronChatOriginFactory; import vip.mate.cron.CronChatOriginFactory;
import vip.mate.cron.model.CronJobEntity; import vip.mate.cron.model.CronJobEntity;
import vip.mate.dashboard.model.CronJobRunEntity; import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.wiki.service.WikiProcessingService;
/** /**
* RFC-063r §2.7.1: scheduler-facing orchestrator that decomposes one cron * RFC-063r §2.7.1: scheduler-facing orchestrator that decomposes one cron
@ -42,6 +45,8 @@ public class CronJobRunner {
private final AgentService agentService; private final AgentService agentService;
private final CronChatOriginFactory originFactory; private final CronChatOriginFactory originFactory;
private final vip.mate.cron.CronConversationResolver conversationResolver; private final vip.mate.cron.CronConversationResolver conversationResolver;
private final WikiProcessingService wikiProcessingService;
private final ObjectMapper objectMapper;
/** /**
* Scheduler-facing entry. Runs three logical segments: * Scheduler-facing entry. Runs three logical segments:
@ -66,6 +71,16 @@ public class CronJobRunner {
log.warn("[CronRunner] executeJob called with null job — ignoring"); log.warn("[CronRunner] executeJob called with null job — ignoring");
return; return;
} }
// task_type='wiki_process' system task with no conversation /
// channel delivery. Parse the wiki-process payload from request_body,
// queue the KB's raw materials for asynchronous processing, and write
// a standalone run record.
if ("wiki_process".equals(job.getTaskType())) {
executeWikiProcess(job, triggerType);
return;
}
String userMessage = "agent".equals(job.getTaskType()) String userMessage = "agent".equals(job.getTaskType())
? job.getRequestBody() ? job.getRequestBody()
: job.getTriggerMessage(); : job.getTriggerMessage();
@ -144,6 +159,90 @@ public class CronJobRunner {
} }
} }
/**
* Execute a {@code wiki_process} job: parse the KB id (+ force flag) from
* {@link CronJobEntity#getRequestBody()}, queue the KB's raw materials,
* and record a standalone run row. No conversation, no LLM call, no
* channel delivery every other cron task type goes through the agent
* path, but this one talks directly to the wiki processing service.
*/
private void executeWikiProcess(CronJobEntity job, String triggerType) {
CronJobRunEntity run;
try {
run = lifecycle.startSystemRun(job, triggerType);
} catch (Exception e) {
log.error("[CronRunner] startSystemRun failed for wiki_process job {}: {}",
job.getId(), e.getMessage(), e);
return;
}
Long kbId;
boolean force;
try {
JsonNode payload = parsePayload(job.getRequestBody());
kbId = readKbId(payload);
force = payload != null && payload.hasNonNull("force") && payload.get("force").asBoolean(false);
} catch (Exception e) {
log.error("[CronRunner] wiki_process payload parse failed for job {}: {}",
job.getId(), e.getMessage());
try {
lifecycle.markRunFailed(run, e);
} catch (Exception markErr) {
log.warn("[CronRunner] markRunFailed after payload-parse failure also failed for run {}: {}",
run.getId(), markErr.getMessage());
}
return;
}
try {
int queued = wikiProcessingService.processKB(kbId, force);
String description = "queued " + queued + " raw material(s)" + (force ? " (force)" : "");
lifecycle.markRunSucceeded(run, description);
} catch (Exception e) {
log.error("[CronRunner] wiki_process job {} failed for kbId={}: {}",
job.getId(), kbId, e.getMessage(), e);
try {
lifecycle.markRunFailed(run, e);
} catch (Exception markErr) {
log.warn("[CronRunner] markRunFailed after wiki_process failure also failed for run {}: {}",
run.getId(), markErr.getMessage());
}
}
}
private JsonNode parsePayload(String requestBody) throws Exception {
if (requestBody == null || requestBody.isBlank()) {
throw new IllegalArgumentException("wiki_process requires a non-empty request_body");
}
return objectMapper.readTree(requestBody);
}
/**
* Read {@code kbId} from a wiki_process payload, accepting either a JSON
* number or a JSON string (mirrors the Snowflake precision contract:
* frontend may send IDs as strings to avoid Number truncation).
*/
private Long readKbId(JsonNode payload) {
if (payload == null || !payload.hasNonNull("kbId")) {
throw new IllegalArgumentException("wiki_process payload missing kbId");
}
JsonNode v = payload.get("kbId");
if (v.isNumber()) {
return v.asLong();
}
if (v.isTextual()) {
String text = v.asText().trim();
if (!text.isEmpty()) {
try {
return Long.parseLong(text);
} catch (NumberFormatException ignored) {
// fall through to exception below
}
}
}
throw new IllegalArgumentException("wiki_process payload has unparseable kbId: " + v);
}
/** /**
* Runs the agent with the cron-derived {@link ChatOrigin} and the * Runs the agent with the cron-derived {@link ChatOrigin} and the
* RFC-063r §2.13 system-prompt guard prepended when the cron is bound to * RFC-063r §2.13 system-prompt guard prepended when the cron is bound to

View File

@ -2,6 +2,8 @@ package vip.mate.cron.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -24,6 +26,8 @@ import vip.mate.cron.model.CronJobDTO;
import vip.mate.cron.model.CronJobEntity; import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.repository.CronJobMapper; import vip.mate.cron.repository.CronJobMapper;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.repository.WikiKnowledgeBaseMapper;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
@ -56,6 +60,8 @@ public class CronJobService implements ApplicationRunner {
private final CronJobMapper cronJobMapper; private final CronJobMapper cronJobMapper;
private final AgentMapper agentMapper; private final AgentMapper agentMapper;
private final ChannelMapper channelMapper; private final ChannelMapper channelMapper;
private final WikiKnowledgeBaseMapper wikiKnowledgeBaseMapper;
private final ObjectMapper objectMapper;
/** /**
* RFC-03 Lane G2: distributed lock for fire-time execution. ShedLock's * RFC-03 Lane G2: distributed lock for fire-time execution. ShedLock's
* JDBC provider is configured in {@link vip.mate.cron.config.ShedLockConfig} * JDBC provider is configured in {@link vip.mate.cron.config.ShedLockConfig}
@ -245,6 +251,13 @@ public class CronJobService implements ApplicationRunner {
if (entity.getTimezone() == null) entity.setTimezone("Asia/Shanghai"); if (entity.getTimezone() == null) entity.setTimezone("Asia/Shanghai");
if (entity.getTaskType() == null) entity.setTaskType("text"); if (entity.getTaskType() == null) entity.setTaskType("text");
if (entity.getEnabled() == null) entity.setEnabled(true); if (entity.getEnabled() == null) entity.setEnabled(true);
// System task types (e.g. wiki_process) have no agent binding, but the
// mate_cron_job.agent_id column is NOT NULL substitute a 0 sentinel
// so the row inserts and the natural unique key (ws, agent, name)
// still detects duplicates.
if (entity.getAgentId() == null && "wiki_process".equals(entity.getTaskType())) {
entity.setAgentId(0L);
}
entity.setNextRunTime(calcNextRunTime(springCron, entity.getTimezone())); entity.setNextRunTime(calcNextRunTime(springCron, entity.getTimezone()));
try { try {
@ -330,7 +343,13 @@ public class CronJobService implements ApplicationRunner {
existing.setName(dto.getName()); existing.setName(dto.getName());
existing.setCronExpression(dto.getCronExpression()); existing.setCronExpression(dto.getCronExpression());
existing.setTimezone(dto.getTimezone() != null ? dto.getTimezone() : "Asia/Shanghai"); existing.setTimezone(dto.getTimezone() != null ? dto.getTimezone() : "Asia/Shanghai");
existing.setAgentId(dto.getAgentId()); // wiki_process has no agent binding keep the NOT NULL constraint
// satisfied with a 0 sentinel (same convention as create()).
Long newAgentIdForUpdate = dto.getAgentId();
if (newAgentIdForUpdate == null && "wiki_process".equals(dto.getTaskType())) {
newAgentIdForUpdate = 0L;
}
existing.setAgentId(newAgentIdForUpdate);
existing.setTaskType(dto.getTaskType()); existing.setTaskType(dto.getTaskType());
existing.setTriggerMessage(dto.getTriggerMessage()); existing.setTriggerMessage(dto.getTriggerMessage());
existing.setRequestBody(dto.getRequestBody()); existing.setRequestBody(dto.getRequestBody());
@ -651,13 +670,15 @@ public class CronJobService implements ApplicationRunner {
if (dto.getName() == null || dto.getName().isBlank()) { if (dto.getName() == null || dto.getName().isBlank()) {
throw new MateClawException("err.cron.name_required", "任务名称不能为空"); throw new MateClawException("err.cron.name_required", "任务名称不能为空");
} }
if (dto.getAgentId() == null) {
throw new MateClawException("err.cron.agent_required", "请选择关联 Agent");
}
if (dto.getCronExpression() == null || dto.getCronExpression().isBlank()) { if (dto.getCronExpression() == null || dto.getCronExpression().isBlank()) {
throw new MateClawException("err.cron.expression_required", "Cron 表达式不能为空"); throw new MateClawException("err.cron.expression_required", "Cron 表达式不能为空");
} }
String taskType = dto.getTaskType() != null ? dto.getTaskType() : "text"; String taskType = dto.getTaskType() != null ? dto.getTaskType() : "text";
// wiki_process is a system task with no agent; every other task type
// needs an agent binding.
if (!"wiki_process".equals(taskType) && dto.getAgentId() == null) {
throw new MateClawException("err.cron.agent_required", "请选择关联 Agent");
}
// 'text' (LLM chat) and 'reminder' (direct push) both rely on triggerMessage. // 'text' (LLM chat) and 'reminder' (direct push) both rely on triggerMessage.
if (("text".equals(taskType) || "reminder".equals(taskType)) if (("text".equals(taskType) || "reminder".equals(taskType))
&& (dto.getTriggerMessage() == null || dto.getTriggerMessage().isBlank())) { && (dto.getTriggerMessage() == null || dto.getTriggerMessage().isBlank())) {
@ -666,5 +687,52 @@ public class CronJobService implements ApplicationRunner {
if ("agent".equals(taskType) && (dto.getRequestBody() == null || dto.getRequestBody().isBlank())) { if ("agent".equals(taskType) && (dto.getRequestBody() == null || dto.getRequestBody().isBlank())) {
throw new MateClawException("err.cron.target_required", "执行目标不能为空"); throw new MateClawException("err.cron.target_required", "执行目标不能为空");
} }
if ("wiki_process".equals(taskType)) {
validateWikiProcessPayload(dto.getRequestBody());
}
}
/**
* Validate a {@code wiki_process} payload: the body must be JSON with a
* {@code kbId} field (number or string) that resolves to an existing
* knowledge base. Accepting both number and string mirrors the Snowflake
* precision contract the UI may serialize the id as a string to avoid
* losing the trailing digits in JS Number.
*/
private void validateWikiProcessPayload(String requestBody) {
if (requestBody == null || requestBody.isBlank()) {
throw new MateClawException("err.cron.wiki_kb_required", "请选择知识库");
}
Long kbId;
try {
JsonNode payload = objectMapper.readTree(requestBody);
if (payload == null || !payload.hasNonNull("kbId")) {
throw new MateClawException("err.cron.wiki_kb_required", "请选择知识库");
}
JsonNode v = payload.get("kbId");
if (v.isNumber()) {
kbId = v.asLong();
} else if (v.isTextual()) {
String text = v.asText().trim();
if (text.isEmpty()) {
throw new MateClawException("err.cron.wiki_kb_required", "请选择知识库");
}
try {
kbId = Long.parseLong(text);
} catch (NumberFormatException nfe) {
throw new MateClawException("err.cron.wiki_kb_invalid", "知识库 ID 格式不合法");
}
} else {
throw new MateClawException("err.cron.wiki_kb_invalid", "知识库 ID 格式不合法");
}
} catch (MateClawException e) {
throw e;
} catch (Exception e) {
throw new MateClawException("err.cron.wiki_kb_invalid", "知识库参数解析失败");
}
WikiKnowledgeBaseEntity kb = wikiKnowledgeBaseMapper.selectById(kbId);
if (kb == null) {
throw new MateClawException("err.cron.wiki_kb_not_found", "知识库不存在");
}
} }
} }

View File

@ -65,6 +65,7 @@ public class SkillController {
@GetMapping @GetMapping
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<IPage<SkillEntity>> list( public R<IPage<SkillEntity>> list(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@ -78,9 +79,10 @@ public class SkillController {
Set<Long> pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of(); Set<Long> pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of();
if (pinnedSkillIds == null) pinnedSkillIds = Set.of(); if (pinnedSkillIds == null) pinnedSkillIds = Set.of();
IPage<SkillEntity> dbPage = skillService.pageSkills( IPage<SkillEntity> dbPage = skillService.pageSkills(
page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime, pinnedSkillIds); page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime,
pinnedSkillIds, workspaceId);
List<SkillEntity> virtualSkills = visibleVirtualSkills( List<SkillEntity> virtualSkills = visibleVirtualSkills(
keyword, skillType, enabled, scanStatus, sort, source, runtime); workspaceId, keyword, skillType, enabled, scanStatus, sort, source, runtime);
if (!virtualSkills.isEmpty()) { if (!virtualSkills.isEmpty()) {
VirtualPageMergeResult merged = mergeVirtualTailPageRecords( VirtualPageMergeResult merged = mergeVirtualTailPageRecords(
dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size); dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size);
@ -93,9 +95,10 @@ public class SkillController {
@Operation(summary = "获取各类型技能计数tab 徽章用)") @Operation(summary = "获取各类型技能计数tab 徽章用)")
@GetMapping("/counts") @GetMapping("/counts")
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<Map<String, Long>> counts() { public R<Map<String, Long>> counts(
Map<String, Long> result = skillService.countByType(); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
Set<String> realNames = realSkillNames(); Map<String, Long> result = skillService.countByType(workspaceId);
Set<String> realNames = realSkillNames(workspaceId);
// RFC-090 §3.2 virtual MCP-derived skills aren't in mate_skill, // RFC-090 §3.2 virtual MCP-derived skills aren't in mate_skill,
// so countByType() misses them. Fold in the live count so the // so countByType() misses them. Fold in the live count so the
// "MCP" and "all" tab badges match what the list endpoint shows. // "MCP" and "all" tab badges match what the list endpoint shows.
@ -185,7 +188,8 @@ public class SkillController {
record VirtualPageMergeResult(List<SkillEntity> records, long total) {} record VirtualPageMergeResult(List<SkillEntity> records, long total) {}
private List<SkillEntity> visibleVirtualSkills(String keyword, private List<SkillEntity> visibleVirtualSkills(Long workspaceId,
String keyword,
String skillType, String skillType,
Boolean enabled, Boolean enabled,
String scanStatus, String scanStatus,
@ -197,7 +201,7 @@ public class SkillController {
boolean includeAcpVirtuals = isAllSkillType(effectiveSource) || "acp".equalsIgnoreCase(effectiveSource); boolean includeAcpVirtuals = isAllSkillType(effectiveSource) || "acp".equalsIgnoreCase(effectiveSource);
if (!includeMcpVirtuals && !includeAcpVirtuals) return List.of(); if (!includeMcpVirtuals && !includeAcpVirtuals) return List.of();
Set<String> realNames = realSkillNames(); Set<String> realNames = realSkillNames(workspaceId);
List<SkillEntity> result = new ArrayList<>(); List<SkillEntity> result = new ArrayList<>();
if (includeMcpVirtuals) { if (includeMcpVirtuals) {
try { try {
@ -245,8 +249,14 @@ public class SkillController {
return value != null && value.toLowerCase().contains(lowerCaseNeedle); return value != null && value.toLowerCase().contains(lowerCaseNeedle);
} }
private Set<String> realSkillNames() { /**
return skillService.listSkills().stream() * Names of every real {@code mate_skill} row visible in {@code
* workspaceId} (builtin + workspace-owned). Used to shadow same-named
* MCP/ACP virtual skills so the catalog never shows two cards for one
* capability.
*/
private Set<String> realSkillNames(Long workspaceId) {
return skillService.listSkills(workspaceId).stream()
.map(SkillEntity::getName) .map(SkillEntity::getName)
.collect(java.util.stream.Collectors.toSet()); .collect(java.util.stream.Collectors.toSet());
} }
@ -254,8 +264,10 @@ public class SkillController {
@Operation(summary = "重新扫描单个技能RFC-042 §2.3.4") @Operation(summary = "重新扫描单个技能RFC-042 §2.3.4")
@PostMapping("/{id}/rescan") @PostMapping("/{id}/rescan")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<SkillEntity> rescan(@PathVariable Long id) { public R<SkillEntity> rescan(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id); rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
return R.ok(skillService.rescanSecurity(id)); return R.ok(skillService.rescanSecurity(id));
} }
@ -265,9 +277,11 @@ public class SkillController {
"down to disk; if no rows exist yet but local files do, ingests them into the canonical store.") "down to disk; if no rows exist yet but local files do, ingests them into the canonical store.")
@PostMapping("/{id}/sync-files") @PostMapping("/{id}/sync-files")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<Map<String, Object>> syncFiles(@PathVariable Long id) { public R<Map<String, Object>> syncFiles(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id); rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id); SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
var report = skillFileSyncer.syncOne(skill); var report = skillFileSyncer.syncOne(skill);
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("skillId", id); body.put("skillId", id);
@ -314,18 +328,41 @@ public class SkillController {
} }
} }
/**
* Reject access to a skill that the request's workspace doesn't own.
* Builtin skills are global and exempt every workspace may read and
* (where role permits) toggle them. The interceptor already verified
* the caller's role inside {@code workspaceId}; this guard closes the
* remaining gap where a member of workspace B targets a skill id that
* actually belongs to workspace A.
*/
private void verifyResourceWorkspace(SkillEntity skill, Long headerWorkspaceId) {
if (skill == null || Boolean.TRUE.equals(skill.getBuiltin())) {
return;
}
long requested = headerWorkspaceId != null
? headerWorkspaceId : SkillService.DEFAULT_WORKSPACE_ID;
long owner = skill.getWorkspaceId() != null
? skill.getWorkspaceId() : SkillService.DEFAULT_WORKSPACE_ID;
if (owner != requested) {
throw new vip.mate.exception.MateClawException("err.common.wrong_workspace", 403,
"Skill " + skill.getId() + " does not belong to the current workspace");
}
}
@Operation(summary = "获取已启用技能列表") @Operation(summary = "获取已启用技能列表")
@GetMapping("/enabled") @GetMapping("/enabled")
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<List<SkillEntity>> listEnabled() { public R<List<SkillEntity>> listEnabled(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// Mirror the merging the paginated /skills endpoint does so the agent // Mirror the merging the paginated /skills endpoint does so the agent
// edit picker (which calls this endpoint) sees MCP- and ACP-derived // edit picker (which calls this endpoint) sees MCP- and ACP-derived
// virtual skills alongside the persisted ones. The shadow base must // virtual skills alongside the persisted ones. The shadow base must
// include all real skill names including disabled ones so a // include all real skill names including disabled ones so a
// disabled real skill correctly suppresses its same-named virtual // disabled real skill correctly suppresses its same-named virtual
// twin, matching /skills and /counts. // twin, matching /skills and /counts.
List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills()); List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills(workspaceId));
Set<String> realNames = realSkillNames(); Set<String> realNames = realSkillNames(workspaceId);
try { try {
result.addAll(filterShadowedVirtualSkills( result.addAll(filterShadowedVirtualSkills(
@ -345,21 +382,24 @@ public class SkillController {
@Operation(summary = "按类型获取技能列表") @Operation(summary = "按类型获取技能列表")
@GetMapping("/type/{skillType}") @GetMapping("/type/{skillType}")
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<List<SkillEntity>> listByType(@PathVariable String skillType) { public R<List<SkillEntity>> listByType(@PathVariable String skillType,
return R.ok(skillService.listSkillsByType(skillType)); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(skillService.listSkillsByType(skillType, workspaceId));
} }
@Operation(summary = "获取已启用技能摘要(按类型分组)") @Operation(summary = "获取已启用技能摘要(按类型分组)")
@GetMapping("/summary") @GetMapping("/summary")
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<Map<String, List<String>>> summary() { public R<Map<String, List<String>>> summary(
return R.ok(skillService.getEnabledSkillSummary()); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(skillService.getEnabledSkillSummary(workspaceId));
} }
@Operation(summary = "获取技能详情") @Operation(summary = "获取技能详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<SkillEntity> get(@PathVariable Long id) { public R<SkillEntity> get(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// RFC-090 §3.2 virtual MCP-derived skills synthesize a row // RFC-090 §3.2 virtual MCP-derived skills synthesize a row
// on demand from the live MCP server entity. // on demand from the live MCP server entity.
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) { if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) {
@ -375,21 +415,31 @@ public class SkillController {
SkillEntity ent = acpSkillBridge.findEntityById(id); SkillEntity ent = acpSkillBridge.findEntityById(id);
return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id); return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id);
} }
return R.ok(skillService.getSkill(id)); SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
return R.ok(skill);
} }
@Operation(summary = "创建技能") @Operation(summary = "创建技能")
@PostMapping @PostMapping
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<SkillEntity> create(@RequestBody SkillEntity skill) { public R<SkillEntity> create(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestBody SkillEntity skill) {
// Always stamp the owning workspace from the request context never
// trust a workspaceId in the request body.
skill.setWorkspaceId(workspaceId != null
? workspaceId : SkillService.DEFAULT_WORKSPACE_ID);
return R.ok(skillService.createSkill(skill)); return R.ok(skillService.createSkill(skill));
} }
@Operation(summary = "更新技能") @Operation(summary = "更新技能")
@PutMapping("/{id}") @PutMapping("/{id}")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<SkillEntity> update(@PathVariable Long id, @RequestBody SkillEntity skill) { public R<SkillEntity> update(@PathVariable Long id, @RequestBody SkillEntity skill,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id); rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
skill.setId(id); skill.setId(id);
return R.ok(skillService.updateSkill(skill)); return R.ok(skillService.updateSkill(skill));
} }
@ -406,8 +456,10 @@ public class SkillController {
@Operation(summary = "硬删除技能 (admin only — 物理删除 + 工作区清空)") @Operation(summary = "硬删除技能 (admin only — 物理删除 + 工作区清空)")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<Void> delete(@PathVariable Long id) { public R<Void> delete(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id); rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
skillService.hardDeleteSkill(id); skillService.hardDeleteSkill(id);
return R.ok(); return R.ok();
} }
@ -415,8 +467,10 @@ public class SkillController {
@Operation(summary = "启用/禁用技能") @Operation(summary = "启用/禁用技能")
@PutMapping("/{id}/toggle") @PutMapping("/{id}/toggle")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<SkillEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) { public R<SkillEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
rejectVirtualSkillMutation(id); rejectVirtualSkillMutation(id);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
return R.ok(skillService.toggleSkill(id, enabled)); return R.ok(skillService.toggleSkill(id, enabled));
} }
@ -657,14 +711,16 @@ public class SkillController {
@Operation(summary = "从对话历史合成 SkillRFC-023") @Operation(summary = "从对话历史合成 SkillRFC-023")
@PostMapping("/synthesize-from-conversation") @PostMapping("/synthesize-from-conversation")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<Map<String, Object>> synthesizeFromConversation(@RequestBody Map<String, Object> body) { public R<Map<String, Object>> synthesizeFromConversation(@RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
String conversationId = (String) body.get("conversationId"); String conversationId = (String) body.get("conversationId");
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null; Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
if (conversationId == null || conversationId.isBlank()) { if (conversationId == null || conversationId.isBlank()) {
return R.fail("conversationId is required"); return R.fail("conversationId is required");
} }
SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(conversationId, agentId); SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(
conversationId, agentId, workspaceId);
if (result.blocked()) { if (result.blocked()) {
return R.ok(Map.of( return R.ok(Map.of(
@ -690,8 +746,10 @@ public class SkillController {
@Operation(summary = "将 skill 导出到工作区目录") @Operation(summary = "将 skill 导出到工作区目录")
@PostMapping("/{id}/export-workspace") @PostMapping("/{id}/export-workspace")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<Map<String, Object>> exportToWorkspace(@PathVariable Long id) { public R<Map<String, Object>> exportToWorkspace(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id); SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent()); var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent());
if (path == null) { if (path == null) {
return R.ok(Map.of("success", false, "message", "Failed to export workspace")); return R.ok(Map.of("success", false, "message", "Failed to export workspace"));
@ -702,8 +760,10 @@ public class SkillController {
@Operation(summary = "获取 skill 工作区信息") @Operation(summary = "获取 skill 工作区信息")
@GetMapping("/{id}/workspace") @GetMapping("/{id}/workspace")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<Map<String, Object>> getWorkspaceInfo(@PathVariable Long id) { public R<Map<String, Object>> getWorkspaceInfo(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id); SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
return R.ok(workspaceManager.getWorkspaceInfo(skill.getName())); return R.ok(workspaceManager.getWorkspaceInfo(skill.getName()));
} }
} }

View File

@ -45,10 +45,14 @@ public class SkillInstallController {
@Operation(summary = "开始异步安装 skill") @Operation(summary = "开始异步安装 skill")
@PostMapping("/start") @PostMapping("/start")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<InstallTask> startInstall(@RequestBody InstallRequest request) { public R<InstallTask> startInstall(@RequestBody InstallRequest request,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
if (request.getBundleUrl() == null || request.getBundleUrl().isBlank()) { if (request.getBundleUrl() == null || request.getBundleUrl().isBlank()) {
return R.fail("bundleUrl is required"); return R.fail("bundleUrl is required");
} }
// Stamp the owning workspace from the request context never trust
// a workspaceId smuggled in the JSON body.
request.setWorkspaceId(workspaceId);
return R.ok(skillInstaller.startInstall(request)); return R.ok(skillInstaller.startInstall(request));
} }
@ -78,7 +82,8 @@ public class SkillInstallController {
@RequestPart("file") MultipartFile zipFile, @RequestPart("file") MultipartFile zipFile,
@RequestParam(defaultValue = "true") Boolean enable, @RequestParam(defaultValue = "true") Boolean enable,
@RequestParam(defaultValue = "false") Boolean overwrite, @RequestParam(defaultValue = "false") Boolean overwrite,
@RequestParam(required = false) String targetName) { @RequestParam(required = false) String targetName,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
// 校验文件类型 // 校验文件类型
String filename = zipFile.getOriginalFilename(); String filename = zipFile.getOriginalFilename();
if (filename == null || !filename.toLowerCase().endsWith(".zip")) { if (filename == null || !filename.toLowerCase().endsWith(".zip")) {
@ -86,7 +91,8 @@ public class SkillInstallController {
} }
try { try {
SkillBundle bundle = ZipSkillFetcher.parse(zipFile, frontmatterParser); SkillBundle bundle = ZipSkillFetcher.parse(zipFile, frontmatterParser);
Map<String, Object> result = skillInstaller.installFromBundle(bundle, enable, overwrite, targetName); Map<String, Object> result = skillInstaller.installFromBundle(
bundle, enable, overwrite, targetName, workspaceId);
return R.ok(result); return R.ok(result);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return R.fail(400, e.getMessage()); return R.fail(400, e.getMessage());
@ -98,8 +104,9 @@ public class SkillInstallController {
@Operation(summary = "卸载 skill") @Operation(summary = "卸载 skill")
@DeleteMapping("/{skillName}") @DeleteMapping("/{skillName}")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<Map<String, String>> uninstall(@PathVariable String skillName) { public R<Map<String, String>> uninstall(@PathVariable String skillName,
skillInstaller.uninstall(skillName); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
skillInstaller.uninstall(skillName, workspaceId);
return R.ok(Map.of("message", "Skill '" + skillName + "' uninstalled")); return R.ok(Map.of("message", "Skill '" + skillName + "' uninstalled"));
} }
} }

View File

@ -81,7 +81,7 @@ public class SkillInstaller {
* admin-only physical removal, call * admin-only physical removal, call
* {@code SkillService.hardDeleteSkill} via {@code DELETE /skills/{id}}. * {@code SkillService.hardDeleteSkill} via {@code DELETE /skills/{id}}.
*/ */
public void uninstall(String skillName) { public void uninstall(String skillName, Long workspaceId) {
List<SkillEntity> skills = skillService.listSkills(); List<SkillEntity> skills = skillService.listSkills();
SkillEntity target = skills.stream() SkillEntity target = skills.stream()
.filter(s -> s.getName().equals(skillName)) .filter(s -> s.getName().equals(skillName))
@ -89,6 +89,18 @@ public class SkillInstaller {
.orElse(null); .orElse(null);
if (target != null) { if (target != null) {
// A workspace may only uninstall the skills it owns. Builtin
// skills are global and rejected by uninstallSkill itself.
if (!Boolean.TRUE.equals(target.getBuiltin())) {
long requested = workspaceId != null
? workspaceId : SkillService.DEFAULT_WORKSPACE_ID;
long owner = target.getWorkspaceId() != null
? target.getWorkspaceId() : SkillService.DEFAULT_WORKSPACE_ID;
if (owner != requested) {
throw new vip.mate.exception.MateClawException("err.common.wrong_workspace", 403,
"Skill '" + skillName + "' does not belong to the current workspace");
}
}
skillService.uninstallSkill(target.getId()); skillService.uninstallSkill(target.getId());
} }
log.info("Uninstalled skill: {}", skillName); log.info("Uninstalled skill: {}", skillName);
@ -159,7 +171,7 @@ public class SkillInstaller {
// 5. Register/update the skill row first so we have an id for the file rows. // 5. Register/update the skill row first so we have an id for the file rows.
SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists,
Boolean.TRUE.equals(request.getEnable())); Boolean.TRUE.equals(request.getEnable()), request.getWorkspaceId());
if (task.isCancelRequested()) { if (task.isCancelRequested()) {
task.markCancelled(); task.markCancelled();
@ -199,7 +211,8 @@ public class SkillInstaller {
* *
* @return 安装结果 MapskillId, name, version, filesCount * @return 安装结果 MapskillId, name, version, filesCount
*/ */
public Map<String, Object> installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite, String targetName) { public Map<String, Object> installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite,
String targetName, Long workspaceId) {
String skillName = (targetName != null && !targetName.isBlank()) ? targetName : bundle.name(); String skillName = (targetName != null && !targetName.isBlank()) ? targetName : bundle.name();
if (skillName == null || skillName.isBlank()) { if (skillName == null || skillName.isBlank()) {
throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle"); throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle");
@ -216,7 +229,7 @@ public class SkillInstaller {
workspaceManager.initWorkspace(skillName, bundle.content(), exists); workspaceManager.initWorkspace(skillName, bundle.content(), exists);
// Register/update skill row first so we have an id to anchor the file rows. // Register/update skill row first so we have an id to anchor the file rows.
SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable); SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable, workspaceId);
// DB-canonical, FS-cache. Empty-bundle guard on both sides. // DB-canonical, FS-cache. Empty-bundle guard on both sides.
persistBundleFiles(skillEntity, bundle, false, "zip"); persistBundleFiles(skillEntity, bundle, false, "zip");
@ -241,8 +254,13 @@ public class SkillInstaller {
/** /**
* Insert or update the {@code mate_skill} row from a bundle. Returns the * Insert or update the {@code mate_skill} row from a bundle. Returns the
* persisted entity so callers have its id for downstream file writes. * persisted entity so callers have its id for downstream file writes.
*
* <p>{@code workspaceId} is stamped only on the insert path an
* existing skill keeps its current owning workspace so a re-install
* never silently migrates a skill between workspaces.
*/ */
private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists, boolean enable) { private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists,
boolean enable, Long workspaceId) {
SkillEntity skillEntity; SkillEntity skillEntity;
if (exists) { if (exists) {
skillEntity = skillService.listSkills().stream() skillEntity = skillService.listSkills().stream()
@ -267,6 +285,7 @@ public class SkillInstaller {
skillEntity.setSkillContent(bundle.content()); skillEntity.setSkillContent(bundle.content());
skillEntity.setConfigJson(buildConfigJson(bundle)); skillEntity.setConfigJson(buildConfigJson(bundle));
skillEntity.setEnabled(enable); skillEntity.setEnabled(enable);
skillEntity.setWorkspaceId(workspaceId);
skillService.createSkill(skillEntity); skillService.createSkill(skillEntity);
} }
return skillEntity; return skillEntity;

View File

@ -33,4 +33,11 @@ public class InstallRequest {
* an intentionally empty bundle. * an intentionally empty bundle.
*/ */
private Boolean forcePrune = false; private Boolean forcePrune = false;
/**
* Owning workspace for the installed skill. Stamped by the controller
* from the {@code X-Workspace-Id} header never trusted from a raw
* client body. {@code null} falls back to the default workspace.
*/
private Long workspaceId;
} }

View File

@ -335,6 +335,7 @@ public class SkillPackageResolver {
.enabled(Boolean.TRUE.equals(entity.getEnabled())) .enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon()) .icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin())) .builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.workspaceId(entity.getWorkspaceId())
.createTime(entity.getCreateTime()) .createTime(entity.getCreateTime())
.build(); .build();
} }
@ -376,6 +377,7 @@ public class SkillPackageResolver {
.enabled(Boolean.TRUE.equals(entity.getEnabled())) .enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon()) .icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin())) .builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.workspaceId(entity.getWorkspaceId())
.createTime(entity.getCreateTime()) .createTime(entity.getCreateTime())
.build(); .build();
} }

View File

@ -345,13 +345,29 @@ public class SkillRuntimeService {
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds, public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
Set<String> effectiveToolNames, Set<String> effectiveToolNames,
Integer maxInputTokens) { Integer maxInputTokens) {
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null); return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null, null);
} }
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds, public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
Set<String> effectiveToolNames, Set<String> effectiveToolNames,
Integer maxInputTokens, Integer maxInputTokens,
Long agentId) { Long agentId) {
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, agentId, null);
}
/**
* 构建技能目录提示片段支持按 Agent 工作区隔离
*
* @param agentWorkspaceId 调用 Agent 的工作区 ID null 目录只保留
* 内置技能全局与该工作区拥有的技能其他工作区
* 的技能不会注入 promptnull 表示不做工作区过滤
* 调试预览等全局场景
*/
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
Set<String> effectiveToolNames,
Integer maxInputTokens,
Long agentId,
Long agentWorkspaceId) {
List<ResolvedSkill> activeSkills; List<ResolvedSkill> activeSkills;
if (boundSkillIds != null) { if (boundSkillIds != null) {
// Per-agent filter: pick the agent's bound subset from the // Per-agent filter: pick the agent's bound subset from the
@ -381,6 +397,16 @@ public class SkillRuntimeService {
activeSkills = activeSkills.stream() activeSkills = activeSkills.stream()
.filter(s -> matchesCurrentPlatform(s, currentOs)) .filter(s -> matchesCurrentPlatform(s, currentOs))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
// Workspace filter a workspace-B agent must not see workspace-A's
// skills in its catalog. Builtin skills are global; virtual MCP
// skills carry no workspace (null) and stay globally visible. Only
// applied when the caller supplies the agent's workspace; the debug
// preview passes null to keep its global view.
if (agentWorkspaceId != null) {
activeSkills = activeSkills.stream()
.filter(s -> matchesWorkspace(s, agentWorkspaceId))
.collect(java.util.stream.Collectors.toList());
}
if (activeSkills.isEmpty()) { if (activeSkills.isEmpty()) {
return ""; return "";
} }
@ -576,4 +602,17 @@ public class SkillRuntimeService {
} }
return false; return false;
} }
/**
* True when the skill is visible to an agent in {@code agentWorkspaceId}.
* Builtin skills are global, virtual MCP-derived skills carry no
* workspace ({@code null}) and are likewise global; every other skill is
* visible only inside its owning workspace.
*/
static boolean matchesWorkspace(ResolvedSkill skill, long agentWorkspaceId) {
if (skill.isBuiltin()) return true;
Long skillWs = skill.getWorkspaceId();
if (skillWs == null) return true;
return skillWs == agentWorkspaceId;
}
} }

View File

@ -73,6 +73,14 @@ public class ResolvedSkill {
@Builder.Default @Builder.Default
private boolean builtin = false; private boolean builtin = false;
/**
* Owning workspace, copied from {@code mate_skill.workspace_id}. Builtin
* skills are global, so for them this is informational only. {@code null}
* for virtual MCP-derived skills (MCP servers carry no workspace) the
* runtime treats a null workspace as globally visible.
*/
private Long workspaceId;
/** /**
* Skill row create timestamp, copied from {@code mate_skill.create_time}. * Skill row create timestamp, copied from {@code mate_skill.create_time}.
* Used by the prompt-catalog ranker to surface freshly installed skills * Used by the prompt-catalog ranker to surface freshly installed skills

View File

@ -67,6 +67,25 @@ public class SkillService {
// ==================== CRUD ==================== // ==================== CRUD ====================
/** Default workspace id used when no {@code X-Workspace-Id} is supplied. */
public static final long DEFAULT_WORKSPACE_ID = 1L;
static long normalizeWorkspaceId(Long workspaceId) {
return workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID;
}
/**
* Restrict a query to skills visible inside {@code workspaceId}: builtin
* skills are global (shared across every workspace), every other skill is
* owned by exactly one workspace. Applied as a nested {@code AND (builtin
* OR workspace_id = ?)} group so it composes with other filters.
*/
private static void applyWorkspaceScope(LambdaQueryWrapper<SkillEntity> wrapper, Long workspaceId) {
long wsId = normalizeWorkspaceId(workspaceId);
wrapper.and(w -> w.eq(SkillEntity::getBuiltin, true)
.or().eq(SkillEntity::getWorkspaceId, wsId));
}
/** /**
* 获取所有技能列表管理页面使用 * 获取所有技能列表管理页面使用
* 排序内置优先然后按创建时间倒序 * 排序内置优先然后按创建时间倒序
@ -77,6 +96,18 @@ public class SkillService {
.orderByDesc(SkillEntity::getCreateTime)); .orderByDesc(SkillEntity::getCreateTime));
} }
/**
* Workspace-scoped variant of {@link #listSkills()} returns builtin
* skills plus the skills owned by {@code workspaceId}.
*/
public List<SkillEntity> listSkills(Long workspaceId) {
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<SkillEntity>()
.orderByDesc(SkillEntity::getBuiltin)
.orderByDesc(SkillEntity::getCreateTime);
applyWorkspaceScope(wrapper, workspaceId);
return skillMapper.selectList(wrapper);
}
/** /**
* Paginated skill listing for the SkillMarket admin UI. * Paginated skill listing for the SkillMarket admin UI.
* *
@ -88,33 +119,23 @@ public class SkillService {
* security_scan_status}: {@code "FAILED"} surfaces blocked skills so the * security_scan_status}: {@code "FAILED"} surfaces blocked skills so the
* admin can inspect findings and rescan, {@code "PASSED"} shows scanned * admin can inspect findings and rescan, {@code "PASSED"} shows scanned
* clean rows, {@code null} / empty means no scan filter. * clean rows, {@code null} / empty means no scan filter.
*
* <p>{@code workspaceId} scopes the result to one workspace's catalog:
* builtin skills are always included (they're global), every other skill
* only when it belongs to {@code workspaceId}. A {@code null} workspace
* falls back to the default workspace.
*/ */
public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
String skillType, Boolean enabled,
String scanStatus) {
return pageSkills(page, size, keyword, skillType, enabled, scanStatus,
null, null, null, Set.of());
}
public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
String skillType, Boolean enabled,
String scanStatus,
String sort,
String source,
String runtime) {
return pageSkills(page, size, keyword, skillType, enabled, scanStatus,
sort, source, runtime, Set.of());
}
public IPage<SkillEntity> pageSkills(int page, int size, String keyword, public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
String skillType, Boolean enabled, String skillType, Boolean enabled,
String scanStatus, String scanStatus,
String sort, String sort,
String source, String source,
String runtime, String runtime,
Set<Long> pinnedSkillIds) { Set<Long> pinnedSkillIds,
Long workspaceId) {
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1)); Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
applyWorkspaceScope(wrapper, workspaceId);
if (keyword != null && !keyword.isBlank()) { if (keyword != null && !keyword.isBlank()) {
String kw = keyword.trim(); String kw = keyword.trim();
@ -191,14 +212,19 @@ public class SkillService {
/** /**
* Aggregate skill counts per {@code skill_type}, plus an {@code all} * Aggregate skill counts per {@code skill_type}, plus an {@code all}
* rollup. Feeds the SkillMarket tab badges without pulling every row. * rollup. Feeds the SkillMarket tab badges without pulling every row.
* Scoped to {@code workspaceId}: builtin skills count for every
* workspace, all other skills only for their owning workspace.
*/ */
public Map<String, Long> countByType() { public Map<String, Long> countByType(Long workspaceId) {
Map<String, Long> result = new LinkedHashMap<>(); Map<String, Long> result = new LinkedHashMap<>();
result.put("all", skillMapper.selectCount(null)); LambdaQueryWrapper<SkillEntity> allWrapper = new LambdaQueryWrapper<>();
applyWorkspaceScope(allWrapper, workspaceId);
result.put("all", skillMapper.selectCount(allWrapper));
for (String type : List.of("builtin", "mcp", "dynamic")) { for (String type : List.of("builtin", "mcp", "dynamic")) {
result.put(type, skillMapper.selectCount( LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<SkillEntity>()
new LambdaQueryWrapper<SkillEntity>() .eq(SkillEntity::getSkillType, type);
.eq(SkillEntity::getSkillType, type))); applyWorkspaceScope(wrapper, workspaceId);
result.put(type, skillMapper.selectCount(wrapper));
} }
return result; return result;
} }
@ -217,6 +243,20 @@ public class SkillService {
.orderByAsc(SkillEntity::getName)); .orderByAsc(SkillEntity::getName));
} }
/**
* Workspace-scoped variant of {@link #listEnabledSkills()} builtin
* skills plus the enabled skills owned by {@code workspaceId}.
*/
public List<SkillEntity> listEnabledSkills(Long workspaceId) {
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getEnabled, true)
.and(w -> w.isNull(SkillEntity::getSecurityScanStatus)
.or().eq(SkillEntity::getSecurityScanStatus, "PASSED"))
.orderByAsc(SkillEntity::getName);
applyWorkspaceScope(wrapper, workspaceId);
return skillMapper.selectList(wrapper);
}
/** /**
* 按名称查找技能RFC-023SkillManageTool 重名检查用 * 按名称查找技能RFC-023SkillManageTool 重名检查用
*/ */
@ -235,6 +275,17 @@ public class SkillService {
.orderByDesc(SkillEntity::getCreateTime)); .orderByDesc(SkillEntity::getCreateTime));
} }
/**
* Workspace-scoped variant of {@link #listSkillsByType(String)}.
*/
public List<SkillEntity> listSkillsByType(String skillType, Long workspaceId) {
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getSkillType, skillType)
.orderByDesc(SkillEntity::getCreateTime);
applyWorkspaceScope(wrapper, workspaceId);
return skillMapper.selectList(wrapper);
}
/** /**
* 获取技能详情 * 获取技能详情
*/ */
@ -268,6 +319,14 @@ public class SkillService {
if (skill.getEnabled() == null) { if (skill.getEnabled() == null) {
skill.setEnabled(true); skill.setEnabled(true);
} }
// Every skill belongs to a workspace. Callers that carry an
// X-Workspace-Id header set this explicitly; the no-arg create
// path falls back to the default workspace instead of relying on
// the column DEFAULT, so the value is always populated on the
// returned entity.
if (skill.getWorkspaceId() == null) {
skill.setWorkspaceId(DEFAULT_WORKSPACE_ID);
}
// 前端只识别 builtin/mcp/dynamic用户新建默认为 dynamic // 前端只识别 builtin/mcp/dynamic用户新建默认为 dynamic
if (skill.getSkillType() == null || skill.getSkillType().isBlank()) { if (skill.getSkillType() == null || skill.getSkillType().isBlank()) {
skill.setSkillType("dynamic"); skill.setSkillType("dynamic");
@ -670,6 +729,17 @@ public class SkillService {
)); ));
} }
/**
* Workspace-scoped variant of {@link #getEnabledSkillSummary()}.
*/
public Map<String, List<String>> getEnabledSkillSummary(Long workspaceId) {
return listEnabledSkills(workspaceId).stream()
.collect(Collectors.groupingBy(
SkillEntity::getSkillType,
Collectors.mapping(SkillEntity::getName, Collectors.toList())
));
}
// ==================== Workspace 集成辅助方法 ==================== // ==================== Workspace 集成辅助方法 ====================
/** /**

View File

@ -56,9 +56,10 @@ public class SkillSynthesisService {
* *
* @param conversationId 源对话 ID * @param conversationId 源对话 ID
* @param agentId Agent ID用于记录来源 * @param agentId Agent ID用于记录来源
* @param workspaceId 目标工作区 ID决定新 Skill 的归属
* @return 合成结果包含 skillIdnamestatus * @return 合成结果包含 skillIdnamestatus
*/ */
public SynthesisResult synthesize(String conversationId, Long agentId) { public SynthesisResult synthesize(String conversationId, Long agentId, Long workspaceId) {
// 1. 读取对话历史 // 1. 读取对话历史
List<MessageEntity> messages = messageMapper.selectList( List<MessageEntity> messages = messageMapper.selectList(
new LambdaQueryWrapper<MessageEntity>() new LambdaQueryWrapper<MessageEntity>()
@ -121,6 +122,7 @@ public class SkillSynthesisService {
skill.setVersion(extractFrontmatterValue(skillMd, "version")); skill.setVersion(extractFrontmatterValue(skillMd, "version"));
skill.setSourceConversationId(conversationId); skill.setSourceConversationId(conversationId);
skill.setSecurityScanStatus(scanStatus); skill.setSecurityScanStatus(scanStatus);
skill.setWorkspaceId(workspaceId);
skillService.createSkill(skill); skillService.createSkill(skill);

View File

@ -53,7 +53,8 @@ public class SkillTemplateController {
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<SkillEntity> instantiate( public R<SkillEntity> instantiate(
@PathVariable String id, @PathVariable String id,
@RequestBody Map<String, Object> values) { @RequestBody Map<String, Object> values,
return R.ok(templateService.instantiate(id, values)); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(templateService.instantiate(id, values, workspaceId));
} }
} }

View File

@ -62,11 +62,12 @@ public class SkillTemplateService {
* Instantiate the template by id, substituting fields, and create * Instantiate the template by id, substituting fields, and create
* the skill. Returns the created {@link SkillEntity}. * the skill. Returns the created {@link SkillEntity}.
* *
* @param templateId id from the registry (e.g. {@code tcm-qa}) * @param templateId id from the registry (e.g. {@code tcm-qa})
* @param values user-supplied field values; missing required * @param values user-supplied field values; missing required
* fields throw a translatable exception * fields throw a translatable exception
* @param workspaceId owning workspace for the created skill
*/ */
public SkillEntity instantiate(String templateId, Map<String, Object> values) { public SkillEntity instantiate(String templateId, Map<String, Object> values, Long workspaceId) {
SkillTemplate template = registry.find(templateId); SkillTemplate template = registry.find(templateId);
if (template == null) { if (template == null) {
throw new MateClawException("err.skill_template.not_found", throw new MateClawException("err.skill_template.not_found",
@ -97,6 +98,7 @@ public class SkillTemplateService {
entity.setAuthor("skill-template-wizard"); entity.setAuthor("skill-template-wizard");
entity.setSkillContent(skillMd); entity.setSkillContent(skillMd);
entity.setEnabled(true); entity.setEnabled(true);
entity.setWorkspaceId(workspaceId);
SkillEntity created = skillService.createSkill(entity); SkillEntity created = skillService.createSkill(entity);

View File

@ -4,8 +4,11 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.skill.model.SkillEntity; import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.SkillSecurityService; import vip.mate.skill.runtime.SkillSecurityService;
@ -120,7 +123,12 @@ public class SkillManageTool {
@JsonProperty @JsonProperty
@JsonPropertyDescription("For patch action: the new text to replace with") @JsonPropertyDescription("For patch action: the new text to replace with")
String newText String newText,
// RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden
// from the LLM by JsonSchemaGenerator. Used to stamp the new
// skill with the agent's owning workspace.
@Nullable ToolContext toolContext
) { ) {
if (action == null || action.isBlank()) { if (action == null || action.isBlank()) {
return "Error: action is required (create | edit | patch | delete)"; return "Error: action is required (create | edit | patch | delete)";
@ -135,8 +143,10 @@ public class SkillManageTool {
+ "'. Must match: lowercase letters, digits, hyphens, dots (1-64 chars, start with letter/digit)"; + "'. Must match: lowercase letters, digits, hyphens, dots (1-64 chars, start with letter/digit)";
} }
Long workspaceId = ChatOrigin.from(toolContext).workspaceId();
return switch (action.strip().toLowerCase()) { return switch (action.strip().toLowerCase()) {
case "create" -> doCreate(normalizedName, content); case "create" -> doCreate(normalizedName, content, workspaceId);
case "edit" -> doEdit(normalizedName, content); case "edit" -> doEdit(normalizedName, content);
case "patch" -> doPatch(normalizedName, oldText, newText); case "patch" -> doPatch(normalizedName, oldText, newText);
case "delete" -> doDelete(normalizedName); case "delete" -> doDelete(normalizedName);
@ -146,7 +156,7 @@ public class SkillManageTool {
// ==================== Create ==================== // ==================== Create ====================
private String doCreate(String name, String content) { private String doCreate(String name, String content, Long workspaceId) {
if (content == null || content.isBlank()) { if (content == null || content.isBlank()) {
return "Error: content is required for create action. Provide full SKILL.md content."; return "Error: content is required for create action. Provide full SKILL.md content.";
} }
@ -175,6 +185,7 @@ public class SkillManageTool {
skill.setBuiltin(false); skill.setBuiltin(false);
skill.setVersion(extractVersion(content)); skill.setVersion(extractVersion(content));
skill.setSecurityScanStatus("PASSED"); skill.setSecurityScanStatus("PASSED");
skill.setWorkspaceId(workspaceId);
skillService.createSkill(skill); skillService.createSkill(skill);

View File

@ -4,17 +4,16 @@ 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;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.audit.service.AuditEventService;
import vip.mate.channel.web.Utf8SseEmitter; import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.common.result.R; import vip.mate.common.result.R;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.wiki.WikiProperties; import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.event.WikiProcessingEvent;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity; import vip.mate.wiki.model.WikiRawMaterialEntity;
@ -52,8 +51,8 @@ public class WikiController {
private final WikiProcessingService processingService; private final WikiProcessingService processingService;
private final WikiDirectoryScanService scanService; private final WikiDirectoryScanService scanService;
private final WikiProperties properties; private final WikiProperties properties;
private final ApplicationEventPublisher eventPublisher;
private final WikiProgressBus progressBus; private final WikiProgressBus progressBus;
private final AuditEventService auditEventService;
// ==================== Knowledge Base ==================== // ==================== Knowledge Base ====================
@ -131,7 +130,12 @@ public class WikiController {
public R<Void> deleteKB(@PathVariable Long id, public R<Void> deleteKB(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId); verifyKBWorkspace(id, workspaceId);
kbService.delete(id); WikiKnowledgeBaseService.CascadeDeleteResult result = kbService.delete(id);
String detail = String.format(
"{\"rawMaterialCount\":%d,\"pageCount\":%d,\"chunkCount\":%d,\"citationCount\":%d,\"processingJobCount\":%d}",
result.rawMaterialCount(), result.pageCount(), result.chunkCount(),
result.citationCount(), result.processingJobCount());
auditEventService.record("DELETE", "WIKI_KB", String.valueOf(id), result.kbName(), detail);
return R.ok(); return R.ok();
} }
@ -501,21 +505,8 @@ public class WikiController {
@RequestParam(value = "force", defaultValue = "false") boolean force, @RequestParam(value = "force", defaultValue = "false") boolean force,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId); verifyKBWorkspace(kbId, workspaceId);
List<WikiRawMaterialEntity> targets; int queued = processingService.processKB(kbId, force);
if (force) { return R.ok(Map.of("queued", queued, "force", force));
// 强制重处理所有非 pending 的材料重置为 pending并清空 hash 短路
targets = rawService.listByKbId(kbId);
for (WikiRawMaterialEntity r : targets) {
rawService.setLastProcessedHash(r.getId(), null);
rawService.reprocess(r.getId()); // reprocess 会把状态设为 pending 并发布事件
}
return R.ok(Map.of("queued", targets.size(), "force", true));
}
targets = rawService.listPending(kbId);
for (WikiRawMaterialEntity raw : targets) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), kbId));
}
return R.ok(Map.of("queued", targets.size(), "force", false));
} }
@RequireWorkspaceRole("viewer") @RequireWorkspaceRole("viewer")

View File

@ -5,8 +5,18 @@ 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.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiChunkEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageCitationEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import vip.mate.wiki.repository.WikiChunkMapper;
import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; import vip.mate.wiki.repository.WikiKnowledgeBaseMapper;
import vip.mate.wiki.repository.WikiPageCitationMapper;
import vip.mate.wiki.repository.WikiPageMapper;
import vip.mate.wiki.repository.WikiProcessingJobMapper;
import vip.mate.wiki.repository.WikiRawMaterialMapper;
import java.util.List; import java.util.List;
@ -21,6 +31,11 @@ import java.util.List;
public class WikiKnowledgeBaseService { public class WikiKnowledgeBaseService {
private final WikiKnowledgeBaseMapper kbMapper; private final WikiKnowledgeBaseMapper kbMapper;
private final WikiRawMaterialMapper rawMapper;
private final WikiPageMapper pageMapper;
private final WikiChunkMapper chunkMapper;
private final WikiPageCitationMapper citationMapper;
private final WikiProcessingJobMapper processingJobMapper;
/** /**
* RFC-051 PR-2: optional system-page scaffold (overview / log). Marked * RFC-051 PR-2: optional system-page scaffold (overview / log). Marked
@ -32,6 +47,19 @@ public class WikiKnowledgeBaseService {
@org.springframework.context.annotation.Lazy @org.springframework.context.annotation.Lazy
private WikiScaffoldService scaffoldService; private WikiScaffoldService scaffoldService;
/**
* Summary returned from cascade delete used by callers (e.g. the
* controller) to record an audit event with affected-row counts.
*/
public record CascadeDeleteResult(
String kbName,
int rawMaterialCount,
int pageCount,
int chunkCount,
int citationCount,
int processingJobCount) {
}
private static final String DEFAULT_CONFIG = """ private static final String DEFAULT_CONFIG = """
# Wiki Processing Rules # Wiki Processing Rules
@ -216,9 +244,54 @@ public class WikiKnowledgeBaseService {
} }
} }
/**
* Cascade-delete a knowledge base and all data that belongs to it.
* <p>
* Single transaction: removes page citations (looked up via page IDs since
* the citation table has no {@code kb_id} column), then chunks, pages, raw
* materials, and processing jobs by {@code kb_id}, and finally the KB row
* itself. Returns a summary so callers can record audit metadata.
*/
@Transactional @Transactional
public void delete(Long id) { public CascadeDeleteResult delete(Long id) {
WikiKnowledgeBaseEntity kb = kbMapper.selectById(id);
if (kb == null) {
throw new IllegalArgumentException("Knowledge base not found: " + id);
}
List<Long> pageIds = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()
.select(WikiPageEntity::getId)
.eq(WikiPageEntity::getKbId, id))
.stream()
.map(WikiPageEntity::getId)
.toList();
int citationCount = pageIds.isEmpty() ? 0 : citationMapper.delete(
new LambdaQueryWrapper<WikiPageCitationEntity>()
.in(WikiPageCitationEntity::getPageId, pageIds));
int pageCount = pageMapper.delete(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, id));
int chunkCount = chunkMapper.delete(
new LambdaQueryWrapper<WikiChunkEntity>()
.eq(WikiChunkEntity::getKbId, id));
int rawCount = rawMapper.delete(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, id));
int jobCount = processingJobMapper.delete(
new LambdaQueryWrapper<WikiProcessingJobEntity>()
.eq(WikiProcessingJobEntity::getKbId, id));
kbMapper.deleteById(id); kbMapper.deleteById(id);
log.info("[Wiki] Knowledge base deleted: id={}", id);
log.info("[Wiki] Knowledge base cascade-deleted: id={}, name={}, raw={}, page={}, chunk={}, citation={}, job={}",
id, kb.getName(), rawCount, pageCount, chunkCount, citationCount, jobCount);
return new CascadeDeleteResult(kb.getName(), rawCount, pageCount, chunkCount, citationCount, jobCount);
} }
} }

View File

@ -17,6 +17,7 @@ import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService; import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.WikiProperties; import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.dto.WikiChunkDraft; import vip.mate.wiki.dto.WikiChunkDraft;
import vip.mate.wiki.event.WikiProcessingEvent;
import vip.mate.wiki.job.WikiKbConfig; import vip.mate.wiki.job.WikiKbConfig;
import vip.mate.wiki.job.WikiKbConfigParser; import vip.mate.wiki.job.WikiKbConfigParser;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
@ -507,6 +508,39 @@ public class WikiProcessingService {
} }
} }
/**
* Queue every raw material in a KB for asynchronous processing and
* return the number of materials queued. Does not block on processing.
* <p>
* When {@code force=true}, every raw material is reset to {@code pending}
* and its {@code last_processed_hash} is cleared so the dedup short-circuit
* is bypassed; otherwise only currently-{@code pending} materials are
* republished to the event bus (the same shape as the manual
* {@code POST /knowledge-bases/{kbId}/process} endpoint).
*
* @return the number of raw materials queued
*/
public int processKB(Long kbId, boolean force) {
if (kbId == null) {
throw new IllegalArgumentException("kbId is required");
}
if (force) {
List<WikiRawMaterialEntity> all = rawService.listByKbId(kbId);
for (WikiRawMaterialEntity raw : all) {
rawService.setLastProcessedHash(raw.getId(), null);
rawService.reprocess(raw.getId());
}
log.info("[Wiki] processKB queued {} raw material(s) for kbId={} (force=true)", all.size(), kbId);
return all.size();
}
List<WikiRawMaterialEntity> pending = rawService.listPending(kbId);
for (WikiRawMaterialEntity raw : pending) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), kbId));
}
log.info("[Wiki] processKB queued {} pending raw material(s) for kbId={}", pending.size(), kbId);
return pending.size();
}
/** /**
* 处理知识库中所有待处理的原始材料 * 处理知识库中所有待处理的原始材料
* <p> * <p>

View File

@ -180,6 +180,9 @@ err.cron.agent_required=\u8bf7\u9009\u62e9\u5173\u8054 Agent
err.cron.expression_required=Cron \u8868\u8fbe\u5f0f\u4e0d\u80fd\u4e3a\u7a7a err.cron.expression_required=Cron \u8868\u8fbe\u5f0f\u4e0d\u80fd\u4e3a\u7a7a
err.cron.trigger_required=\u89e6\u53d1\u6d88\u606f\u4e0d\u80fd\u4e3a\u7a7a err.cron.trigger_required=\u89e6\u53d1\u6d88\u606f\u4e0d\u80fd\u4e3a\u7a7a
err.cron.target_required=\u6267\u884c\u76ee\u6807\u4e0d\u80fd\u4e3a\u7a7a err.cron.target_required=\u6267\u884c\u76ee\u6807\u4e0d\u80fd\u4e3a\u7a7a
err.cron.wiki_kb_required=\u8bf7\u9009\u62e9\u77e5\u8bc6\u5e93
err.cron.wiki_kb_invalid=\u77e5\u8bc6\u5e93 ID \u683c\u5f0f\u4e0d\u5408\u6cd5
err.cron.wiki_kb_not_found=\u77e5\u8bc6\u5e93\u4e0d\u5b58\u5728
# agent (extended) # agent (extended)
err.agent.no_default_model=\u65e0\u6cd5\u6784\u5efa Agent\uff1a\u8bf7\u5148\u914d\u7f6e\u5e76\u542f\u7528\u9ed8\u8ba4\u6a21\u578b err.agent.no_default_model=\u65e0\u6cd5\u6784\u5efa Agent\uff1a\u8bf7\u5148\u914d\u7f6e\u5e76\u542f\u7528\u9ed8\u8ba4\u6a21\u578b
err.agent.model_not_configured=\u6a21\u578b Provider \u672a\u5b8c\u6210\u914d\u7f6e err.agent.model_not_configured=\u6a21\u578b Provider \u672a\u5b8c\u6210\u914d\u7f6e

View File

@ -192,6 +192,9 @@ err.cron.agent_required=Please select an Agent
err.cron.expression_required=Cron expression cannot be empty err.cron.expression_required=Cron expression cannot be empty
err.cron.trigger_required=Trigger message cannot be empty err.cron.trigger_required=Trigger message cannot be empty
err.cron.target_required=Execution target cannot be empty err.cron.target_required=Execution target cannot be empty
err.cron.wiki_kb_required=Please select a knowledge base
err.cron.wiki_kb_invalid=Knowledge base id is malformed
err.cron.wiki_kb_not_found=Knowledge base does not exist
# agent (extended) # agent (extended)
err.agent.no_default_model=Cannot build Agent: please configure and enable a default model in Settings > Models err.agent.no_default_model=Cannot build Agent: please configure and enable a default model in Settings > Models
err.agent.model_not_configured=Model provider not configured, please fill in API Key in Settings > Models err.agent.model_not_configured=Model provider not configured, please fill in API Key in Settings > Models

View File

@ -220,6 +220,29 @@ class AgentBindingServiceTest {
assertEquals(0, count, "拒绝时不能写入绑定行"); assertEquals(0, count, "拒绝时不能写入绑定行");
} }
@Test
@DisplayName("bindSkill 允许跨 workspace 的 builtin skillbuiltin 为全局能力,不做 tenancy 校验)")
void bindSkillAllowsBuiltinSkillCrossWorkspace() {
// Builtin skills are seeded once into the default workspace but are
// global an agent in any workspace must be able to bind them. Seed
// a builtin row whose workspace_id deliberately differs from the
// agent's (=1) to prove the builtin exemption, not a workspace match,
// is what lets the binding through.
long builtinSkillId = 7_777_350L;
jdbcTemplate.update(
"MERGE INTO mate_skill (id, name, skill_type, version, enabled, builtin, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, ?, 'builtin', '1.0.0', TRUE, TRUE, 2, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
builtinSkillId, "binding-test-builtin-" + builtinSkillId);
assertDoesNotThrow(() -> bindingService.bindSkill(agentId, builtinSkillId));
Set<Long> ids = bindingService.getBoundSkillIds(agentId);
assertNotNull(ids);
assertTrue(ids.contains(builtinSkillId), "builtin skill 应当可跨 workspace 绑定");
}
@Test @Test
@DisplayName("bindSkill 允许 MCP 虚拟 skillMcpServerEntity 无 workspace全局共享") @DisplayName("bindSkill 允许 MCP 虚拟 skillMcpServerEntity 无 workspace全局共享")
void bindSkillAllowsVirtualMcpSkill() { void bindSkillAllowsVirtualMcpSkill() {

View File

@ -54,8 +54,8 @@ class SkillControllerListEnabledTest {
acpSkillBridge); acpSkillBridge);
// listSkills() supplies realSkillNames() for shadow base default // listSkills() supplies realSkillNames() for shadow base default
// to empty so each test can override. // to empty so each test can override.
when(skillService.listSkills()).thenReturn(List.of()); when(skillService.listSkills(null)).thenReturn(List.of());
when(skillService.listEnabledSkills()).thenReturn(List.of()); when(skillService.listEnabledSkills(null)).thenReturn(List.of());
when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of()); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of());
when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of()); when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of());
} }
@ -66,7 +66,7 @@ class SkillControllerListEnabledTest {
SkillEntity mcp = skill("github", "mcp"); SkillEntity mcp = skill("github", "mcp");
when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp)); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp));
R<List<SkillEntity>> response = controller.listEnabled(); R<List<SkillEntity>> response = controller.listEnabled(null);
assertNotNull(response.getData()); assertNotNull(response.getData());
assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName())), assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName())),
@ -79,7 +79,7 @@ class SkillControllerListEnabledTest {
SkillEntity acp = skill("claude-code", "acp"); SkillEntity acp = skill("claude-code", "acp");
when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of(acp)); when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of(acp));
R<List<SkillEntity>> response = controller.listEnabled(); R<List<SkillEntity>> response = controller.listEnabled(null);
assertTrue(response.getData().stream().anyMatch(s -> "claude-code".equals(s.getName()))); assertTrue(response.getData().stream().anyMatch(s -> "claude-code".equals(s.getName())));
} }
@ -92,13 +92,13 @@ class SkillControllerListEnabledTest {
// (enabled-only) by mistake, the virtual would slip through here. // (enabled-only) by mistake, the virtual would slip through here.
SkillEntity disabledReal = skill("github", "custom"); SkillEntity disabledReal = skill("github", "custom");
disabledReal.setEnabled(false); disabledReal.setEnabled(false);
when(skillService.listSkills()).thenReturn(List.of(disabledReal)); when(skillService.listSkills(null)).thenReturn(List.of(disabledReal));
when(skillService.listEnabledSkills()).thenReturn(List.of()); when(skillService.listEnabledSkills(null)).thenReturn(List.of());
SkillEntity virtualMcp = skill("github", "mcp"); SkillEntity virtualMcp = skill("github", "mcp");
when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(virtualMcp)); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(virtualMcp));
R<List<SkillEntity>> response = controller.listEnabled(); R<List<SkillEntity>> response = controller.listEnabled(null);
// The real skill is disabled, so listEnabledSkills() returns nothing; // The real skill is disabled, so listEnabledSkills() returns nothing;
// the virtual MCP must also be filtered to keep this endpoint in step // the virtual MCP must also be filtered to keep this endpoint in step
@ -112,11 +112,11 @@ class SkillControllerListEnabledTest {
void mcpBridgeFailureSwallowed() { void mcpBridgeFailureSwallowed() {
SkillEntity enabled = skill("web_search", "builtin"); SkillEntity enabled = skill("web_search", "builtin");
enabled.setEnabled(true); enabled.setEnabled(true);
when(skillService.listEnabledSkills()).thenReturn(List.of(enabled)); when(skillService.listEnabledSkills(null)).thenReturn(List.of(enabled));
when(mcpSkillBridge.listMcpDerivedSkillEntities()) when(mcpSkillBridge.listMcpDerivedSkillEntities())
.thenThrow(new RuntimeException("MCP bridge offline")); .thenThrow(new RuntimeException("MCP bridge offline"));
R<List<SkillEntity>> response = controller.listEnabled(); R<List<SkillEntity>> response = controller.listEnabled(null);
assertEquals(1, response.getData().size()); assertEquals(1, response.getData().size());
assertEquals("web_search", response.getData().get(0).getName()); assertEquals("web_search", response.getData().get(0).getName());
@ -130,7 +130,7 @@ class SkillControllerListEnabledTest {
SkillEntity mcp = skill("github", "mcp"); SkillEntity mcp = skill("github", "mcp");
when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp)); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp));
R<List<SkillEntity>> response = controller.listEnabled(); R<List<SkillEntity>> response = controller.listEnabled(null);
assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName()))); assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName())));
} }

View File

@ -26,7 +26,7 @@ class SkillControllerVirtualGuardTest {
void updateRejectsVirtualMcpId() { void updateRejectsVirtualMcpId() {
long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
MateClawException ex = assertThrows(MateClawException.class, MateClawException ex = assertThrows(MateClawException.class,
() -> controller.update(virtualId, new SkillEntity())); () -> controller.update(virtualId, new SkillEntity(), null));
assertTrue(ex.getMessage().contains("MCP/ACP"), assertTrue(ex.getMessage().contains("MCP/ACP"),
"expected redirect-to-connection-page hint, got: " + ex.getMessage()); "expected redirect-to-connection-page hint, got: " + ex.getMessage());
} }
@ -41,16 +41,16 @@ class SkillControllerVirtualGuardTest {
assertTrue(AcpSkillBridge.isVirtualAcpSkillId(virtualAcpId), assertTrue(AcpSkillBridge.isVirtualAcpSkillId(virtualAcpId),
"test fixture id is not in ACP virtual range; ACP base layout changed?"); "test fixture id is not in ACP virtual range; ACP base layout changed?");
assertThrows(MateClawException.class, assertThrows(MateClawException.class,
() -> controller.update(virtualAcpId, new SkillEntity())); () -> controller.update(virtualAcpId, new SkillEntity(), null));
} }
@Test @Test
@DisplayName("delete / toggle / rescan all reject virtual ids the same way") @DisplayName("delete / toggle / rescan all reject virtual ids the same way")
void mutationFamilyAllGuarded() { void mutationFamilyAllGuarded() {
long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
assertThrows(MateClawException.class, () -> controller.delete(virtualId)); assertThrows(MateClawException.class, () -> controller.delete(virtualId, null));
assertThrows(MateClawException.class, () -> controller.toggle(virtualId, true)); assertThrows(MateClawException.class, () -> controller.toggle(virtualId, true, null));
assertThrows(MateClawException.class, () -> controller.rescan(virtualId)); assertThrows(MateClawException.class, () -> controller.rescan(virtualId, null));
} }
@Test @Test
@ -69,7 +69,7 @@ class SkillControllerVirtualGuardTest {
// A virtual-id call would have thrown MateClawException before // A virtual-id call would have thrown MateClawException before
// reaching the service. // reaching the service.
try { try {
real.update(snowflakeId, new SkillEntity()); real.update(snowflakeId, new SkillEntity(), null);
} catch (MateClawException e) { } catch (MateClawException e) {
// The guard message contains "MCP/ACP"; any other MateClawException // The guard message contains "MCP/ACP"; any other MateClawException
// (e.g. from the service layer) is acceptable. // (e.g. from the service layer) is acceptable.

View File

@ -159,6 +159,53 @@ class SkillRuntimeServicePromptBudgetTest {
+ "prompt was: " + prompt); + "prompt was: " + prompt);
} }
@Test
@DisplayName("workspace filter hides other workspaces' skills, keeps builtin global")
void workspaceFilterHidesOtherWorkspaceSkills() {
SkillService skillService = mock(SkillService.class);
SkillPackageResolver resolver = mock(SkillPackageResolver.class);
SkillLessonsService lessonsService = mock(SkillLessonsService.class);
McpSkillBridge mcpBridge = mock(McpSkillBridge.class);
AcpSkillBridge acpBridge = mock(AcpSkillBridge.class);
SkillUsageService usageService = mock(SkillUsageService.class);
SkillEntity builtin = entity(1L, "pdf-builtin", "builtin");
SkillEntity ownWorkspace = entity(2L, "ws1-skill", "dynamic");
SkillEntity otherWorkspace = entity(3L, "ws2-skill", "dynamic");
when(skillService.listEnabledSkills()).thenReturn(List.of(builtin, ownWorkspace, otherWorkspace));
when(resolver.resolve(builtin)).thenReturn(scopedResolved(builtin, true, null));
when(resolver.resolve(ownWorkspace)).thenReturn(scopedResolved(ownWorkspace, false, 1L));
when(resolver.resolve(otherWorkspace)).thenReturn(scopedResolved(otherWorkspace, false, 2L));
when(mcpBridge.listMcpDerivedResolvedSkills()).thenReturn(List.of());
when(acpBridge.listAcpDerivedResolvedSkills()).thenReturn(List.of());
when(usageService.recentLoadedSkillNames(null, 8)).thenReturn(Set.of());
when(usageService.frequentlyLoadedSkillNames(8)).thenReturn(Set.of());
SkillRuntimeService runtime = new SkillRuntimeService(
skillService, resolver, lessonsService, mcpBridge, acpBridge, usageService);
// Agent lives in workspace 1.
String prompt = runtime.buildSkillPromptEnhancement(null, null, 8192, null, 1L);
assertTrue(prompt.contains("pdf-builtin"), "builtin skill must stay globally visible");
assertTrue(prompt.contains("ws1-skill"), "the agent's own workspace skill must be visible");
assertFalse(prompt.contains("ws2-skill"), "another workspace's skill must not leak into the prompt");
}
private static ResolvedSkill scopedResolved(SkillEntity entity, boolean builtin, Long workspaceId) {
return ResolvedSkill.builder()
.id(entity.getId())
.name(entity.getName())
.description(entity.getDescription())
.enabled(true)
.runtimeAvailable(true)
.dependencyReady(true)
.securityBlocked(false)
.builtin(builtin)
.workspaceId(workspaceId)
.build();
}
private static SkillEntity entity(Long id, String name, String type) { private static SkillEntity entity(Long id, String name, String type) {
SkillEntity entity = new SkillEntity(); SkillEntity entity = new SkillEntity();
entity.setId(id); entity.setId(id);

View File

@ -0,0 +1,104 @@
package vip.mate.skill.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import vip.mate.skill.model.SkillEntity;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Issue #135 verifies that skill read paths are scoped to one workspace:
* builtin skills are global (visible everywhere), every other skill is only
* visible inside its owning workspace. Without this, a workspace-B user saw
* workspace-A's skills in the marketplace but hit a 403 when binding them.
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:skill_ws_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none"
})
class SkillServiceWorkspaceScopeTest {
private static final AtomicLong SKILL_ID_SEQ = new AtomicLong(8_135_000L);
@Autowired
private SkillService skillService;
@Autowired
private JdbcTemplate jdbcTemplate;
/** Insert a skill row directly so {@code createSkill}'s workspace/FS side effects stay out of scope. */
private long seedSkill(String name, long workspaceId, boolean builtin) {
long id = SKILL_ID_SEQ.getAndIncrement();
jdbcTemplate.update(
"MERGE INTO mate_skill (id, name, skill_type, version, enabled, builtin, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, ?, ?, '1.0.0', TRUE, ?, ?, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
id, name, builtin ? "builtin" : "dynamic", builtin, workspaceId);
return id;
}
@Test
@DisplayName("listSkills(workspaceId) returns builtin + own-workspace skills, hides other workspaces")
void listSkillsIsWorkspaceScoped() {
String ws1Name = "ws1-skill-" + SKILL_ID_SEQ.get();
String ws2Name = "ws2-skill-" + SKILL_ID_SEQ.get();
String builtinName = "builtin-skill-" + SKILL_ID_SEQ.get();
seedSkill(ws1Name, 1L, false);
seedSkill(ws2Name, 2L, false);
seedSkill(builtinName, 1L, true);
Set<String> ws2Names = skillService.listSkills(2L).stream()
.map(SkillEntity::getName)
.collect(java.util.stream.Collectors.toSet());
assertTrue(ws2Names.contains(ws2Name), "workspace 2 must see its own skill");
assertTrue(ws2Names.contains(builtinName), "workspace 2 must see the global builtin skill");
assertFalse(ws2Names.contains(ws1Name), "workspace 2 must not see workspace 1's skill");
}
@Test
@DisplayName("pageSkills(workspaceId) excludes other workspaces' skills from the marketplace listing")
void pageSkillsIsWorkspaceScoped() {
String ws1Name = "page-ws1-" + SKILL_ID_SEQ.get();
String ws2Name = "page-ws2-" + SKILL_ID_SEQ.get();
seedSkill(ws1Name, 1L, false);
seedSkill(ws2Name, 2L, false);
IPage<SkillEntity> ws2Page = skillService.pageSkills(
1, 200, null, null, null, null, null, null, null, Set.of(), 2L);
Set<String> names = ws2Page.getRecords().stream()
.map(SkillEntity::getName)
.collect(java.util.stream.Collectors.toSet());
assertTrue(names.contains(ws2Name), "marketplace page for workspace 2 must list its own skill");
assertFalse(names.contains(ws1Name), "marketplace page for workspace 2 must not list workspace 1's skill");
}
@Test
@DisplayName("countByType(workspaceId) counts builtin globally but other skills per workspace")
void countByTypeIsWorkspaceScoped() {
long before = skillService.countByType(2L).getOrDefault("dynamic", 0L);
seedSkill("count-ws1-" + SKILL_ID_SEQ.get(), 1L, false);
seedSkill("count-ws2-" + SKILL_ID_SEQ.get(), 2L, false);
long after = skillService.countByType(2L).getOrDefault("dynamic", 0L);
assertTrue(after == before + 1,
"workspace 2's dynamic count should rise by exactly one (its own skill), not two");
}
}

View File

@ -1847,6 +1847,10 @@ export default {
sortName: 'Name', sortName: 'Name',
sortPages: 'Pages', sortPages: 'Pages',
backToLibrary: 'Back to library', backToLibrary: 'Back to library',
deleteKB: 'Delete knowledge base',
deleteKBConfirm: 'Delete knowledge base "{name}"? This will also remove {raws} raw materials and {pages} wiki pages. This action cannot be undone.',
deleteKBSuccess: 'Deleted knowledge base "{name}"',
deleteKBFailed: 'Failed to delete knowledge base',
}, },
selectPage: 'Select a page from the sidebar', selectPage: 'Select a page from the sidebar',
pageKicker: 'Knowledge Page', pageKicker: 'Knowledge Page',
@ -2151,7 +2155,7 @@ export default {
delivered: 'Delivered', delivered: 'Delivered',
not_delivered: 'Failed', not_delivered: 'Failed',
}, },
taskTypes: { text: 'Text Message', reminder: 'Reminder', agent: 'Agent Goal' }, taskTypes: { text: 'Text Message', reminder: 'Reminder', agent: 'Agent Goal', wiki_process: 'Wiki Processing' },
cronTypes: { hourly: 'Hourly', daily: 'Daily', weekly: 'Weekly', custom: 'Custom' }, cronTypes: { hourly: 'Hourly', daily: 'Daily', weekly: 'Weekly', custom: 'Custom' },
days: { mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun' }, days: { mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun' },
fields: { fields: {
@ -2166,6 +2170,11 @@ export default {
reminderTextPlaceholder: 'Exact text to push when the reminder fires (no LLM rewriting)', reminderTextPlaceholder: 'Exact text to push when the reminder fires (no LLM rewriting)',
requestBody: 'Goal', requestBody: 'Goal',
requestBodyPlaceholder: 'Describe the goal for the agent', requestBodyPlaceholder: 'Describe the goal for the agent',
wikiKb: 'Knowledge Base',
wikiKbPlaceholder: 'Select a knowledge base to process',
wikiKbEmpty: 'No knowledge bases in this workspace — create one in the Wiki page first.',
wikiForce: 'Force reprocess',
wikiForceHint: 'When on, clears last_processed_hash and re-queues every raw material. When off, only pending materials are queued.',
cronFrequency: 'Frequency', cronFrequency: 'Frequency',
cronTime: 'Time', cronTime: 'Time',
cronDays: 'Days', cronDays: 'Days',

View File

@ -1859,6 +1859,10 @@ export default {
sortName: '名称', sortName: '名称',
sortPages: '页面数', sortPages: '页面数',
backToLibrary: '返回库', backToLibrary: '返回库',
deleteKB: '删除知识库',
deleteKBConfirm: '确认删除知识库「{name}」?将一并清除 {raws} 个原始材料和 {pages} 个 Wiki 页面,此操作不可撤销。',
deleteKBSuccess: '已删除知识库「{name}」',
deleteKBFailed: '删除知识库失败',
}, },
selectPage: '从左侧选择一个页面查看', selectPage: '从左侧选择一个页面查看',
pageKicker: '知识页面', pageKicker: '知识页面',
@ -2163,7 +2167,7 @@ export default {
delivered: '已送达', delivered: '已送达',
not_delivered: '投递失败', not_delivered: '投递失败',
}, },
taskTypes: { text: '文字消息', reminder: '提醒', agent: 'Agent 目标' }, taskTypes: { text: '文字消息', reminder: '提醒', agent: 'Agent 目标', wiki_process: '知识库处理' },
cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' }, cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' },
days: { mon: '周一', tue: '周二', wed: '周三', thu: '周四', fri: '周五', sat: '周六', sun: '周日' }, days: { mon: '周一', tue: '周二', wed: '周三', thu: '周四', fri: '周五', sat: '周六', sun: '周日' },
fields: { fields: {
@ -2178,6 +2182,11 @@ export default {
reminderTextPlaceholder: '输入到点要原样推送的提醒内容(不会经过 LLM 改写)', reminderTextPlaceholder: '输入到点要原样推送的提醒内容(不会经过 LLM 改写)',
requestBody: '执行目标', requestBody: '执行目标',
requestBodyPlaceholder: '直接描述 Agent 要完成的目标', requestBodyPlaceholder: '直接描述 Agent 要完成的目标',
wikiKb: '知识库',
wikiKbPlaceholder: '选择要处理的知识库',
wikiKbEmpty: '当前工作区还没有知识库,请先在 Wiki 页面创建',
wikiForce: '强制重处理',
wikiForceHint: '开启后清空所有 last_processed_hash重新入队全部材料关闭时只处理 pending 状态的材料',
cronFrequency: '执行频率', cronFrequency: '执行频率',
cronTime: '执行时间', cronTime: '执行时间',
cronDays: '执行星期', cronDays: '执行星期',

View File

@ -923,9 +923,9 @@ export interface CronJob {
name: string name: string
cronExpression: string cronExpression: string
timezone: string timezone: string
agentId: string | number agentId: string | number | null
agentName?: string agentName?: string
taskType: 'text' | 'agent' | 'reminder' taskType: 'text' | 'agent' | 'reminder' | 'wiki_process'
triggerMessage?: string triggerMessage?: string
requestBody?: string requestBody?: string
enabled: boolean enabled: boolean

View File

@ -44,7 +44,7 @@
<div class="job-main"> <div class="job-main">
<div class="job-name" :title="job.name">{{ job.name }}</div> <div class="job-name" :title="job.name">{{ job.name }}</div>
<div class="job-meta-row"> <div class="job-meta-row">
<span class="agent-badge" :title="job.agentName || 'Unknown'">{{ job.agentName || 'Unknown' }}</span> <span v-if="job.taskType !== 'wiki_process'" class="agent-badge" :title="job.agentName || 'Unknown'">{{ job.agentName || 'Unknown' }}</span>
<span class="type-badge" :class="'type-' + job.taskType"> <span class="type-badge" :class="'type-' + job.taskType">
{{ t('cronJobs.taskTypes.' + job.taskType) }} {{ t('cronJobs.taskTypes.' + job.taskType) }}
</span> </span>
@ -140,7 +140,7 @@
</button> </button>
</div> </div>
<div class="modal-body detail-grid"> <div class="modal-body detail-grid">
<div class="detail-item"> <div class="detail-item" v-if="detailJob.taskType !== 'wiki_process'">
<div class="detail-label">{{ t('cronJobs.columns.agent') }}</div> <div class="detail-label">{{ t('cronJobs.columns.agent') }}</div>
<div class="detail-value">{{ detailJob.agentName || 'Unknown' }}</div> <div class="detail-value">{{ detailJob.agentName || 'Unknown' }}</div>
</div> </div>
@ -193,6 +193,18 @@
<div class="detail-label">{{ t('cronJobs.fields.reminderText') }}</div> <div class="detail-label">{{ t('cronJobs.fields.reminderText') }}</div>
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div> <div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
</div> </div>
<template v-else-if="detailJob.taskType === 'wiki_process'">
<div class="detail-item">
<div class="detail-label">{{ t('cronJobs.fields.wikiKb') }}</div>
<div class="detail-value">{{ wikiProcessSummary(detailJob).kbLabel }}</div>
</div>
<div class="detail-item">
<div class="detail-label">{{ t('cronJobs.fields.wikiForce') }}</div>
<div class="detail-value">
{{ wikiProcessSummary(detailJob).force ? t('common.yes') : t('common.no') }}
</div>
</div>
</template>
<div class="detail-item detail-item-full" v-else> <div class="detail-item detail-item-full" v-else>
<div class="detail-label">{{ t('cronJobs.fields.requestBody') }}</div> <div class="detail-label">{{ t('cronJobs.fields.requestBody') }}</div>
<div class="detail-value detail-block">{{ detailJob.requestBody || '-' }}</div> <div class="detail-value detail-block">{{ detailJob.requestBody || '-' }}</div>
@ -218,7 +230,7 @@
<input v-model="form.name" class="form-input" :placeholder="t('cronJobs.fields.namePlaceholder')" /> <input v-model="form.name" class="form-input" :placeholder="t('cronJobs.fields.namePlaceholder')" />
</div> </div>
<div class="form-group"> <div v-if="form.taskType !== 'wiki_process'" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.agent') }} *</label> <label class="form-label">{{ t('cronJobs.fields.agent') }} *</label>
<select v-model="form.agentId" class="form-input"> <select v-model="form.agentId" class="form-input">
<option :value="undefined" disabled>{{ t('cronJobs.fields.agentPlaceholder') }}</option> <option :value="undefined" disabled>{{ t('cronJobs.fields.agentPlaceholder') }}</option>
@ -241,6 +253,10 @@
<input type="radio" v-model="form.taskType" value="agent" /> <input type="radio" v-model="form.taskType" value="agent" />
{{ t('cronJobs.taskTypes.agent') }} {{ t('cronJobs.taskTypes.agent') }}
</label> </label>
<label class="radio-option" :class="{ active: form.taskType === 'wiki_process' }">
<input type="radio" v-model="form.taskType" value="wiki_process" />
{{ t('cronJobs.taskTypes.wiki_process') }}
</label>
</div> </div>
</div> </div>
@ -254,11 +270,33 @@
<textarea v-model="form.triggerMessage" class="form-textarea" rows="3" <textarea v-model="form.triggerMessage" class="form-textarea" rows="3"
:placeholder="t('cronJobs.fields.reminderTextPlaceholder')"></textarea> :placeholder="t('cronJobs.fields.reminderTextPlaceholder')"></textarea>
</div> </div>
<div v-else class="form-group"> <div v-else-if="form.taskType === 'agent'" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.requestBody') }} *</label> <label class="form-label">{{ t('cronJobs.fields.requestBody') }} *</label>
<textarea v-model="form.requestBody" class="form-textarea" rows="3" <textarea v-model="form.requestBody" class="form-textarea" rows="3"
:placeholder="t('cronJobs.fields.requestBodyPlaceholder')"></textarea> :placeholder="t('cronJobs.fields.requestBodyPlaceholder')"></textarea>
</div> </div>
<template v-else>
<div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.wikiKb') }} *</label>
<select v-model="form.wikiKbId" class="form-input">
<option value="" disabled>{{ t('cronJobs.fields.wikiKbPlaceholder') }}</option>
<option v-for="kb in wikiKbs" :key="kb.id" :value="kb.id">{{ kb.name }}</option>
</select>
<p v-if="wikiKbsLoaded && wikiKbs.length === 0" class="form-hint">
{{ t('cronJobs.fields.wikiKbEmpty') }}
</p>
</div>
<div class="form-group">
<label class="toggle-label">
<label class="toggle-switch">
<input type="checkbox" v-model="form.wikiForce" />
<span class="toggle-slider"></span>
</label>
<span>{{ t('cronJobs.fields.wikiForce') }}</span>
</label>
<p class="form-hint">{{ t('cronJobs.fields.wikiForceHint') }}</p>
</div>
</template>
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.cronFrequency') }}</label> <label class="form-label">{{ t('cronJobs.fields.cronFrequency') }}</label>
@ -328,8 +366,14 @@ import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm' import { mcConfirm } from '@/components/common/useConfirm'
import { useCronJobStore } from '@/stores/useCronJobStore' import { useCronJobStore } from '@/stores/useCronJobStore'
import { useAgentStore } from '@/stores/useAgentStore' import { useAgentStore } from '@/stores/useAgentStore'
import { wikiApi } from '@/api/index'
import type { CronJob } from '@/types/index' import type { CronJob } from '@/types/index'
interface WikiKbOption {
id: number | string
name: string
}
const { t } = useI18n() const { t } = useI18n()
const store = useCronJobStore() const store = useCronJobStore()
const agentStore = useAgentStore() const agentStore = useAgentStore()
@ -338,6 +382,8 @@ const agents = computed(() => agentStore.agents)
const showModal = ref(false) const showModal = ref(false)
const editing = ref<CronJob | null>(null) const editing = ref<CronJob | null>(null)
const detailJob = ref<CronJob | null>(null) const detailJob = ref<CronJob | null>(null)
const wikiKbs = ref<WikiKbOption[]>([])
const wikiKbsLoaded = ref(false)
const cronTypeOptions = ['hourly', 'daily', 'weekly', 'custom'] as const const cronTypeOptions = ['hourly', 'daily', 'weekly', 'custom'] as const
const cronType = ref<string>('daily') const cronType = ref<string>('daily')
@ -352,7 +398,7 @@ const timezones = [
'Australia/Sydney', 'Australia/Sydney',
] ]
const defaultForm = (): Partial<CronJob> => ({ const defaultForm = (): Partial<CronJob> & { wikiKbId?: number | string; wikiForce?: boolean } => ({
name: '', name: '',
cronExpression: '', cronExpression: '',
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
@ -361,18 +407,64 @@ const defaultForm = (): Partial<CronJob> => ({
triggerMessage: '', triggerMessage: '',
requestBody: '', requestBody: '',
enabled: true, enabled: true,
wikiKbId: '',
wikiForce: false,
}) })
const form = ref<any>(defaultForm()) const form = ref<any>(defaultForm())
const canSave = computed(() => { const canSave = computed(() => {
if (!form.value.name || !form.value.agentId) return false if (!form.value.name) return false
// wiki_process has no agent binding; every other task type needs an agent.
if (form.value.taskType !== 'wiki_process' && !form.value.agentId) return false
if (form.value.taskType === 'text' && !form.value.triggerMessage) return false if (form.value.taskType === 'text' && !form.value.triggerMessage) return false
if (form.value.taskType === 'reminder' && !form.value.triggerMessage) return false if (form.value.taskType === 'reminder' && !form.value.triggerMessage) return false
if (form.value.taskType === 'agent' && !form.value.requestBody) return false if (form.value.taskType === 'agent' && !form.value.requestBody) return false
if (form.value.taskType === 'wiki_process'
&& (form.value.wikiKbId == null || form.value.wikiKbId === '')) return false
if (cronType.value === 'custom' && !form.value.cronExpression?.trim()) return false if (cronType.value === 'custom' && !form.value.cronExpression?.trim()) return false
return true return true
}) })
async function loadWikiKbs() {
if (wikiKbsLoaded.value) return
try {
const res: any = await wikiApi.listKBs()
wikiKbs.value = (res?.data || []).map((kb: any) => ({
id: kb.id,
name: kb.name || (kb.id != null ? String(kb.id) : ''),
}))
} catch {
wikiKbs.value = []
} finally {
wikiKbsLoaded.value = true
}
}
// Parse a wiki_process request body back into form fields so the edit modal
// shows the bound KB and force flag instead of a raw JSON blob.
function applyWikiProcessForm(target: any, requestBody: string | null | undefined) {
if (!requestBody) {
target.wikiKbId = ''
target.wikiForce = false
return
}
try {
const payload = JSON.parse(requestBody)
// Keep kbId as string to preserve Snowflake precision.
target.wikiKbId = payload.kbId != null ? String(payload.kbId) : ''
target.wikiForce = !!payload.force
} catch {
target.wikiKbId = ''
target.wikiForce = false
}
}
watch(() => form.value.taskType, (next) => {
if (next === 'wiki_process') {
loadWikiKbs()
}
})
onMounted(() => { onMounted(() => {
store.fetchJobs() store.fetchJobs()
agentStore.fetchAgents() agentStore.fetchAgents()
@ -411,7 +503,11 @@ function openCreateModal() {
function openEditModal(job: CronJob) { function openEditModal(job: CronJob) {
editing.value = job editing.value = job
form.value = { ...job } form.value = { ...defaultForm(), ...job }
if (job.taskType === 'wiki_process') {
applyWikiProcessForm(form.value, job.requestBody)
loadWikiKbs()
}
const parsed = parseCronToForm(job.cronExpression) const parsed = parseCronToForm(job.cronExpression)
cronType.value = parsed.type cronType.value = parsed.type
cronTime.value = parsed.time cronTime.value = parsed.time
@ -426,19 +522,69 @@ function closeModal() {
function openDetailModal(job: CronJob) { function openDetailModal(job: CronJob) {
detailJob.value = job detailJob.value = job
if (job.taskType === 'wiki_process') {
loadWikiKbs()
}
}
interface WikiProcessSummary {
kbId: string
kbLabel: string
force: boolean
}
function wikiProcessSummary(job: CronJob): WikiProcessSummary {
if (!job?.requestBody) {
return { kbId: '', kbLabel: '-', force: false }
}
try {
const payload = JSON.parse(job.requestBody)
const kbId = payload.kbId != null ? String(payload.kbId) : ''
const match = wikiKbs.value.find((kb) => String(kb.id) === kbId)
return {
kbId,
kbLabel: match ? match.name : (kbId ? `#${kbId}` : '-'),
force: !!payload.force,
}
} catch {
return { kbId: '', kbLabel: '-', force: false }
}
} }
function closeDetailModal() { function closeDetailModal() {
detailJob.value = null detailJob.value = null
} }
function buildSavePayload() {
// Strip the form-only wiki helpers and substitute them with the canonical
// JSON request_body the backend expects for wiki_process. For every other
// task type the payload is forwarded as-is.
const { wikiKbId, wikiForce, ...rest } = form.value
if (form.value.taskType === 'wiki_process') {
return {
...rest,
// Drop agent binding server defaults to a 0 sentinel for system tasks.
agentId: undefined,
triggerMessage: '',
// Stringify kbId to preserve Snowflake precision over JSON.parse on the
// backend, which accepts both number and string forms.
requestBody: JSON.stringify({
kbId: wikiKbId != null ? String(wikiKbId) : '',
force: !!wikiForce,
}),
}
}
return rest
}
async function saveJob() { async function saveJob() {
try { try {
const payload = buildSavePayload()
if (editing.value) { if (editing.value) {
await store.updateJob(editing.value.id, form.value) await store.updateJob(editing.value.id, payload)
mcToast.success(t('cronJobs.messages.updateSuccess')) mcToast.success(t('cronJobs.messages.updateSuccess'))
} else { } else {
await store.createJob(form.value) await store.createJob(payload)
mcToast.success(t('cronJobs.messages.createSuccess')) mcToast.success(t('cronJobs.messages.createSuccess'))
} }
closeModal() closeModal()
@ -694,6 +840,7 @@ function formatTime(datetime: string | undefined): string {
.type-text { background: var(--mc-primary-bg); color: var(--mc-primary); } .type-text { background: var(--mc-primary-bg); color: var(--mc-primary); }
.type-reminder { background: var(--mc-warning-bg, var(--mc-primary-bg)); color: var(--mc-warning, var(--mc-primary-hover)); } .type-reminder { background: var(--mc-warning-bg, var(--mc-primary-bg)); color: var(--mc-warning, var(--mc-primary-hover)); }
.type-agent { background: var(--mc-success-bg, var(--mc-primary-bg)); color: var(--mc-success, var(--mc-primary-hover)); } .type-agent { background: var(--mc-success-bg, var(--mc-primary-bg)); color: var(--mc-success, var(--mc-primary-hover)); }
.type-wiki_process { background: rgba(99, 102, 241, 0.12); color: rgb(99, 102, 241); }
.cron-code { display: inline-flex; background: var(--mc-bg-sunken); padding: 4px 8px; border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); font-family: monospace; } .cron-code { display: inline-flex; background: var(--mc-bg-sunken); padding: 4px 8px; border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); font-family: monospace; }
.cron-readable { font-size: 12px; line-height: 1.45; color: var(--mc-text-tertiary); margin-top: 6px; } .cron-readable { font-size: 12px; line-height: 1.45; color: var(--mc-text-tertiary); margin-top: 6px; }
.runtime-stack { display: flex; flex-direction: column; gap: 6px; } .runtime-stack { display: flex; flex-direction: column; gap: 6px; }
@ -811,6 +958,7 @@ function formatTime(datetime: string | undefined): string {
.form-group { display: flex; flex-direction: column; gap: 6px; } .form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); } .form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); }
.form-hint { font-size: 12px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.45; }
.form-input { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); width: 100%; } .form-input { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); width: 100%; }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); } .form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.form-input.mono { font-family: monospace; } .form-input.mono { font-family: monospace; }

View File

@ -1,10 +1,29 @@
<template> <template>
<button <div
type="button"
class="kb-card mc-surface-card" class="kb-card mc-surface-card"
:class="{ 'kb-card--has-warn': failedJobCount > 0 }" :class="{ 'kb-card--has-warn': failedJobCount > 0 }"
role="button"
tabindex="0"
@click="$emit('open', kb.id)" @click="$emit('open', kb.id)"
@keydown.enter.prevent="$emit('open', kb.id)"
@keydown.space.prevent="$emit('open', kb.id)"
> >
<button
type="button"
class="kb-card-delete"
:title="t('wiki.library.deleteKB')"
:aria-label="t('wiki.library.deleteKB')"
@click.stop="$emit('delete', kb)"
@keydown.enter.stop
@keydown.space.stop
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>
</svg>
</button>
<div class="kb-card-top"> <div class="kb-card-top">
<div class="kb-card-icon" :style="iconStyle">{{ initial }}</div> <div class="kb-card-icon" :style="iconStyle">{{ initial }}</div>
<span <span
@ -35,7 +54,7 @@
<div v-if="failedJobCount > 0" class="kb-card-warn"> <div v-if="failedJobCount > 0" class="kb-card-warn">
{{ t('wiki.stats.failedJobs', { count: failedJobCount }) }} {{ t('wiki.stats.failedJobs', { count: failedJobCount }) }}
</div> </div>
</button> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -49,7 +68,10 @@ const props = defineProps<{
failedJobCount?: number failedJobCount?: number
}>() }>()
defineEmits<{ (e: 'open', id: number): void }>() defineEmits<{
(e: 'open', id: number): void
(e: 'delete', kb: WikiKB): void
}>()
const { t, locale } = useI18n() const { t, locale } = useI18n()
@ -64,6 +86,7 @@ const relative = computed(() => relativeTime(props.kb.updateTime, locale.value.s
<style scoped> <style scoped>
.kb-card { .kb-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@ -82,6 +105,43 @@ const relative = computed(() => relativeTime(props.kb.updateTime, locale.value.s
} }
.kb-card:focus-visible { outline: 2px solid var(--mc-primary); outline-offset: 2px; } .kb-card:focus-visible { outline: 2px solid var(--mc-primary); outline-offset: 2px; }
.kb-card-delete {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--mc-border-light);
border-radius: 8px;
background: var(--mc-bg-elevated);
color: var(--mc-text-tertiary);
cursor: pointer;
opacity: 0;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease, color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
padding: 0;
z-index: 1;
}
.kb-card:hover .kb-card-delete,
.kb-card:focus-within .kb-card-delete {
opacity: 1;
transform: translateY(0);
}
.kb-card-delete:hover {
color: var(--mc-danger);
border-color: rgba(245, 108, 108, 0.45);
background: rgba(245, 108, 108, 0.08);
}
.kb-card-delete:focus-visible {
outline: 2px solid var(--mc-danger);
outline-offset: 2px;
opacity: 1;
transform: translateY(0);
}
.kb-card-top { .kb-card-top {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

View File

@ -66,6 +66,7 @@
:kb="kb" :kb="kb"
:failed-job-count="kbStats[kb.id]?.failedJobCount || 0" :failed-job-count="kbStats[kb.id]?.failedJobCount || 0"
@open="$emit('open', $event)" @open="$emit('open', $event)"
@delete="$emit('delete', $event)"
/> />
</div> </div>
@ -103,6 +104,7 @@ const props = defineProps<{
defineEmits<{ defineEmits<{
(e: 'open', id: number): void (e: 'open', id: number): void
(e: 'create'): void (e: 'create'): void
(e: 'delete', kb: WikiKB): void
}>() }>()
const { t } = useI18n() const { t } = useI18n()

View File

@ -9,6 +9,7 @@
:loading="store.loading" :loading="store.loading"
@open="enterKB" @open="enterKB"
@create="showCreateKB = true" @create="showCreateKB = true"
@delete="handleDeleteKB"
/> />
<WikiWorkspace <WikiWorkspace
v-else v-else
@ -40,8 +41,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue' import { ref, reactive, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore' import { useWikiStore, type WikiKB } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index' import { wikiApi } from '@/api/index'
import { mcConfirm } from '@/components/common/useConfirm'
import { mcToast } from '@/composables/useMcToast'
import WikiLibrary from './components/WikiLibrary.vue' import WikiLibrary from './components/WikiLibrary.vue'
import WikiWorkspace from './components/WikiWorkspace.vue' import WikiWorkspace from './components/WikiWorkspace.vue'
@ -85,6 +88,26 @@ async function handleCreateKB() {
newKBDesc.value = '' newKBDesc.value = ''
} }
async function handleDeleteKB(kb: WikiKB) {
const ok = await mcConfirm({
title: t('wiki.library.deleteKB'),
message: t('wiki.library.deleteKBConfirm', {
name: kb.name,
raws: kb.rawCount ?? 0,
pages: kb.pageCount ?? 0,
}),
confirmText: t('common.delete'),
tone: 'danger',
})
if (!ok) return
try {
await store.deleteKB(kb.id)
mcToast.success(t('wiki.library.deleteKBSuccess', { name: kb.name }))
} catch (e: any) {
mcToast.error(e?.response?.data?.message || t('wiki.library.deleteKBFailed'))
}
}
onMounted(() => { onMounted(() => {
store.fetchKnowledgeBases() store.fetchKnowledgeBases()
}) })