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();
// 使用 skill runtime 构建技能增强per-agent 绑定过滤
// 使用 skill runtime 构建技能增强per-agent 绑定过滤 + 工作区隔离
Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(
boundSkillIds, boundTools, maxInputTokens, entity.getId());
boundSkillIds, boundTools, maxInputTokens, entity.getId(), entity.getWorkspaceId());
// 工具调用指导
String toolGuidance = """

View File

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

View File

@ -127,6 +127,41 @@ public class CronJobLifecycleService {
.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
* succeeded, then publish the two domain events. The

View File

@ -1,5 +1,7 @@
package vip.mate.cron.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.model.CronJobEntity;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.wiki.service.WikiProcessingService;
/**
* 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 CronChatOriginFactory originFactory;
private final vip.mate.cron.CronConversationResolver conversationResolver;
private final WikiProcessingService wikiProcessingService;
private final ObjectMapper objectMapper;
/**
* Scheduler-facing entry. Runs three logical segments:
@ -66,6 +71,16 @@ public class CronJobRunner {
log.warn("[CronRunner] executeJob called with null job — ignoring");
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())
? job.getRequestBody()
: 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
* 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.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
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.repository.CronJobMapper;
import vip.mate.exception.MateClawException;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.repository.WikiKnowledgeBaseMapper;
import java.time.Duration;
import java.time.Instant;
@ -56,6 +60,8 @@ public class CronJobService implements ApplicationRunner {
private final CronJobMapper cronJobMapper;
private final AgentMapper agentMapper;
private final ChannelMapper channelMapper;
private final WikiKnowledgeBaseMapper wikiKnowledgeBaseMapper;
private final ObjectMapper objectMapper;
/**
* RFC-03 Lane G2: distributed lock for fire-time execution. ShedLock's
* 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.getTaskType() == null) entity.setTaskType("text");
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()));
try {
@ -330,7 +343,13 @@ public class CronJobService implements ApplicationRunner {
existing.setName(dto.getName());
existing.setCronExpression(dto.getCronExpression());
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.setTriggerMessage(dto.getTriggerMessage());
existing.setRequestBody(dto.getRequestBody());
@ -651,13 +670,15 @@ public class CronJobService implements ApplicationRunner {
if (dto.getName() == null || dto.getName().isBlank()) {
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()) {
throw new MateClawException("err.cron.expression_required", "Cron 表达式不能为空");
}
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.
if (("text".equals(taskType) || "reminder".equals(taskType))
&& (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())) {
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
@RequireWorkspaceRole("member")
public R<IPage<SkillEntity>> list(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@ -78,9 +79,10 @@ public class SkillController {
Set<Long> pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of();
if (pinnedSkillIds == null) pinnedSkillIds = Set.of();
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(
keyword, skillType, enabled, scanStatus, sort, source, runtime);
workspaceId, keyword, skillType, enabled, scanStatus, sort, source, runtime);
if (!virtualSkills.isEmpty()) {
VirtualPageMergeResult merged = mergeVirtualTailPageRecords(
dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size);
@ -93,9 +95,10 @@ public class SkillController {
@Operation(summary = "获取各类型技能计数tab 徽章用)")
@GetMapping("/counts")
@RequireWorkspaceRole("member")
public R<Map<String, Long>> counts() {
Map<String, Long> result = skillService.countByType();
Set<String> realNames = realSkillNames();
public R<Map<String, Long>> counts(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
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,
// so countByType() misses them. Fold in the live count so the
// "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) {}
private List<SkillEntity> visibleVirtualSkills(String keyword,
private List<SkillEntity> visibleVirtualSkills(Long workspaceId,
String keyword,
String skillType,
Boolean enabled,
String scanStatus,
@ -197,7 +201,7 @@ public class SkillController {
boolean includeAcpVirtuals = isAllSkillType(effectiveSource) || "acp".equalsIgnoreCase(effectiveSource);
if (!includeMcpVirtuals && !includeAcpVirtuals) return List.of();
Set<String> realNames = realSkillNames();
Set<String> realNames = realSkillNames(workspaceId);
List<SkillEntity> result = new ArrayList<>();
if (includeMcpVirtuals) {
try {
@ -245,8 +249,14 @@ public class SkillController {
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)
.collect(java.util.stream.Collectors.toSet());
}
@ -254,8 +264,10 @@ public class SkillController {
@Operation(summary = "重新扫描单个技能RFC-042 §2.3.4")
@PostMapping("/{id}/rescan")
@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);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
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.")
@PostMapping("/{id}/sync-files")
@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);
SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId);
var report = skillFileSyncer.syncOne(skill);
Map<String, Object> body = new LinkedHashMap<>();
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 = "获取已启用技能列表")
@GetMapping("/enabled")
@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
// edit picker (which calls this endpoint) sees MCP- and ACP-derived
// virtual skills alongside the persisted ones. The shadow base must
// include all real skill names including disabled ones so a
// disabled real skill correctly suppresses its same-named virtual
// twin, matching /skills and /counts.
List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills());
Set<String> realNames = realSkillNames();
List<SkillEntity> result = new ArrayList<>(skillService.listEnabledSkills(workspaceId));
Set<String> realNames = realSkillNames(workspaceId);
try {
result.addAll(filterShadowedVirtualSkills(
@ -345,21 +382,24 @@ public class SkillController {
@Operation(summary = "按类型获取技能列表")
@GetMapping("/type/{skillType}")
@RequireWorkspaceRole("member")
public R<List<SkillEntity>> listByType(@PathVariable String skillType) {
return R.ok(skillService.listSkillsByType(skillType));
public R<List<SkillEntity>> listByType(@PathVariable String skillType,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(skillService.listSkillsByType(skillType, workspaceId));
}
@Operation(summary = "获取已启用技能摘要(按类型分组)")
@GetMapping("/summary")
@RequireWorkspaceRole("member")
public R<Map<String, List<String>>> summary() {
return R.ok(skillService.getEnabledSkillSummary());
public R<Map<String, List<String>>> summary(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(skillService.getEnabledSkillSummary(workspaceId));
}
@Operation(summary = "获取技能详情")
@GetMapping("/{id}")
@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
// on demand from the live MCP server entity.
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) {
@ -375,21 +415,31 @@ public class SkillController {
SkillEntity ent = acpSkillBridge.findEntityById(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 = "创建技能")
@PostMapping
@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));
}
@Operation(summary = "更新技能")
@PutMapping("/{id}")
@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);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
skill.setId(id);
return R.ok(skillService.updateSkill(skill));
}
@ -406,8 +456,10 @@ public class SkillController {
@Operation(summary = "硬删除技能 (admin only — 物理删除 + 工作区清空)")
@DeleteMapping("/{id}")
@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);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
skillService.hardDeleteSkill(id);
return R.ok();
}
@ -415,8 +467,10 @@ public class SkillController {
@Operation(summary = "启用/禁用技能")
@PutMapping("/{id}/toggle")
@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);
verifyResourceWorkspace(skillService.getSkill(id), workspaceId);
return R.ok(skillService.toggleSkill(id, enabled));
}
@ -657,14 +711,16 @@ public class SkillController {
@Operation(summary = "从对话历史合成 SkillRFC-023")
@PostMapping("/synthesize-from-conversation")
@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");
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
if (conversationId == null || conversationId.isBlank()) {
return R.fail("conversationId is required");
}
SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(conversationId, agentId);
SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(
conversationId, agentId, workspaceId);
if (result.blocked()) {
return R.ok(Map.of(
@ -690,8 +746,10 @@ public class SkillController {
@Operation(summary = "将 skill 导出到工作区目录")
@PostMapping("/{id}/export-workspace")
@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);
verifyResourceWorkspace(skill, workspaceId);
var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent());
if (path == null) {
return R.ok(Map.of("success", false, "message", "Failed to export workspace"));
@ -702,8 +760,10 @@ public class SkillController {
@Operation(summary = "获取 skill 工作区信息")
@GetMapping("/{id}/workspace")
@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);
verifyResourceWorkspace(skill, workspaceId);
return R.ok(workspaceManager.getWorkspaceInfo(skill.getName()));
}
}

View File

@ -45,10 +45,14 @@ public class SkillInstallController {
@Operation(summary = "开始异步安装 skill")
@PostMapping("/start")
@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()) {
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));
}
@ -78,7 +82,8 @@ public class SkillInstallController {
@RequestPart("file") MultipartFile zipFile,
@RequestParam(defaultValue = "true") Boolean enable,
@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();
if (filename == null || !filename.toLowerCase().endsWith(".zip")) {
@ -86,7 +91,8 @@ public class SkillInstallController {
}
try {
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);
} catch (IllegalArgumentException e) {
return R.fail(400, e.getMessage());
@ -98,8 +104,9 @@ public class SkillInstallController {
@Operation(summary = "卸载 skill")
@DeleteMapping("/{skillName}")
@RequireWorkspaceRole("admin")
public R<Map<String, String>> uninstall(@PathVariable String skillName) {
skillInstaller.uninstall(skillName);
public R<Map<String, String>> uninstall(@PathVariable String skillName,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
skillInstaller.uninstall(skillName, workspaceId);
return R.ok(Map.of("message", "Skill '" + skillName + "' uninstalled"));
}
}

View File

@ -81,7 +81,7 @@ public class SkillInstaller {
* admin-only physical removal, call
* {@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();
SkillEntity target = skills.stream()
.filter(s -> s.getName().equals(skillName))
@ -89,6 +89,18 @@ public class SkillInstaller {
.orElse(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());
}
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.
SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists,
Boolean.TRUE.equals(request.getEnable()));
Boolean.TRUE.equals(request.getEnable()), request.getWorkspaceId());
if (task.isCancelRequested()) {
task.markCancelled();
@ -199,7 +211,8 @@ public class SkillInstaller {
*
* @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();
if (skillName == null || skillName.isBlank()) {
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);
// 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.
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
* 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;
if (exists) {
skillEntity = skillService.listSkills().stream()
@ -267,6 +285,7 @@ public class SkillInstaller {
skillEntity.setSkillContent(bundle.content());
skillEntity.setConfigJson(buildConfigJson(bundle));
skillEntity.setEnabled(enable);
skillEntity.setWorkspaceId(workspaceId);
skillService.createSkill(skillEntity);
}
return skillEntity;

View File

@ -33,4 +33,11 @@ public class InstallRequest {
* an intentionally empty bundle.
*/
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()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.workspaceId(entity.getWorkspaceId())
.createTime(entity.getCreateTime())
.build();
}
@ -376,6 +377,7 @@ public class SkillPackageResolver {
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.workspaceId(entity.getWorkspaceId())
.createTime(entity.getCreateTime())
.build();
}

View File

@ -345,13 +345,29 @@ public class SkillRuntimeService {
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
Set<String> effectiveToolNames,
Integer maxInputTokens) {
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null);
return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null, null);
}
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds,
Set<String> effectiveToolNames,
Integer maxInputTokens,
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;
if (boundSkillIds != null) {
// Per-agent filter: pick the agent's bound subset from the
@ -381,6 +397,16 @@ public class SkillRuntimeService {
activeSkills = activeSkills.stream()
.filter(s -> matchesCurrentPlatform(s, currentOs))
.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()) {
return "";
}
@ -576,4 +602,17 @@ public class SkillRuntimeService {
}
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
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}.
* Used by the prompt-catalog ranker to surface freshly installed skills

View File

@ -67,6 +67,25 @@ public class SkillService {
// ==================== 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));
}
/**
* 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.
*
@ -88,33 +119,23 @@ public class SkillService {
* security_scan_status}: {@code "FAILED"} surfaces blocked skills so the
* admin can inspect findings and rescan, {@code "PASSED"} shows scanned
* 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,
String skillType, Boolean enabled,
String scanStatus,
String sort,
String source,
String runtime,
Set<Long> pinnedSkillIds) {
Set<Long> pinnedSkillIds,
Long workspaceId) {
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
applyWorkspaceScope(wrapper, workspaceId);
if (keyword != null && !keyword.isBlank()) {
String kw = keyword.trim();
@ -191,14 +212,19 @@ public class SkillService {
/**
* Aggregate skill counts per {@code skill_type}, plus an {@code all}
* 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<>();
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")) {
result.put(type, skillMapper.selectCount(
new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getSkillType, type)));
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getSkillType, type);
applyWorkspaceScope(wrapper, workspaceId);
result.put(type, skillMapper.selectCount(wrapper));
}
return result;
}
@ -217,6 +243,20 @@ public class SkillService {
.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 重名检查用
*/
@ -235,6 +275,17 @@ public class SkillService {
.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) {
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
if (skill.getSkillType() == null || skill.getSkillType().isBlank()) {
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 集成辅助方法 ====================
/**

View File

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

View File

@ -53,7 +53,8 @@ public class SkillTemplateController {
@RequireWorkspaceRole("admin")
public R<SkillEntity> instantiate(
@PathVariable String id,
@RequestBody Map<String, Object> values) {
return R.ok(templateService.instantiate(id, values));
@RequestBody Map<String, Object> 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
* the skill. Returns the created {@link SkillEntity}.
*
* @param templateId id from the registry (e.g. {@code tcm-qa})
* @param values user-supplied field values; missing required
* fields throw a translatable exception
* @param templateId id from the registry (e.g. {@code tcm-qa})
* @param values user-supplied field values; missing required
* 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);
if (template == null) {
throw new MateClawException("err.skill_template.not_found",
@ -97,6 +98,7 @@ public class SkillTemplateService {
entity.setAuthor("skill-template-wizard");
entity.setSkillContent(skillMd);
entity.setEnabled(true);
entity.setWorkspaceId(workspaceId);
SkillEntity created = skillService.createSkill(entity);

View File

@ -4,8 +4,11 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.SkillSecurityService;
@ -120,7 +123,12 @@ public class SkillManageTool {
@JsonProperty
@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()) {
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)";
}
Long workspaceId = ChatOrigin.from(toolContext).workspaceId();
return switch (action.strip().toLowerCase()) {
case "create" -> doCreate(normalizedName, content);
case "create" -> doCreate(normalizedName, content, workspaceId);
case "edit" -> doEdit(normalizedName, content);
case "patch" -> doPatch(normalizedName, oldText, newText);
case "delete" -> doDelete(normalizedName);
@ -146,7 +156,7 @@ public class SkillManageTool {
// ==================== Create ====================
private String doCreate(String name, String content) {
private String doCreate(String name, String content, Long workspaceId) {
if (content == null || content.isBlank()) {
return "Error: content is required for create action. Provide full SKILL.md content.";
}
@ -175,6 +185,7 @@ public class SkillManageTool {
skill.setBuiltin(false);
skill.setVersion(extractVersion(content));
skill.setSecurityScanStatus("PASSED");
skill.setWorkspaceId(workspaceId);
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.audit.service.AuditEventService;
import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.event.WikiProcessingEvent;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
@ -52,8 +51,8 @@ public class WikiController {
private final WikiProcessingService processingService;
private final WikiDirectoryScanService scanService;
private final WikiProperties properties;
private final ApplicationEventPublisher eventPublisher;
private final WikiProgressBus progressBus;
private final AuditEventService auditEventService;
// ==================== Knowledge Base ====================
@ -131,7 +130,12 @@ public class WikiController {
public R<Void> deleteKB(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long 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();
}
@ -501,21 +505,8 @@ public class WikiController {
@RequestParam(value = "force", defaultValue = "false") boolean force,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
List<WikiRawMaterialEntity> targets;
if (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));
int queued = processingService.processKB(kbId, force);
return R.ok(Map.of("queued", queued, "force", force));
}
@RequireWorkspaceRole("viewer")

View File

@ -5,8 +5,18 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
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.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.WikiPageCitationMapper;
import vip.mate.wiki.repository.WikiPageMapper;
import vip.mate.wiki.repository.WikiProcessingJobMapper;
import vip.mate.wiki.repository.WikiRawMaterialMapper;
import java.util.List;
@ -21,6 +31,11 @@ import java.util.List;
public class WikiKnowledgeBaseService {
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
@ -32,6 +47,19 @@ public class WikiKnowledgeBaseService {
@org.springframework.context.annotation.Lazy
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 = """
# 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
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);
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.wiki.WikiProperties;
import vip.mate.wiki.dto.WikiChunkDraft;
import vip.mate.wiki.event.WikiProcessingEvent;
import vip.mate.wiki.job.WikiKbConfig;
import vip.mate.wiki.job.WikiKbConfigParser;
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>

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.trigger_required=\u89e6\u53d1\u6d88\u606f\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)
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

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.trigger_required=Trigger message 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)
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

