mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-17 12:54:40 +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.ModelFamily;
|
||||||
import vip.mate.llm.model.ModelProtocol;
|
import vip.mate.llm.model.ModelProtocol;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
|
import vip.mate.llm.routing.ProviderRouter;
|
||||||
import vip.mate.llm.service.ModelConfigService;
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
import vip.mate.llm.service.ModelProviderService;
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
import vip.mate.planning.service.PlanningService;
|
import vip.mate.planning.service.PlanningService;
|
||||||
@ -101,6 +102,7 @@ public class AgentGraphBuilder {
|
|||||||
private final ModelConfigService modelConfigService;
|
private final ModelConfigService modelConfigService;
|
||||||
private final ModelProviderService modelProviderService;
|
private final ModelProviderService modelProviderService;
|
||||||
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
|
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
|
||||||
|
private final ProviderRouter providerRouter;
|
||||||
private final PlanningService planningService;
|
private final PlanningService planningService;
|
||||||
private final ToolGuardService toolGuardService;
|
private final ToolGuardService toolGuardService;
|
||||||
private final vip.mate.tool.guard.service.ToolGuardConfigService toolGuardConfigService;
|
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:请先在「设置 → 模型」中配置并启用默认模型");
|
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;
|
ModelProviderEntity provider;
|
||||||
try {
|
try {
|
||||||
provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
|
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 lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.skill.manifest.SkillManifest;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
|
import vip.mate.skill.runtime.SkillDependencyChecker;
|
||||||
import vip.mate.skill.service.SkillService;
|
import vip.mate.skill.service.SkillService;
|
||||||
import vip.mate.skill.synthesis.SkillSynthesisService;
|
import vip.mate.skill.synthesis.SkillSynthesisService;
|
||||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -34,6 +38,7 @@ public class SkillController {
|
|||||||
private final SkillRuntimeService skillRuntimeService;
|
private final SkillRuntimeService skillRuntimeService;
|
||||||
private final SkillWorkspaceManager workspaceManager;
|
private final SkillWorkspaceManager workspaceManager;
|
||||||
private final SkillSynthesisService synthesisService;
|
private final SkillSynthesisService synthesisService;
|
||||||
|
private final SkillDependencyChecker dependencyChecker;
|
||||||
|
|
||||||
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@ -150,6 +155,60 @@ public class SkillController {
|
|||||||
"resynced", resynced));
|
"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) ====================
|
// ==================== Synthesis API (RFC-023) ====================
|
||||||
|
|
||||||
@Operation(summary = "从对话历史合成 Skill(RFC-023)")
|
@Operation(summary = "从对话历史合成 Skill(RFC-023)")
|
||||||
|
|||||||
@ -196,7 +196,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ t('skills.actions.configure') }}
|
{{ t('skills.actions.configure') }}
|
||||||
</button>
|
</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">
|
<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"/>
|
<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"/>
|
<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 { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 type { Skill, SkillRuntimeStatus, SkillSecurityFinding } from '@/types/index'
|
||||||
import ImportHubDialog from '@/components/skill/ImportHubDialog.vue'
|
import ImportHubDialog from '@/components/skill/ImportHubDialog.vue'
|
||||||
import { useSkillName } from '@/composables/useSkillName'
|
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 {
|
try {
|
||||||
await ElMessageBox.confirm(t('skills.messages.deleteConfirm'), t('skills.messages.deleteTitle'), { type: 'warning' })
|
await ElMessageBox.confirm(t('skills.messages.deleteConfirm'), t('skills.messages.deleteTitle'), { type: 'warning' })
|
||||||
} catch { return }
|
} catch { return }
|
||||||
try {
|
try {
|
||||||
await skillApi.delete(id)
|
await skillInstallApi.uninstall(skill.name)
|
||||||
await loadAll()
|
await loadAll()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.messages.deleteFailed'))
|
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.messages.deleteFailed'))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user