forked from boos/BoosAPI
feat: bundle desktop skills
This commit is contained in:
@@ -4,6 +4,73 @@ use boosapi_core::types::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::State;
|
||||
|
||||
struct BuiltinMarketplaceSkill {
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
repo: &'static str,
|
||||
stars: i64,
|
||||
installs: i64,
|
||||
keywords: &'static [&'static str],
|
||||
fallback_skill_name: Option<&'static str>,
|
||||
}
|
||||
|
||||
const BUILTIN_MARKETPLACE_SKILLS: &[BuiltinMarketplaceSkill] = &[
|
||||
BuiltinMarketplaceSkill {
|
||||
name: "jianying-editor",
|
||||
description: "剪映 AI 自动化剪辑 Skill,支持草稿生成、素材导入、字幕、配音、特效和导出。",
|
||||
repo: "luoluoluo22/jianying-editor-skill",
|
||||
stars: 0,
|
||||
installs: 0,
|
||||
keywords: &["jianying", "剪映", "capcut", "video", "editing", "视频", "剪辑", "boosapi"],
|
||||
fallback_skill_name: None,
|
||||
},
|
||||
BuiltinMarketplaceSkill {
|
||||
name: "seedance",
|
||||
description: "中文 Seedance 2.0 / 即梦视频与图片提示词工程,支持 @素材引用、分镜、运镜和长视频流水线。",
|
||||
repo: "MapleShaw/seedance2.0-prompt-skill",
|
||||
stars: 0,
|
||||
installs: 0,
|
||||
keywords: &["seedance", "即梦", "jimeng", "video", "prompt", "视频", "提示词", "boosapi"],
|
||||
fallback_skill_name: None,
|
||||
},
|
||||
BuiltinMarketplaceSkill {
|
||||
name: "seedance-prompt-en",
|
||||
description: "English Seedance 2.0 multimodal AI video prompt writing guide with @Image/@Video/@Audio reference syntax.",
|
||||
repo: "dexhunter/seedance2-skill",
|
||||
stars: 0,
|
||||
installs: 0,
|
||||
keywords: &["seedance", "jimeng", "dreamina", "video", "prompt", "english", "boosapi"],
|
||||
fallback_skill_name: None,
|
||||
},
|
||||
BuiltinMarketplaceSkill {
|
||||
name: "dwg-mcp-server",
|
||||
description: "DWG MCP Server 包装技能:指导 Agent 安装并使用 dwg-mcp 检查 AutoCAD DWG 文件。",
|
||||
repo: "dimitrovakulenko/dwg-mcp-server",
|
||||
stars: 0,
|
||||
installs: 0,
|
||||
keywords: &["dwg", "cad", "autocad", "mcp", "drawing", "图纸", "建筑", "boosapi"],
|
||||
fallback_skill_name: Some("dwg-mcp-server"),
|
||||
},
|
||||
BuiltinMarketplaceSkill {
|
||||
name: "cad-ai-agent",
|
||||
description: "CAD/DXF Agent 包装技能:用于 DXF/DWG/PDF 图纸摘要、合规检查、工程量统计、RFI 和安全编辑流程。",
|
||||
repo: "jeremylongshore/cad-ai-agent",
|
||||
stars: 0,
|
||||
installs: 0,
|
||||
keywords: &["cad", "dxf", "dwg", "rfi", "takeoff", "compliance", "drawing", "图纸", "boosapi"],
|
||||
fallback_skill_name: Some("cad-ai-agent"),
|
||||
},
|
||||
BuiltinMarketplaceSkill {
|
||||
name: "gorden-ppt-skill",
|
||||
description: "中文 PPT 生成与编辑 Skill,内置模板、版式保持、出框检测和 PowerPoint 成品导出流程。",
|
||||
repo: "GordenSun/GordenPPTSkill",
|
||||
stars: 0,
|
||||
installs: 0,
|
||||
keywords: &["ppt", "powerpoint", "slides", "演示", "幻灯片", "汇报", "boosapi"],
|
||||
fallback_skill_name: None,
|
||||
},
|
||||
];
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("Could not determine home directory")
|
||||
}
|
||||
@@ -12,8 +79,79 @@ fn skills_dir() -> PathBuf {
|
||||
boosapi_home().join("skills")
|
||||
}
|
||||
|
||||
struct BundledSkill {
|
||||
name: &'static str,
|
||||
source_ref: &'static str,
|
||||
body: &'static str,
|
||||
}
|
||||
|
||||
const BUNDLED_SKILLS: &[BundledSkill] = &[
|
||||
BundledSkill {
|
||||
name: "jianying-editor",
|
||||
source_ref: "luoluoluo22/jianying-editor-skill",
|
||||
body: include_str!("../../builtin-skills/jianying-editor/SKILL.md"),
|
||||
},
|
||||
BundledSkill {
|
||||
name: "seedance",
|
||||
source_ref: "MapleShaw/seedance2.0-prompt-skill",
|
||||
body: include_str!("../../builtin-skills/seedance/SKILL.md"),
|
||||
},
|
||||
BundledSkill {
|
||||
name: "seedance-prompt-en",
|
||||
source_ref: "dexhunter/seedance2-skill",
|
||||
body: include_str!("../../builtin-skills/seedance-prompt-en/SKILL.md"),
|
||||
},
|
||||
BundledSkill {
|
||||
name: "dwg-mcp-server",
|
||||
source_ref: "dimitrovakulenko/dwg-mcp-server",
|
||||
body: include_str!("../../builtin-skills/dwg-mcp-server/SKILL.md"),
|
||||
},
|
||||
BundledSkill {
|
||||
name: "cad-ai-agent",
|
||||
source_ref: "jeremylongshore/cad-ai-agent",
|
||||
body: include_str!("../../builtin-skills/cad-ai-agent/SKILL.md"),
|
||||
},
|
||||
BundledSkill {
|
||||
name: "gorden-ppt-skill",
|
||||
source_ref: "GordenSun/GordenPPTSkill",
|
||||
body: include_str!("../../builtin-skills/gorden-ppt-skill/SKILL.md"),
|
||||
},
|
||||
];
|
||||
|
||||
pub fn seed_builtin_skills() -> Result<(), String> {
|
||||
let target_root = skills_dir();
|
||||
std::fs::create_dir_all(&target_root).map_err(|e| e.to_string())?;
|
||||
|
||||
for skill in BUNDLED_SKILLS {
|
||||
let skill_dir = target_root.join(skill.name);
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
if skill_file.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&skill_dir).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&skill_file, skill.body).map_err(|e| e.to_string())?;
|
||||
|
||||
let manifest = serde_json::json!({
|
||||
"source_kind": "builtin",
|
||||
"source_ref": skill.source_ref,
|
||||
"installed_at": chrono::Utc::now().to_rfc3339(),
|
||||
"installed_via": "boosapi-desktop"
|
||||
});
|
||||
std::fs::write(
|
||||
skill_dir.join("skill-manifest.json"),
|
||||
serde_json::to_string_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_skills(state: State<'_, AppState>) -> Result<Vec<SkillInfo>, String> {
|
||||
seed_builtin_skills()?;
|
||||
|
||||
let home = home_dir();
|
||||
let skills = open_agent_sdk::skills::load_all_global(&home);
|
||||
let disabled = boosapi_core::repo::skill::get_disabled_skills(&state.sea_db)
|
||||
@@ -220,6 +358,8 @@ async fn install_from_github(owner: &str, repo: &str, target_dir: &Path) -> Resu
|
||||
}
|
||||
|
||||
copy_dir_recursive(&extracted, &skill_target)?;
|
||||
let installed_name =
|
||||
ensure_skill_entrypoint(&skill_target, owner, repo).unwrap_or_else(|| repo.to_string());
|
||||
|
||||
let manifest = serde_json::json!({
|
||||
"source_kind": "github",
|
||||
@@ -236,7 +376,45 @@ async fn install_from_github(owner: &str, repo: &str, target_dir: &Path) -> Resu
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(repo.to_string())
|
||||
Ok(installed_name)
|
||||
}
|
||||
|
||||
fn ensure_skill_entrypoint(skill_target: &Path, owner: &str, repo: &str) -> Option<String> {
|
||||
let existing_skill = skill_target.join("SKILL.md");
|
||||
if existing_skill.exists() {
|
||||
return read_skill_name(&existing_skill).or_else(|| Some(repo.to_string()));
|
||||
}
|
||||
|
||||
let source_ref = format!("{}/{}", owner, repo);
|
||||
let builtin = BUILTIN_MARKETPLACE_SKILLS
|
||||
.iter()
|
||||
.find(|skill| skill.repo.eq_ignore_ascii_case(&source_ref))?;
|
||||
let name = builtin.fallback_skill_name?;
|
||||
let body = BUNDLED_SKILLS
|
||||
.iter()
|
||||
.find(|skill| skill.name == name)
|
||||
.map(|skill| skill.body)?;
|
||||
if std::fs::write(&existing_skill, body).is_ok() {
|
||||
Some(name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn read_skill_name(skill_file: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(skill_file).ok()?;
|
||||
let trimmed = text.trim_start();
|
||||
if !trimmed.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
let rest = trimmed[3..].trim_start_matches(['\r', '\n']);
|
||||
let end_idx = rest.find("\n---")?;
|
||||
let yaml = &rest[..end_idx];
|
||||
let value: serde_yaml::Value = serde_yaml::from_str(yaml).ok()?;
|
||||
value
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
async fn install_from_local(source: &str, target_dir: &Path) -> Result<String, String> {
|
||||
@@ -407,12 +585,14 @@ pub async fn search_marketplace(
|
||||
source: Option<String>,
|
||||
) -> Result<Vec<MarketplaceSkill>, String> {
|
||||
let installed_refs = installed_source_refs();
|
||||
let query = query.trim();
|
||||
let builtin_results = builtin_marketplace_results(query, &installed_refs);
|
||||
|
||||
match source.as_deref().unwrap_or("skills.sh") {
|
||||
"github" => {
|
||||
let url = format!(
|
||||
"https://api.github.com/search/repositories?q={}+topic:agent-skill&sort=stars&per_page=20",
|
||||
urlencoding::encode(&query)
|
||||
urlencoding::encode(query)
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
@@ -431,7 +611,7 @@ pub async fn search_marketplace(
|
||||
let body: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
let items = body["items"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
let results: Vec<MarketplaceSkill> = items
|
||||
let mut results: Vec<MarketplaceSkill> = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let skill_name = item["name"].as_str().unwrap_or("").to_string();
|
||||
@@ -449,12 +629,13 @@ pub async fn search_marketplace(
|
||||
})
|
||||
.collect();
|
||||
|
||||
merge_marketplace_results(&mut results, builtin_results);
|
||||
Ok(results)
|
||||
}
|
||||
_ => {
|
||||
let url = format!(
|
||||
"https://skills.sh/api/search?q={}",
|
||||
urlencoding::encode(&query)
|
||||
urlencoding::encode(query)
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
@@ -472,7 +653,7 @@ pub async fn search_marketplace(
|
||||
let body: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
|
||||
let items = body["skills"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
let results: Vec<MarketplaceSkill> = items
|
||||
let mut results: Vec<MarketplaceSkill> = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let skill_name = item["name"].as_str().unwrap_or("").to_string();
|
||||
@@ -490,11 +671,69 @@ pub async fn search_marketplace(
|
||||
})
|
||||
.collect();
|
||||
|
||||
merge_marketplace_results(&mut results, builtin_results);
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_marketplace_results(
|
||||
query: &str,
|
||||
installed_refs: &std::collections::HashSet<String>,
|
||||
) -> Vec<MarketplaceSkill> {
|
||||
let normalized_query = query.to_lowercase();
|
||||
let tokens: Vec<&str> = normalized_query
|
||||
.split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '-' || ch == '_' || ch == '.')
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect();
|
||||
|
||||
BUILTIN_MARKETPLACE_SKILLS
|
||||
.iter()
|
||||
.filter(|skill| {
|
||||
if tokens.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let haystack = format!(
|
||||
"{} {} {} {}",
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.repo,
|
||||
skill.keywords.join(" ")
|
||||
)
|
||||
.to_lowercase();
|
||||
tokens.iter().all(|token| haystack.contains(token))
|
||||
})
|
||||
.map(|skill| MarketplaceSkill {
|
||||
name: skill.name.to_string(),
|
||||
description: skill.description.to_string(),
|
||||
repo: skill.repo.to_string(),
|
||||
stars: skill.stars,
|
||||
installs: skill.installs,
|
||||
installed: installed_refs.contains(&skill.repo.to_lowercase()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn merge_marketplace_results(
|
||||
results: &mut Vec<MarketplaceSkill>,
|
||||
builtin_results: Vec<MarketplaceSkill>,
|
||||
) {
|
||||
for builtin in builtin_results.into_iter().rev() {
|
||||
if let Some(existing) = results
|
||||
.iter_mut()
|
||||
.find(|item| item.repo.eq_ignore_ascii_case(&builtin.repo))
|
||||
{
|
||||
if existing.description.is_empty() {
|
||||
existing.description = builtin.description;
|
||||
}
|
||||
existing.name = builtin.name;
|
||||
existing.installed = existing.installed || builtin.installed;
|
||||
} else {
|
||||
results.insert(0, builtin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_skill_updates() -> Result<Vec<SkillUpdateInfo>, String> {
|
||||
let skills_path = skills_dir();
|
||||
|
||||
@@ -520,6 +520,8 @@ pub fn run() {
|
||||
version = %app.package_info().version,
|
||||
"BoosApi setup started"
|
||||
);
|
||||
commands::skills::seed_builtin_skills().expect("failed to seed built-in skills");
|
||||
tracing::info!("BoosApi built-in skills seeded");
|
||||
|
||||
// Ensure ~/Documents/boosapi/{images,files,backups}/ exist
|
||||
boosapi_core::storage_paths::ensure_documents_dirs()
|
||||
|
||||
Reference in New Issue
Block a user