View File

@ -220,6 +220,29 @@ class AgentBindingServiceTest {
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
@DisplayName("bindSkill 允许 MCP 虚拟 skillMcpServerEntity 无 workspace全局共享")
void bindSkillAllowsVirtualMcpSkill() {

View File

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

View File

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

View File

@ -159,6 +159,53 @@ class SkillRuntimeServicePromptBudgetTest {
+ "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) {
SkillEntity entity = new SkillEntity();
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',
sortPages: 'Pages',
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',
pageKicker: 'Knowledge Page',
@ -2151,7 +2155,7 @@ export default {
delivered: 'Delivered',
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' },
days: { mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun' },
fields: {
@ -2166,6 +2170,11 @@ export default {
reminderTextPlaceholder: 'Exact text to push when the reminder fires (no LLM rewriting)',
requestBody: 'Goal',
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',
cronTime: 'Time',
cronDays: 'Days',

View File

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

View File

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

View File

@ -44,7 +44,7 @@
<div class="job-main">
<div class="job-name" :title="job.name">{{ job.name }}</div>
<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">
{{ t('cronJobs.taskTypes.' + job.taskType) }}
</span>
@ -140,7 +140,7 @@
</button>
</div>
<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-value">{{ detailJob.agentName || 'Unknown' }}</div>
</div>
@ -193,6 +193,18 @@
<div class="detail-label">{{ t('cronJobs.fields.reminderText') }}</div>
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</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-label">{{ t('cronJobs.fields.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')" />
</div>
<div class="form-group">
<div v-if="form.taskType !== 'wiki_process'" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.agent') }} *</label>
<select v-model="form.agentId" class="form-input">
<option :value="undefined" disabled>{{ t('cronJobs.fields.agentPlaceholder') }}</option>
@ -241,6 +253,10 @@
<input type="radio" v-model="form.taskType" value="agent" />
{{ t('cronJobs.taskTypes.agent') }}
</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>
@ -254,11 +270,33 @@
<textarea v-model="form.triggerMessage" class="form-textarea" rows="3"
:placeholder="t('cronJobs.fields.reminderTextPlaceholder')"></textarea>
</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>
<textarea v-model="form.requestBody" class="form-textarea" rows="3"
:placeholder="t('cronJobs.fields.requestBodyPlaceholder')"></textarea>
</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">
<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 { useCronJobStore } from '@/stores/useCronJobStore'
import { useAgentStore } from '@/stores/useAgentStore'
import { wikiApi } from '@/api/index'
import type { CronJob } from '@/types/index'
interface WikiKbOption {
id: number | string
name: string
}
const { t } = useI18n()
const store = useCronJobStore()
const agentStore = useAgentStore()
@ -338,6 +382,8 @@ const agents = computed(() => agentStore.agents)
const showModal = ref(false)
const editing = 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 cronType = ref<string>('daily')
@ -352,7 +398,7 @@ const timezones = [
'Australia/Sydney',
]
const defaultForm = (): Partial<CronJob> => ({
const defaultForm = (): Partial<CronJob> & { wikiKbId?: number | string; wikiForce?: boolean } => ({
name: '',
cronExpression: '',
timezone: 'Asia/Shanghai',
@ -361,18 +407,64 @@ const defaultForm = (): Partial<CronJob> => ({
triggerMessage: '',
requestBody: '',
enabled: true,
wikiKbId: '',
wikiForce: false,
})
const form = ref<any>(defaultForm())
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 === 'reminder' && !form.value.triggerMessage) 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
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(() => {
store.fetchJobs()
agentStore.fetchAgents()
@ -411,7 +503,11 @@ function openCreateModal() {
function openEditModal(job: CronJob) {
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)
cronType.value = parsed.type
cronTime.value = parsed.time
@ -426,19 +522,69 @@ function closeModal() {
function openDetailModal(job: CronJob) {
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() {
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() {
try {
const payload = buildSavePayload()
if (editing.value) {
await store.updateJob(editing.value.id, form.value)
await store.updateJob(editing.value.id, payload)
mcToast.success(t('cronJobs.messages.updateSuccess'))
} else {
await store.createJob(form.value)
await store.createJob(payload)
mcToast.success(t('cronJobs.messages.createSuccess'))
}
closeModal()
@ -694,6 +840,7 @@ function formatTime(datetime: string | undefined): string {
.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-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-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; }
@ -811,6 +958,7 @@ function formatTime(datetime: string | undefined): string {
.form-group { display: flex; flex-direction: column; gap: 6px; }
.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:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.form-input.mono { font-family: monospace; }

View File

@ -1,10 +1,29 @@
<template>
<button
type="button"
<div
class="kb-card mc-surface-card"
:class="{ 'kb-card--has-warn': failedJobCount > 0 }"
role="button"
tabindex="0"
@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-icon" :style="iconStyle">{{ initial }}</div>
<span
@ -35,7 +54,7 @@
<div v-if="failedJobCount > 0" class="kb-card-warn">
{{ t('wiki.stats.failedJobs', { count: failedJobCount }) }}
</div>
</button>
</div>
</template>
<script setup lang="ts">
@ -49,7 +68,10 @@ const props = defineProps<{
failedJobCount?: number
}>()
defineEmits<{ (e: 'open', id: number): void }>()
defineEmits<{
(e: 'open', id: number): void
(e: 'delete', kb: WikiKB): void
}>()
const { t, locale } = useI18n()
@ -64,6 +86,7 @@ const relative = computed(() => relativeTime(props.kb.updateTime, locale.value.s
<style scoped>
.kb-card {
position: relative;
display: flex;
flex-direction: column;
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-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 {
display: flex;
align-items: flex-start;

View File

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

View File

@ -9,6 +9,7 @@
:loading="store.loading"
@open="enterKB"
@create="showCreateKB = true"
@delete="handleDeleteKB"
/>
<WikiWorkspace
v-else
@ -40,8 +41,10 @@
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { useWikiStore, type WikiKB } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import { mcConfirm } from '@/components/common/useConfirm'
import { mcToast } from '@/composables/useMcToast'
import WikiLibrary from './components/WikiLibrary.vue'
import WikiWorkspace from './components/WikiWorkspace.vue'
@ -85,6 +88,26 @@ async function handleCreateKB() {
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(() => {
store.fetchKnowledgeBases()
})