feat(skill): install/uninstall split + Requirements API + provider router

This commit is contained in:
matevip 2026-05-01 09:48:59 +08:00
parent 91e231e7a5
commit d927521d51
4 changed files with 221 additions and 4 deletions

View File

@ -59,6 +59,7 @@ import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.routing.ProviderRouter;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.planning.service.PlanningService;
@ -101,6 +102,7 @@ public class AgentGraphBuilder {
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
private final ProviderRouter providerRouter;
private final PlanningService planningService;
private final ToolGuardService toolGuardService;
private final vip.mate.tool.guard.service.ToolGuardConfigService toolGuardConfigService;
@ -157,6 +159,15 @@ public class AgentGraphBuilder {
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent请先在「设置 → 模型」中配置并启用默认模型");
}
// RFC-090 §9.2 调整 C log a warning when the chosen primary model
// doesn't satisfy the union of bound skills' requires-model. This is
// diagnostics only; the chain order isn't rewritten yet.
try {
providerRouter.diagnosePrimary(entity.getId(), runtimeModel);
} catch (Exception e) {
log.debug("[ProviderRouter] diagnostic failed: {}", e.getMessage());
}
ModelProviderEntity provider;
try {
provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());

View File

@ -0,0 +1,139 @@
package vip.mate.llm.routing;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.llm.service.ModelCapabilityService.Modality;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* RFC-090 §9.2 调整 C diagnostics-first ProviderRouter.
*
* <p>This first iteration does not yet rewrite the fallback chain order
* (the existing {@code AgentBindingService.getPreferredProviderIds} +
* {@link vip.mate.agent.AgentGraphBuilder#buildFallbackChain} flow is
* already in place). Instead it:
*
* <ol>
* <li>Aggregates {@code requires-model} from the agent's bound skills'
* manifests.</li>
* <li>Compares the union against the primary model's resolved
* capability set ({@link ModelCapabilityService#resolve}).</li>
* <li>Logs a clear WARN if a capability is missing surfacing the
* same gap RFC-085's "ready" badge would render in UI.</li>
* </ol>
*
* <p>Promoting this to actual chain re-ordering (i.e. "prefer providers
* that satisfy modelNeeds") is straightforward once we have the data
* for it: add a phase between {@code reorderByPreferences} and the
* model build loop. That phase is intentionally not in this commit so
* we can ship the diagnostics path independently and watch it in dev.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProviderRouter {
private final SkillRuntimeService skillRuntimeService;
private final AgentBindingService bindingService;
private final ModelCapabilityService capabilityService;
private final ModelConfigService modelConfigService;
/**
* Compute the union of capability requirements declared by the
* skills bound to {@code agentId}. Returns an empty set when no
* bindings exist or no skill declares {@code requires-model}.
*/
public Set<String> aggregateModelNeeds(Long agentId) {
if (agentId == null || skillRuntimeService == null) return Set.of();
Set<Long> boundSkillIds = bindingService.getBoundSkillIds(agentId);
if (boundSkillIds == null || boundSkillIds.isEmpty()) return Set.of();
List<ResolvedSkill> all = skillRuntimeService.resolveAllSkillsStatus();
Set<String> needs = new LinkedHashSet<>();
for (ResolvedSkill r : all) {
if (r == null || r.getId() == null) continue;
if (!boundSkillIds.contains(r.getId())) continue;
SkillManifest m = r.getManifest();
if (m == null) continue;
List<String> declared = m.getRequiresModel();
if (declared == null || declared.isEmpty()) continue;
needs.addAll(declared);
}
return needs;
}
/**
* Diagnostic check: does the chosen primary model satisfy every
* skill-declared capability? Logs a single WARN per gap.
*
* <p>Intentionally never throws this is observability, not policy.
*/
public void diagnosePrimary(Long agentId, ModelConfigEntity primary) {
if (primary == null || agentId == null) return;
Set<String> needs = aggregateModelNeeds(agentId);
if (needs.isEmpty()) return;
EnumSet<Modality> resolved = capabilityService.resolve(
primary.getModelName(), primary.getModalities());
for (String need : needs) {
Modality required = mapToModality(need);
if (required == null) continue; // capability we can't translate (e.g. function_calling) skip
if (!resolved.contains(required)) {
log.warn("[ProviderRouter] agent={} primary={}/{} missing capability '{}' " +
"required by bound skills (resolved: {})",
agentId, primary.getProvider(), primary.getModelName(),
need, resolved);
}
}
}
/**
* Translate a manifest {@code requires-model} token to a
* {@link Modality}. Tokens that don't map (e.g. {@code function_calling},
* {@code long_context_100k}) return null the caller skips diagnostics
* for them rather than emit a noisy warning we can't act on yet.
*/
private Modality mapToModality(String need) {
if (need == null) return null;
String n = need.trim().toLowerCase();
return switch (n) {
case "vision", "image", "vl" -> Modality.VISION;
case "video" -> Modality.VIDEO;
case "audio", "speech" -> Modality.AUDIO;
default -> null;
};
}
/**
* Convenience for tests / health checks: return the current model's
* capability resolution as a structured summary (provider/model
* modalities).
*/
public String summarize(Long agentId) {
try {
ModelConfigEntity primary = modelConfigService.getDefaultModel();
EnumSet<Modality> resolved = capabilityService.resolve(
primary.getModelName(), primary.getModalities());
Set<String> needs = aggregateModelNeeds(agentId);
return String.format("primary=%s/%s modalities=%s needs=%s",
primary.getProvider(), primary.getModelName(), resolved, needs);
} catch (Exception e) {
return "ProviderRouter summary unavailable: " + e.getMessage();
}
}
/** {@code @Lazy} hint — kept for symmetry if AgentBindingService is constructed first. */
@Lazy
public void noopForLazyHint() { /* no-op */ }
}

View File

@ -6,13 +6,17 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.SkillDependencyChecker;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.synthesis.SkillSynthesisService;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -34,6 +38,7 @@ public class SkillController {
private final SkillRuntimeService skillRuntimeService;
private final SkillWorkspaceManager workspaceManager;
private final SkillSynthesisService synthesisService;
private final SkillDependencyChecker dependencyChecker;
@Operation(summary = "获取技能分页列表RFC-042 §2.1")
@GetMapping
@ -150,6 +155,60 @@ public class SkillController {
"resynced", resynced));
}
// ==================== Requirements API (RFC-090 §7) ====================
/**
* RFC-090 §7 pre-flight check: returns the per-requirement status
* for the skill so the install dialog and the detail drawer can render
* "✓ ffmpeg detected / ✗ groq_key missing" rows.
*
* <p>Calls the same {@link SkillDependencyChecker} the runtime uses,
* so the UI never disagrees with the runtime gate.
*/
@Operation(summary = "Pre-flight requirement statuses for a skill (RFC-090)")
@GetMapping("/{id}/requirements")
public R<Map<String, Object>> requirements(@PathVariable Long id) {
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
.filter(r -> r != null && id.equals(r.getId()))
.findFirst()
.orElse(null);
if (resolved == null) {
return R.fail("Skill not found: " + id);
}
SkillManifest manifest = resolved.getManifest();
if (manifest == null) {
// Legacy skill fall back to dependency summary already on the resolved view.
return R.ok(Map.of(
"allMet", resolved.isDependencyReady(),
"statuses", List.of(),
"summary", resolved.getDependencySummary() != null ? resolved.getDependencySummary() : ""
));
}
List<Map<String, Object>> statuses = new ArrayList<>();
boolean allMet = true;
for (SkillManifest.RequirementDef req : manifest.getRequires()) {
SkillDependencyChecker.RequirementStatus st = dependencyChecker.checkRequirement(req);
boolean satisfied = st == SkillDependencyChecker.RequirementStatus.SATISFIED;
if (!satisfied && !req.isOptional()) allMet = false;
Map<String, Object> row = new LinkedHashMap<>();
row.put("key", req.getKey());
row.put("type", req.getType());
row.put("description", req.getDescription());
row.put("optional", req.isOptional());
row.put("status", st.name());
row.put("satisfied", satisfied);
row.put("installCommands", req.getInstall());
statuses.add(row);
}
return R.ok(Map.of(
"allMet", allMet,
"statuses", statuses,
"featureStatuses", resolved.getFeatureStatuses(),
"activeFeatures", resolved.getActiveFeatures()
));
}
// ==================== Synthesis API (RFC-023) ====================
@Operation(summary = "从对话历史合成 SkillRFC-023")

View File

@ -196,7 +196,7 @@
</svg>
{{ t('skills.actions.configure') }}
</button>
<button v-if="skill.skillType !== 'builtin'" class="skill-btn danger" @click="deleteSkill(skill.id)">
<button v-if="skill.skillType !== 'builtin'" class="skill-btn danger" @click="deleteSkill(skill)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
@ -396,7 +396,7 @@
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { skillApi } from '@/api/index'
import { skillApi, skillInstallApi } from '@/api/index'
import type { Skill, SkillRuntimeStatus, SkillSecurityFinding } from '@/types/index'
import ImportHubDialog from '@/components/skill/ImportHubDialog.vue'
import { useSkillName } from '@/composables/useSkillName'
@ -635,12 +635,20 @@ async function saveSkill() {
}
}
async function deleteSkill(id: string | number) {
async function deleteSkill(idOrSkill: string | number | Skill) {
// RFC-090 §14.5 UI "Delete" routes to uninstall, not hard-delete:
// - DELETE /skills/install/{name} archives the workspace + soft-delete row
// - DELETE /skills/{id} (admin "") stays hidden in the UI
// Resolve to the skill record so we can call the uninstall path by name.
const skill: Skill | undefined = typeof idOrSkill === 'object'
? idOrSkill
: skills.value.find(s => s.id === idOrSkill)
if (!skill) return
try {
await ElMessageBox.confirm(t('skills.messages.deleteConfirm'), t('skills.messages.deleteTitle'), { type: 'warning' })
} catch { return }
try {
await skillApi.delete(id)
await skillInstallApi.uninstall(skill.name)
await loadAll()
} catch (e: any) {
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.messages.deleteFailed'))