Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- ISS-002: SkillInfoResponse 增加 source/path 字段,修复技能系统显示 0 个 - ISS-003: Sidebar 添加自动化/技能市场导航入口 + App 返回按钮 - ISS-004: SaaS fetchAvailableModels 添加 .catch() 防限流崩溃 - ISS-006: SaaSSettings/PricingPage 包裹 ErrorBoundary 防白屏 - ISS-008: listModels 加载 localStorage 自定义模型,修复仅显示 1 个模型 - configStore listSkills 映射添加 source/path 转发
367 lines
12 KiB
Rust
367 lines
12 KiB
Rust
//! Skill CRUD + execute commands
|
|
//!
|
|
//! Skills are loaded from the Kernel's SkillRegistry.
|
|
//! Skills are registered during kernel initialization.
|
|
|
|
use std::path::PathBuf;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json;
|
|
use tauri::State;
|
|
use zclaw_types::SkillId;
|
|
|
|
use super::{validate_id, KernelState};
|
|
use crate::intelligence::validation::validate_identifier;
|
|
|
|
// ============================================================================
|
|
// Skills Commands - Dynamic Discovery
|
|
// ============================================================================
|
|
|
|
/// Skill information response for frontend
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillInfoResponse {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub version: String,
|
|
pub capabilities: Vec<String>,
|
|
pub tags: Vec<String>,
|
|
pub mode: String,
|
|
pub enabled: bool,
|
|
pub triggers: Vec<String>,
|
|
pub category: Option<String>,
|
|
#[serde(default = "default_source")]
|
|
pub source: String,
|
|
#[serde(default)]
|
|
pub path: Option<String>,
|
|
}
|
|
|
|
fn default_source() -> String {
|
|
"builtin".to_string()
|
|
}
|
|
|
|
impl From<zclaw_skills::SkillManifest> for SkillInfoResponse {
|
|
fn from(manifest: zclaw_skills::SkillManifest) -> Self {
|
|
Self {
|
|
id: manifest.id.to_string(),
|
|
name: manifest.name,
|
|
description: manifest.description,
|
|
version: manifest.version,
|
|
capabilities: manifest.capabilities,
|
|
tags: manifest.tags,
|
|
mode: format!("{:?}", manifest.mode),
|
|
enabled: manifest.enabled,
|
|
triggers: manifest.triggers,
|
|
category: manifest.category,
|
|
source: "builtin".to_string(),
|
|
path: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// List all discovered skills
|
|
///
|
|
/// Returns skills from the Kernel's SkillRegistry.
|
|
/// Skills are loaded from the skills/ directory during kernel initialization.
|
|
// @connected
|
|
#[tauri::command]
|
|
pub async fn skill_list(
|
|
state: State<'_, KernelState>,
|
|
) -> Result<Vec<SkillInfoResponse>, String> {
|
|
let kernel_lock = state.lock().await;
|
|
|
|
let kernel = kernel_lock.as_ref()
|
|
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
|
|
|
let skills = kernel.list_skills().await;
|
|
println!("[skill_list] Found {} skills", skills.len());
|
|
for skill in &skills {
|
|
println!("[skill_list] - {} ({})", skill.name, skill.id);
|
|
}
|
|
Ok(skills.into_iter().map(SkillInfoResponse::from).collect())
|
|
}
|
|
|
|
/// Refresh skills from a directory
|
|
///
|
|
/// Re-scans the skills directory for new or updated skills.
|
|
/// Optionally accepts a custom directory path to scan.
|
|
// @connected
|
|
#[tauri::command]
|
|
pub async fn skill_refresh(
|
|
state: State<'_, KernelState>,
|
|
skill_dir: Option<String>,
|
|
) -> Result<Vec<SkillInfoResponse>, String> {
|
|
let kernel_lock = state.lock().await;
|
|
|
|
let kernel = kernel_lock.as_ref()
|
|
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
|
|
|
// Convert optional string to PathBuf
|
|
let dir_path = skill_dir.map(PathBuf::from);
|
|
|
|
// Refresh skills
|
|
kernel.refresh_skills(dir_path)
|
|
.await
|
|
.map_err(|e| format!("Failed to refresh skills: {}", e))?;
|
|
|
|
// Return updated list
|
|
let skills = kernel.list_skills().await;
|
|
Ok(skills.into_iter().map(SkillInfoResponse::from).collect())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Skill CRUD Commands
|
|
// ============================================================================
|
|
|
|
/// Request body for creating a new skill
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CreateSkillRequest {
|
|
pub name: String,
|
|
pub description: Option<String>,
|
|
pub triggers: Vec<String>,
|
|
pub actions: Vec<String>,
|
|
pub enabled: Option<bool>,
|
|
}
|
|
|
|
/// Request body for updating a skill
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UpdateSkillRequest {
|
|
pub name: Option<String>,
|
|
pub description: Option<String>,
|
|
pub triggers: Option<Vec<String>>,
|
|
pub actions: Option<Vec<String>>,
|
|
pub enabled: Option<bool>,
|
|
}
|
|
|
|
/// Create a new skill in the skills directory
|
|
// @connected
|
|
#[tauri::command]
|
|
pub async fn skill_create(
|
|
state: State<'_, KernelState>,
|
|
request: CreateSkillRequest,
|
|
) -> Result<SkillInfoResponse, String> {
|
|
let name = request.name.trim().to_string();
|
|
if name.is_empty() {
|
|
return Err("Skill name cannot be empty".to_string());
|
|
}
|
|
|
|
// Generate skill ID from name
|
|
let id = name.to_lowercase()
|
|
.replace(' ', "-")
|
|
.replace(|c: char| !c.is_alphanumeric() && c != '-', "");
|
|
|
|
validate_identifier(&id, "skill_id")
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let kernel_lock = state.lock().await;
|
|
let kernel = kernel_lock.as_ref()
|
|
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
|
|
|
let manifest = zclaw_skills::SkillManifest {
|
|
id: SkillId::new(&id),
|
|
name: name.clone(),
|
|
description: request.description.unwrap_or_default(),
|
|
version: "1.0.0".to_string(),
|
|
author: None,
|
|
mode: zclaw_skills::SkillMode::PromptOnly,
|
|
capabilities: request.actions,
|
|
input_schema: None,
|
|
output_schema: None,
|
|
tags: vec![],
|
|
category: None,
|
|
triggers: request.triggers,
|
|
enabled: request.enabled.unwrap_or(true),
|
|
};
|
|
|
|
kernel.create_skill(manifest.clone())
|
|
.await
|
|
.map_err(|e| format!("Failed to create skill: {}", e))?;
|
|
|
|
Ok(SkillInfoResponse::from(manifest))
|
|
}
|
|
|
|
/// Update an existing skill
|
|
// @connected
|
|
#[tauri::command]
|
|
pub async fn skill_update(
|
|
state: State<'_, KernelState>,
|
|
id: String,
|
|
request: UpdateSkillRequest,
|
|
) -> Result<SkillInfoResponse, String> {
|
|
validate_identifier(&id, "skill_id")
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let kernel_lock = state.lock().await;
|
|
let kernel = kernel_lock.as_ref()
|
|
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
|
|
|
// Get existing manifest
|
|
let existing = kernel.skills()
|
|
.get_manifest(&SkillId::new(&id))
|
|
.await
|
|
.ok_or_else(|| format!("Skill not found: {}", id))?;
|
|
|
|
// Build updated manifest from existing + request fields
|
|
let updated = zclaw_skills::SkillManifest {
|
|
id: existing.id.clone(),
|
|
name: request.name.unwrap_or(existing.name),
|
|
description: request.description.unwrap_or(existing.description),
|
|
version: existing.version.clone(),
|
|
author: existing.author.clone(),
|
|
mode: existing.mode.clone(),
|
|
capabilities: request.actions.unwrap_or(existing.capabilities),
|
|
input_schema: existing.input_schema.clone(),
|
|
output_schema: existing.output_schema.clone(),
|
|
tags: existing.tags.clone(),
|
|
category: existing.category.clone(),
|
|
triggers: request.triggers.unwrap_or(existing.triggers),
|
|
enabled: request.enabled.unwrap_or(existing.enabled),
|
|
};
|
|
|
|
let result = kernel.update_skill(&SkillId::new(&id), updated)
|
|
.await
|
|
.map_err(|e| format!("Failed to update skill: {}", e))?;
|
|
|
|
Ok(SkillInfoResponse::from(result))
|
|
}
|
|
|
|
/// Delete a skill
|
|
// @connected
|
|
#[tauri::command]
|
|
pub async fn skill_delete(
|
|
state: State<'_, KernelState>,
|
|
id: String,
|
|
) -> Result<(), String> {
|
|
validate_identifier(&id, "skill_id")
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let kernel_lock = state.lock().await;
|
|
let kernel = kernel_lock.as_ref()
|
|
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
|
|
|
kernel.delete_skill(&SkillId::new(&id))
|
|
.await
|
|
.map_err(|e| format!("Failed to delete skill: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Skill Execution Command
|
|
// ============================================================================
|
|
|
|
/// Skill execution context
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillContext {
|
|
pub agent_id: String,
|
|
pub session_id: String,
|
|
pub working_dir: Option<String>,
|
|
}
|
|
|
|
impl From<SkillContext> for zclaw_skills::SkillContext {
|
|
fn from(ctx: SkillContext) -> Self {
|
|
Self {
|
|
agent_id: ctx.agent_id,
|
|
session_id: ctx.session_id,
|
|
working_dir: ctx.working_dir.map(std::path::PathBuf::from),
|
|
env: std::collections::HashMap::new(),
|
|
timeout_secs: 300,
|
|
network_allowed: true,
|
|
file_access_allowed: true,
|
|
llm: None, // Injected by Kernel.execute_skill()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Skill execution result
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillResult {
|
|
pub success: bool,
|
|
pub output: serde_json::Value,
|
|
pub error: Option<String>,
|
|
pub duration_ms: Option<u64>,
|
|
}
|
|
|
|
impl From<zclaw_skills::SkillResult> for SkillResult {
|
|
fn from(result: zclaw_skills::SkillResult) -> Self {
|
|
Self {
|
|
success: result.success,
|
|
output: result.output,
|
|
error: result.error,
|
|
duration_ms: result.duration_ms,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Execute a skill
|
|
///
|
|
/// Executes a skill with the given ID and input.
|
|
/// Returns the skill result as JSON.
|
|
// @connected
|
|
#[tauri::command]
|
|
pub async fn skill_execute(
|
|
state: State<'_, KernelState>,
|
|
id: String,
|
|
context: SkillContext,
|
|
input: serde_json::Value,
|
|
autonomy_level: Option<String>,
|
|
) -> Result<SkillResult, String> {
|
|
// Validate skill ID
|
|
let id = validate_id(&id, "skill_id")?;
|
|
|
|
let kernel_lock = state.lock().await;
|
|
|
|
let kernel = kernel_lock.as_ref()
|
|
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
|
|
|
// Autonomy guard: supervised mode creates an approval request for ALL skills
|
|
if autonomy_level.as_deref() == Some("supervised") {
|
|
let approval = kernel.create_approval(id.clone(), input).await;
|
|
return Ok(SkillResult {
|
|
success: false,
|
|
output: serde_json::json!({
|
|
"status": "pending_approval",
|
|
"approval_id": approval.id,
|
|
"skill_id": approval.hand_id,
|
|
"message": "监督模式下所有技能执行需要用户审批"
|
|
}),
|
|
error: None,
|
|
duration_ms: None,
|
|
});
|
|
}
|
|
|
|
// Assisted mode: require approval for non-prompt skills (shell/python) that have side effects
|
|
if autonomy_level.as_deref() != Some("autonomous") {
|
|
let skill_id = SkillId::new(&id);
|
|
if let Some(manifest) = kernel.skills().get_manifest(&skill_id).await {
|
|
match manifest.mode {
|
|
zclaw_skills::SkillMode::Shell | zclaw_skills::SkillMode::Python => {
|
|
let approval = kernel.create_approval(id.clone(), input).await;
|
|
return Ok(SkillResult {
|
|
success: false,
|
|
output: serde_json::json!({
|
|
"status": "pending_approval",
|
|
"approval_id": approval.id,
|
|
"skill_id": approval.hand_id,
|
|
"message": format!("技能 '{}' 使用 {:?} 模式,需要用户审批后执行", manifest.name, manifest.mode)
|
|
}),
|
|
error: None,
|
|
duration_ms: None,
|
|
});
|
|
}
|
|
_ => {} // PromptOnly and other modes are safe to execute directly
|
|
}
|
|
}
|
|
}
|
|
|
|
// Execute skill directly
|
|
let result = kernel.execute_skill(&id, context.into(), input).await
|
|
.map_err(|e| format!("Failed to execute skill: {}", e))?;
|
|
|
|
Ok(SkillResult::from(result))
|
|
}
|