feat: 新增技能管理API,支持获取技能列表和切换技能状态

This commit is contained in:
oudecheng 2026-07-06 11:59:11 +08:00
parent d7ff969560
commit 38b9f661ee
4 changed files with 325 additions and 2 deletions

View File

@ -5,6 +5,34 @@ use std::sync::Arc;
use super::GatewayState; use super::GatewayState;
use crate::config::{Config, get_default_config_path}; 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)] #[derive(Serialize)]
pub struct HealthResponse { pub struct HealthResponse {
@ -182,3 +210,87 @@ pub async fn mcp_status(
}; };
Json(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),
}),
)
}
}
}

View File

@ -63,6 +63,7 @@ pub struct GatewayState {
pub cancel_manager: CancelManager, pub cancel_manager: CancelManager,
pub restart_tx: watch::Sender<bool>, pub restart_tx: watch::Sender<bool>,
pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>, pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>,
pub skills: Arc<SkillRuntime>,
} }
impl GatewayState { impl GatewayState {
@ -93,7 +94,7 @@ impl GatewayState {
config.time.timezone.clone(), config.time.timezone.clone(),
provider_config, provider_config,
provider_configs, provider_configs,
skills, skills.clone(),
Arc::new(BusSessionMessageSender::new(bus.clone())), Arc::new(BusSessionMessageSender::new(bus.clone())),
std::collections::HashSet::new(), std::collections::HashSet::new(),
config.tools.task.clone(), config.tools.task.clone(),
@ -121,6 +122,7 @@ impl GatewayState {
cancel_manager, cancel_manager,
restart_tx, restart_tx,
mcp_manager, 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/config", routing::get(http::get_config).put(http::save_config))
.route("/api/restart", routing::post(http::restart)) .route("/api/restart", routing::post(http::restart))
.route("/api/mcp/status", routing::get(http::mcp_status)) .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)) .route("/ws", routing::get(ws::ws_handler))
.fallback(static_handler) .fallback(static_handler)
.with_state(state.clone()) .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/config", routing::get(http::get_config).put(http::save_config))
.route("/api/restart", routing::post(http::restart)) .route("/api/restart", routing::post(http::restart))
.route("/api/mcp/status", routing::get(http::mcp_status)) .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)) .route("/ws", routing::get(ws::ws_handler))
.fallback_service(ServeDir::new(&static_dir)) .fallback_service(ServeDir::new(&static_dir))
.with_state(state.clone()) .with_state(state.clone())

View File

@ -25,6 +25,17 @@ pub struct Skill {
pub path: PathBuf, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSource { pub enum SkillSource {
User, User,
@ -163,6 +174,24 @@ impl SkillRuntime {
.clone() .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> { pub fn get_skill(&self, name: &str) -> Option<Skill> {
self.catalog self.catalog
.read() .read()
@ -1435,4 +1464,65 @@ mod tests {
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap(); let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap();
assert_eq!(payload["source"], "user_openclaw"); 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());
}
} }

View File

@ -32,6 +32,20 @@ interface McpServerConfig {
description?: string 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 { interface McpServerStatus {
key: string key: string
name: string name: string
@ -306,6 +320,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
const [showRestartDialog, setShowRestartDialog] = useState(false) const [showRestartDialog, setShowRestartDialog] = useState(false)
const [restarting, setRestarting] = useState(false) const [restarting, setRestarting] = useState(false)
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null) const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
const [skillListLoading, setSkillListLoading] = useState(false)
const fetchMcpStatus = useCallback(async () => { const fetchMcpStatus = useCallback(async () => {
try { try {
@ -314,6 +330,24 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
} catch { /* ignore fetch errors */ } } 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(() => { const handleClose = useCallback(() => {
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return
onClose() onClose()
@ -332,6 +366,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
if (activeTab === 'mcp') fetchMcpStatus() if (activeTab === 'mcp') fetchMcpStatus()
}, [activeTab, fetchMcpStatus]) }, [activeTab, fetchMcpStatus])
// Fetch skill list when skills tab is selected
useEffect(() => {
if (activeTab === 'skills') fetchSkillList()
}, [activeTab, fetchSkillList])
// ESC to close // ESC to close
useEffect(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() } 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']} examplePaths={['D:\\my-skills', '/home/user/shared-skills']}
/> />
</SectionCard> </SectionCard>
{renderDiscoveredSkills()}
</div> </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[] = [ const TASK_KNOWN_TOOLS: KnownSource[] = [
{ key: 'read', label: 'Read', description: '读取文件' }, { key: 'read', label: 'Read', description: '读取文件' },
{ key: 'edit', label: 'Edit', description: '编辑文件' }, { key: 'edit', label: 'Edit', description: '编辑文件' },
@ -883,7 +998,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
} }
return ( 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 <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]" 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()} onClick={e => e.stopPropagation()}