mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): install/uninstall split + Requirements API + provider router
This commit is contained in:
parent
91e231e7a5
commit
d927521d51
@ -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());
|
||||
|
||||
@ -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 */ }
|
||||
}
|
||||
@ -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 = "从对话历史合成 Skill(RFC-023)")
|
||||
|
||||
@ -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'))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user