diff --git a/src/gateway/http.rs b/src/gateway/http.rs index a3bf39a..7d11e22 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + available: Option, + #[serde(skip_serializing_if = "Option::is_none")] + disabled_in_scopes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +pub struct SkillListResponse { + skills_system_enabled: bool, + total: usize, + skills: Vec, +} #[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>, +) -> Json { + 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>, + Json(req): Json, +) -> (StatusCode, Json) { + 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), + }), + ) + } + } +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index f8ab7d5..622b1b9 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -63,6 +63,7 @@ pub struct GatewayState { pub cancel_manager: CancelManager, pub restart_tx: watch::Sender, pub mcp_manager: Option>, + pub skills: Arc, } 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()) diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 3d556dd..86a2937 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -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, +} + #[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 { + 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 { 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()); + } } diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index 8cb058a..1eedea8 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -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(null) + const [skillList, setSkillList] = useState(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']} /> + {renderDiscoveredSkills()} ) + 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 ( + + {skillListLoading && skills.length === 0 ? ( +
+ 加载中... +
+ ) : skills.length === 0 ? ( +

未发现任何技能,请检查来源目录配置

+ ) : ( +
+ {skills.map(skill => { + const isEnabled = skill.disabled_in_scopes.length === 0 + return ( +
+
+
+ {skill.name} + {skill.source} +
+

{skill.description}

+
+ handleToggle(skill.name, isEnabled)} /> +
+ ) + })} +
+ )} +
+ ) + } + 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 ( -
+
e.stopPropagation()}