mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): scope skill catalog and runtime by workspace (#135)
This commit is contained in:
parent
1f9adeb31d
commit
3b9b4d79d5
@ -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 = """
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", "知识库不存在");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = "从对话历史合成 Skill(RFC-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()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 安装结果 Map(skillId, 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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 时,目录只保留
|
||||
* 内置技能(全局)与该工作区拥有的技能;其他工作区
|
||||
* 的技能不会注入 prompt。null 表示不做工作区过滤
|
||||
* (调试预览等全局场景)。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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-023:SkillManageTool 重名检查用)
|
||||
*/
|
||||
@ -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 集成辅助方法 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -56,9 +56,10 @@ public class SkillSynthesisService {
|
||||
*
|
||||
* @param conversationId 源对话 ID
|
||||
* @param agentId Agent ID(用于记录来源)
|
||||
* @param workspaceId 目标工作区 ID(决定新 Skill 的归属)
|
||||
* @return 合成结果(包含 skillId、name、status)
|
||||
*/
|
||||
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);
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -220,6 +220,29 @@ class AgentBindingServiceTest {
|
||||
assertEquals(0, count, "拒绝时不能写入绑定行");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bindSkill 允许跨 workspace 的 builtin skill(builtin 为全局能力,不做 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 虚拟 skill(McpServerEntity 无 workspace,全局共享)")
|
||||
void bindSkillAllowsVirtualMcpSkill() {
|
||||
|
||||
@ -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())));
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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: '执行星期',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user