feat: 新增技能管理API,支持获取技能列表和切换技能状态
This commit is contained in:
parent
d7ff969560
commit
38b9f661ee
@ -5,6 +5,34 @@ use std::sync::Arc;
|
||||
|
||||
use super::GatewayState;
|
||||
use crate::config::{Config, get_default_config_path};
|
||||
use crate::skills::SkillWithStatus;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SkillToggleRequest {
|
||||
pub name: String,
|
||||
pub scope: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SkillToggleResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
changed: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
available: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
disabled_in_scopes: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SkillListResponse {
|
||||
skills_system_enabled: bool,
|
||||
total: usize,
|
||||
skills: Vec<SkillWithStatus>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HealthResponse {
|
||||
@ -182,3 +210,87 @@ pub async fn mcp_status(
|
||||
};
|
||||
Json(status)
|
||||
}
|
||||
|
||||
/// GET /api/skills — Return all discovered skills with their disabled status
|
||||
pub async fn skills_list(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Json<SkillListResponse> {
|
||||
let skills_enabled = state.config.read().await.skills.enabled;
|
||||
|
||||
if !skills_enabled {
|
||||
return Json(SkillListResponse {
|
||||
skills_system_enabled: false,
|
||||
total: 0,
|
||||
skills: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let skills = state.skills.list_skills_with_status();
|
||||
let total = skills.len();
|
||||
|
||||
Json(SkillListResponse {
|
||||
skills_system_enabled: true,
|
||||
total,
|
||||
skills,
|
||||
})
|
||||
}
|
||||
|
||||
/// POST /api/skills/toggle — Enable or disable a specific skill
|
||||
pub async fn skills_toggle(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<SkillToggleRequest>,
|
||||
) -> (StatusCode, Json<SkillToggleResponse>) {
|
||||
let scope = match crate::skills::SkillScope::parse(&req.scope) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(SkillToggleResponse {
|
||||
success: false,
|
||||
changed: None,
|
||||
available: None,
|
||||
disabled_in_scopes: None,
|
||||
error: Some(format!("invalid scope: {}", req.scope)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let result = if req.enabled {
|
||||
state.skills.enable_skill(scope, &req.name, true)
|
||||
} else {
|
||||
state.skills.disable_skill(scope, &req.name, true)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(change) => (
|
||||
StatusCode::OK,
|
||||
Json(SkillToggleResponse {
|
||||
success: true,
|
||||
changed: Some(change.changed),
|
||||
available: Some(change.available),
|
||||
disabled_in_scopes: Some(
|
||||
change.disabled_in_scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
||||
),
|
||||
error: None,
|
||||
}),
|
||||
),
|
||||
Err(msg) => {
|
||||
let status = if msg.contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(SkillToggleResponse {
|
||||
success: false,
|
||||
changed: None,
|
||||
available: None,
|
||||
disabled_in_scopes: None,
|
||||
error: Some(msg),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,6 +63,7 @@ pub struct GatewayState {
|
||||
pub cancel_manager: CancelManager,
|
||||
pub restart_tx: watch::Sender<bool>,
|
||||
pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>,
|
||||
pub skills: Arc<SkillRuntime>,
|
||||
}
|
||||
|
||||
impl GatewayState {
|
||||
@ -93,7 +94,7 @@ impl GatewayState {
|
||||
config.time.timezone.clone(),
|
||||
provider_config,
|
||||
provider_configs,
|
||||
skills,
|
||||
skills.clone(),
|
||||
Arc::new(BusSessionMessageSender::new(bus.clone())),
|
||||
std::collections::HashSet::new(),
|
||||
config.tools.task.clone(),
|
||||
@ -121,6 +122,7 @@ impl GatewayState {
|
||||
cancel_manager,
|
||||
restart_tx,
|
||||
mcp_manager,
|
||||
skills,
|
||||
})
|
||||
}
|
||||
|
||||
@ -230,6 +232,8 @@ pub async fn run(
|
||||
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
||||
.route("/api/restart", routing::post(http::restart))
|
||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||
.route("/api/skills", routing::get(http::skills_list))
|
||||
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
||||
.route("/ws", routing::get(ws::ws_handler))
|
||||
.fallback(static_handler)
|
||||
.with_state(state.clone())
|
||||
@ -240,6 +244,8 @@ pub async fn run(
|
||||
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
||||
.route("/api/restart", routing::post(http::restart))
|
||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||
.route("/api/skills", routing::get(http::skills_list))
|
||||
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
||||
.route("/ws", routing::get(ws::ws_handler))
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
.with_state(state.clone())
|
||||
|
||||
@ -25,6 +25,17 @@ pub struct Skill {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// A skill entry with its disabled status across scopes.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SkillWithStatus {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub source: String,
|
||||
pub path: String,
|
||||
/// Which scopes have this skill disabled. Empty means enabled.
|
||||
pub disabled_in_scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SkillSource {
|
||||
User,
|
||||
@ -163,6 +174,24 @@ impl SkillRuntime {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// List all discovered skills including disabled ones, with their disabled scopes.
|
||||
pub fn list_skills_with_status(&self) -> Vec<SkillWithStatus> {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let catalog = SkillCatalog::discover_without_state(&self.config, &cwd);
|
||||
let disable_state = load_skill_disable_state(&cwd);
|
||||
|
||||
catalog.skills.iter().map(|skill| {
|
||||
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
|
||||
SkillWithStatus {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
source: skill.source.as_str().to_string(),
|
||||
path: skill.path.display().to_string(),
|
||||
disabled_in_scopes: disabled_scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
||||
self.catalog
|
||||
.read()
|
||||
@ -1435,4 +1464,65 @@ mod tests {
|
||||
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap();
|
||||
assert_eq!(payload["source"], "user_openclaw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_with_status_includes_disabled() {
|
||||
let _lock = acquire_test_lock();
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let home_dir = temp_dir.path().join("home");
|
||||
let project_dir = temp_dir.path().join("project");
|
||||
fs::create_dir_all(&home_dir).unwrap();
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let _home = HomeDirGuard::enter(&home_dir);
|
||||
let _guard = CurrentDirGuard::enter(&project_dir);
|
||||
|
||||
let skill_dir = project_dir.join(".picobot").join("skills").join("demo");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\ndescription: A demo skill\n---\nBody here",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
save_skill_state_file(
|
||||
&project_dir.join(".picobot").join("skill-state.json"),
|
||||
&SkillStateFile {
|
||||
disabled_skills: vec!["demo".to_string()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let runtime = SkillRuntime::from_config(SkillsConfig {
|
||||
enabled: true,
|
||||
sources: vec!["project".to_string()],
|
||||
max_index_chars: 4000,
|
||||
max_listed_skills: 32,
|
||||
});
|
||||
|
||||
// list_skills_with_status should include the disabled skill with its disabled scope
|
||||
let skills = runtime.list_skills_with_status();
|
||||
assert_eq!(
|
||||
skills.len(),
|
||||
1,
|
||||
"list_skills_with_status should include disabled skills"
|
||||
);
|
||||
assert_eq!(skills[0].name, "demo");
|
||||
assert_eq!(skills[0].description, "A demo skill");
|
||||
assert_eq!(skills[0].source, "project");
|
||||
assert_eq!(skills[0].disabled_in_scopes, vec!["project".to_string()]);
|
||||
|
||||
// list_skills (normal) should filter out disabled skills
|
||||
let active = runtime.list_skills();
|
||||
assert_eq!(
|
||||
active.len(),
|
||||
0,
|
||||
"list_skills should filter out disabled skills"
|
||||
);
|
||||
|
||||
// After enabling, list_skills_with_status should report no disabled scopes
|
||||
runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
|
||||
let skills_after = runtime.list_skills_with_status();
|
||||
assert_eq!(skills_after.len(), 1);
|
||||
assert!(skills_after[0].disabled_in_scopes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +32,20 @@ interface McpServerConfig {
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface SkillItem {
|
||||
name: string
|
||||
description: string
|
||||
source: string
|
||||
path: string
|
||||
disabled_in_scopes: string[]
|
||||
}
|
||||
|
||||
interface SkillListResponse {
|
||||
skills_system_enabled: boolean
|
||||
total: number
|
||||
skills: SkillItem[]
|
||||
}
|
||||
|
||||
interface McpServerStatus {
|
||||
key: string
|
||||
name: string
|
||||
@ -306,6 +320,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
const [showRestartDialog, setShowRestartDialog] = useState(false)
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
|
||||
const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
|
||||
const [skillListLoading, setSkillListLoading] = useState(false)
|
||||
|
||||
const fetchMcpStatus = useCallback(async () => {
|
||||
try {
|
||||
@ -314,6 +330,24 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
} catch { /* ignore fetch errors */ }
|
||||
}, [])
|
||||
|
||||
const fetchSkillList = useCallback(async () => {
|
||||
setSkillListLoading(true)
|
||||
try {
|
||||
const resp = await fetch('/api/skills')
|
||||
if (resp.ok) setSkillList(await resp.json())
|
||||
} catch { /* ignore fetch errors */ }
|
||||
finally { setSkillListLoading(false) }
|
||||
}, [])
|
||||
|
||||
const toggleSkill = useCallback(async (name: string, scope: string, enabled: boolean) => {
|
||||
const resp = await fetch('/api/skills/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, scope, enabled }),
|
||||
})
|
||||
return resp
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return
|
||||
onClose()
|
||||
@ -332,6 +366,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
if (activeTab === 'mcp') fetchMcpStatus()
|
||||
}, [activeTab, fetchMcpStatus])
|
||||
|
||||
// Fetch skill list when skills tab is selected
|
||||
useEffect(() => {
|
||||
if (activeTab === 'skills') fetchSkillList()
|
||||
}, [activeTab, fetchSkillList])
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
|
||||
@ -621,9 +660,85 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
examplePaths={['D:\\my-skills', '/home/user/shared-skills']}
|
||||
/>
|
||||
</SectionCard>
|
||||
{renderDiscoveredSkills()}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderDiscoveredSkills = () => {
|
||||
if (!skillList || !skillList.skills_system_enabled) return null
|
||||
|
||||
const skills = skillList.skills
|
||||
|
||||
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
|
||||
// Optimistic update
|
||||
const prevSkillList = skillList
|
||||
setSkillList({
|
||||
...skillList,
|
||||
skills: skills.map(s =>
|
||||
s.name === name
|
||||
? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] }
|
||||
: s
|
||||
),
|
||||
})
|
||||
|
||||
try {
|
||||
const resp = await toggleSkill(name, 'project', !currentlyEnabled)
|
||||
const data = await resp.json()
|
||||
if (!resp.ok || !data.success) {
|
||||
// Rollback
|
||||
setSkillList(prevSkillList)
|
||||
setToast(data.error || '切换技能状态失败')
|
||||
setTimeout(() => setToast(''), 3000)
|
||||
return
|
||||
}
|
||||
// Update with server response
|
||||
setSkillList({
|
||||
...prevSkillList,
|
||||
skills: prevSkillList.skills.map(s =>
|
||||
s.name === name
|
||||
? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] }
|
||||
: s
|
||||
),
|
||||
})
|
||||
} catch {
|
||||
// Rollback
|
||||
setSkillList(prevSkillList)
|
||||
setToast('网络错误,切换技能状态失败')
|
||||
setTimeout(() => setToast(''), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="已发现技能">
|
||||
{skillListLoading && skills.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-muted)]">未发现任何技能,请检查来源目录配置</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{skills.map(skill => {
|
||||
const isEnabled = skill.disabled_in_scopes.length === 0
|
||||
return (
|
||||
<div key={skill.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono text-[var(--text-primary)]">{skill.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{skill.source}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{skill.description}</p>
|
||||
</div>
|
||||
<Toggle checked={isEnabled} onChange={() => handleToggle(skill.name, isEnabled)} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
const TASK_KNOWN_TOOLS: KnownSource[] = [
|
||||
{ key: 'read', label: 'Read', description: '读取文件' },
|
||||
{ key: 'edit', label: 'Edit', description: '编辑文件' },
|
||||
@ -883,7 +998,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]" onClick={handleClose}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]">
|
||||
<div
|
||||
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-[scaleIn_0.2s_ease-out]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user