From 6e5e862dda54c2622d6c6a92c044a4124e93d12c Mon Sep 17 00:00:00 2001 From: xmanrui <841206367@qq.com> Date: Thu, 12 Mar 2026 23:04:14 +0800 Subject: [PATCH] Add skill source viewer in dashboard --- app/api/skills/content/route.ts | 28 ++++++ app/api/skills/route.ts | 158 +------------------------------ app/skills/page.tsx | 121 +++++++++++++++++++++++- lib/i18n.tsx | 12 +++ lib/openclaw-paths.ts | 3 +- lib/openclaw-skills.ts | 161 ++++++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 161 deletions(-) create mode 100644 app/api/skills/content/route.ts create mode 100644 lib/openclaw-skills.ts diff --git a/app/api/skills/content/route.ts b/app/api/skills/content/route.ts new file mode 100644 index 0000000..6a52aef --- /dev/null +++ b/app/api/skills/content/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { getOpenclawSkillContent } from "@/lib/openclaw-skills"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const source = (searchParams.get("source") || "").trim(); + const id = (searchParams.get("id") || "").trim(); + + if (!source || !id) { + return NextResponse.json({ error: "Missing source or id" }, { status: 400 }); + } + + const result = getOpenclawSkillContent(source, id); + if (!result) { + return NextResponse.json({ error: "Skill not found" }, { status: 404 }); + } + + return NextResponse.json({ + id: result.skill.id, + name: result.skill.name, + source: result.skill.source, + content: result.content, + }); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts index ece7c1f..21b5335 100644 --- a/app/api/skills/route.ts +++ b/app/api/skills/route.ts @@ -1,163 +1,9 @@ import { NextResponse } from "next/server"; -import fs from "fs"; -import path from "path"; -import { getOpenclawPackageCandidates, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; - -// Find OpenClaw package directory -function findOpenClawPkg(): string { - // Check common locations - const candidates = getOpenclawPackageCandidates(); - for (const c of candidates) { - if (fs.existsSync(path.join(c, "package.json"))) return c; - } - // Fallback: try to find via which - return candidates[0]; -} - -const OPENCLAW_PKG = findOpenClawPkg(); - -interface SkillInfo { - id: string; - name: string; - description: string; - emoji: string; - source: string; // "builtin" | "extension" | "custom" - location: string; - usedBy: string[]; // agent ids -} - -function parseFrontmatter(content: string): Record { - const result: Record = {}; - if (!content.startsWith("---")) return result; - const parts = content.split("---", 3); - if (parts.length < 3) return result; - const fm = parts[1]; - - const nameMatch = fm.match(/^name:\s*(.+)/m); - if (nameMatch) result.name = nameMatch[1].trim().replace(/^["']|["']$/g, ""); - - const descMatch = fm.match(/^description:\s*["']?(.+?)["']?\s*$/m); - if (descMatch) result.description = descMatch[1].trim().replace(/^["']|["']$/g, ""); - - const emojiMatch = fm.match(/"emoji":\s*"([^"]+)"/); - if (emojiMatch) result.emoji = emojiMatch[1]; - - return result; -} - -function scanSkillsDir(dir: string, source: string): SkillInfo[] { - const skills: SkillInfo[] = []; - if (!fs.existsSync(dir)) return skills; - for (const name of fs.readdirSync(dir).sort()) { - const skillMd = path.join(dir, name, "SKILL.md"); - if (!fs.existsSync(skillMd)) continue; - const content = fs.readFileSync(skillMd, "utf-8"); - const fm = parseFrontmatter(content); - skills.push({ - id: name, - name: fm.name || name, - description: fm.description || "", - emoji: fm.emoji || "🔧", - source, - location: skillMd, - usedBy: [], - }); - } - return skills; -} - -function getAgentSkillsFromSessions(): Record> { - // Parse skillsSnapshot from session JSONL files - const agentsDir = path.join(OPENCLAW_HOME, "agents"); - const result: Record> = {}; - if (!fs.existsSync(agentsDir)) return result; - - for (const agentId of fs.readdirSync(agentsDir)) { - const sessionsDir = path.join(agentsDir, agentId, "sessions"); - if (!fs.existsSync(sessionsDir)) continue; - - const jsonlFiles = fs.readdirSync(sessionsDir) - .filter(f => f.endsWith(".jsonl")) - .sort(); - const skillNames = new Set(); - - // Check the most recent session files for skillsSnapshot - for (const file of jsonlFiles.slice(-3)) { - const content = fs.readFileSync(path.join(sessionsDir, file), "utf-8"); - const idx = content.indexOf("skillsSnapshot"); - if (idx < 0) continue; - const chunk = content.slice(idx, idx + 5000); - // Match skill names in escaped JSON: \"name\":\"xxx\" or "name":"xxx" - const matches = chunk.matchAll(/\\?"name\\?":\s*\\?"([^"\\]+)\\?"/g); - for (const m of matches) { - const name = m[1]; - // Filter out tool names and other non-skill entries - if (!["exec","read","edit","write","process","message","web_search","web_fetch", - "browser","tts","gateway","memory_search","memory_get","cron","nodes", - "canvas","session_status","sessions_list","sessions_history","sessions_send", - "sessions_spawn","agents_list"].includes(name) && name.length > 1) { - skillNames.add(name); - } - } - } - if (skillNames.size > 0) { - result[agentId] = skillNames; - } - } - return result; -} +import { listOpenclawSkills } from "@/lib/openclaw-skills"; export async function GET() { try { - // 1. Scan builtin skills - const builtinDir = path.join(OPENCLAW_PKG, "skills"); - const builtinSkills = scanSkillsDir(builtinDir, "builtin"); - - // 2. Scan extension skills - const extDir = path.join(OPENCLAW_PKG, "extensions"); - const extSkills: SkillInfo[] = []; - if (fs.existsSync(extDir)) { - for (const ext of fs.readdirSync(extDir)) { - const skillsDir = path.join(extDir, ext, "skills"); - if (fs.existsSync(skillsDir)) { - const skills = scanSkillsDir(skillsDir, `extension:${ext}`); - extSkills.push(...skills); - } - } - } - - // 3. Scan custom skills (~/.openclaw/skills) - const customDir = path.join(OPENCLAW_HOME, "skills"); - const customSkills = scanSkillsDir(customDir, "custom"); - - const allSkills = [...builtinSkills, ...extSkills, ...customSkills]; - - // 4. Map agent usage from session data - const agentSkills = getAgentSkillsFromSessions(); - for (const skill of allSkills) { - for (const [agentId, skills] of Object.entries(agentSkills)) { - if (skills.has(skill.id) || skills.has(skill.name)) { - skill.usedBy.push(agentId); - } - } - } - - // 5. Get agent info for display - const configPath = OPENCLAW_CONFIG_PATH; - const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); - const agentList = config.agents?.list || []; - const agentMap: Record = {}; - for (const a of agentList) { - const name = a.identity?.name || a.name || a.id; - const emoji = a.identity?.emoji || "🤖"; - agentMap[a.id] = { name, emoji }; - } - - return NextResponse.json({ - skills: allSkills, - agents: agentMap, - total: allSkills.length, - }); + return NextResponse.json(listOpenclawSkills()); } catch (err: any) { return NextResponse.json({ error: err.message }, { status: 500 }); } diff --git a/app/skills/page.tsx b/app/skills/page.tsx index 78957d4..7371eb0 100644 --- a/app/skills/page.tsx +++ b/app/skills/page.tsx @@ -67,6 +67,10 @@ export default function SkillsPage() { const [loading, setLoading] = useState(true); const [filter, setFilter] = useState<"all" | "builtin" | "extension" | "custom">("all"); const [search, setSearch] = useState(""); + const [selectedSkill, setSelectedSkill] = useState(null); + const [skillContent, setSkillContent] = useState>({}); + const [contentLoading, setContentLoading] = useState(false); + const [contentError, setContentError] = useState(null); useEffect(() => { let cancelled = false; @@ -110,6 +114,62 @@ export default function SkillsPage() { }; }, []); + useEffect(() => { + if (!selectedSkill) return; + + const cacheKey = `${selectedSkill.source}:${selectedSkill.id}`; + if (skillContent[cacheKey]) { + setContentError(null); + setContentLoading(false); + return; + } + + const controller = new AbortController(); + setContentLoading(true); + setContentError(null); + + fetch(`/api/skills/content?source=${encodeURIComponent(selectedSkill.source)}&id=${encodeURIComponent(selectedSkill.id)}`, { + signal: controller.signal, + }) + .then(async (response) => { + const data = await response.json(); + if (!response.ok) { + throw new Error(data?.error || `HTTP ${response.status}`); + } + return data; + }) + .then((data) => { + const content = typeof data?.content === "string" ? data.content : ""; + setSkillContent((prev) => ({ ...prev, [cacheKey]: content })); + }) + .catch((err) => { + if (controller.signal.aborted) return; + setContentError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!controller.signal.aborted) { + setContentLoading(false); + } + }); + + return () => controller.abort(); + }, [selectedSkill, skillContent]); + + useEffect(() => { + if (!selectedSkill) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setSelectedSkill(null); + setContentError(null); + setContentLoading(false); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [selectedSkill]); + const filtered = skills.filter((skill) => { if (filter === "builtin" && skill.source !== "builtin") return false; if (filter === "extension" && !skill.source.startsWith("extension:")) return false; @@ -137,6 +197,9 @@ export default function SkillsPage() { return "bg-green-500/20 text-green-400"; }; + const selectedSkillCacheKey = selectedSkill ? `${selectedSkill.source}:${selectedSkill.id}` : ""; + const selectedSkillContent = selectedSkill ? skillContent[selectedSkillCacheKey] : ""; + if (loading) { return (
@@ -217,9 +280,11 @@ export default function SkillsPage() {
) : ( filtered.map((skill) => ( -
setSelectedSkill(skill)} + className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition text-left cursor-pointer" >
@@ -233,6 +298,9 @@ export default function SkillsPage() {

{skill.description || t("skills.noDesc")}

+
+ {t("skills.viewSource")} +
{skill.usedBy.length > 0 && (
{skill.usedBy.map((agentId) => { @@ -248,10 +316,55 @@ export default function SkillsPage() { })}
)} -
+ )) )}
+ + {selectedSkill && ( +
+ +
+
+ {contentLoading && !selectedSkillContent ? ( +
{t("skills.loadingContent")}
+ ) : contentError ? ( +
{t("skills.contentLoadFailed")}: {contentError}
+ ) : ( +
+                  {selectedSkillContent}
+                
+ )} +
+
+ + )} ); -} \ No newline at end of file +} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 9a6e4d4..8a73af4 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -238,6 +238,10 @@ const translations: Record> = { "skills.noDesc": "無描述", "skills.source.builtin": "內建", "skills.source.custom": "自訂", + "skills.viewSource": "查看 SKILL.md", + "skills.contentTitle": "SKILL.md 內容", + "skills.loadingContent": "正在載入技能內容...", + "skills.contentLoadFailed": "技能內容載入失敗", // gateway status "gateway.healthy": "Gateway 運作正常", @@ -520,6 +524,10 @@ const translations: Record> = { "skills.noDesc": "无描述", "skills.source.builtin": "内置", "skills.source.custom": "自定义", + "skills.viewSource": "查看 SKILL.md", + "skills.contentTitle": "SKILL.md 内容", + "skills.loadingContent": "正在加载技能内容...", + "skills.contentLoadFailed": "技能内容加载失败", // gateway status "gateway.healthy": "Gateway 运行正常", @@ -802,6 +810,10 @@ const translations: Record> = { "skills.noDesc": "No description", "skills.source.builtin": "Built-in", "skills.source.custom": "Custom", + "skills.viewSource": "View SKILL.md", + "skills.contentTitle": "SKILL.md", + "skills.loadingContent": "Loading skill content...", + "skills.contentLoadFailed": "Failed to load skill content", // gateway status "gateway.healthy": "Gateway is running", diff --git a/lib/openclaw-paths.ts b/lib/openclaw-paths.ts index 0d15613..44af55d 100644 --- a/lib/openclaw-paths.ts +++ b/lib/openclaw-paths.ts @@ -19,6 +19,7 @@ export function getOpenclawPackageCandidates(version = process.version): string[ return uniquePaths([ process.env.OPENCLAW_PACKAGE_DIR, + path.join(home, ".local", "lib", "node_modules", "openclaw"), npmPrefix ? path.join(npmPrefix, "node_modules", "openclaw") : undefined, path.join(home, ".nvm", "versions", "node", version, "lib", "node_modules", "openclaw"), path.join(home, ".fnm", "node-versions", version, "installation", "lib", "node_modules", "openclaw"), @@ -31,4 +32,4 @@ export function getOpenclawPackageCandidates(version = process.version): string[ "/usr/local/lib/node_modules/openclaw", "/usr/lib/node_modules/openclaw", ]); -} \ No newline at end of file +} diff --git a/lib/openclaw-skills.ts b/lib/openclaw-skills.ts new file mode 100644 index 0000000..842f315 --- /dev/null +++ b/lib/openclaw-skills.ts @@ -0,0 +1,161 @@ +import fs from "fs"; +import path from "path"; +import { getOpenclawPackageCandidates, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; + +export interface SkillInfo { + id: string; + name: string; + description: string; + emoji: string; + source: string; + location: string; + usedBy: string[]; +} + +export interface SkillAgentInfo { + name: string; + emoji: string; +} + +function findOpenClawPkg(): string { + const candidates = getOpenclawPackageCandidates(); + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "package.json"))) return candidate; + } + return candidates[0]; +} + +const OPENCLAW_PKG = findOpenClawPkg(); + +function parseFrontmatter(content: string): Record { + const result: Record = {}; + if (!content.startsWith("---")) return result; + const parts = content.split("---", 3); + if (parts.length < 3) return result; + const fm = parts[1]; + + const nameMatch = fm.match(/^name:\s*(.+)/m); + if (nameMatch) result.name = nameMatch[1].trim().replace(/^["']|["']$/g, ""); + + const descMatch = fm.match(/^description:\s*["']?(.+?)["']?\s*$/m); + if (descMatch) result.description = descMatch[1].trim().replace(/^["']|["']$/g, ""); + + const emojiMatch = fm.match(/"emoji":\s*"([^"]+)"/); + if (emojiMatch) result.emoji = emojiMatch[1]; + + return result; +} + +function readSkillFile(skillMd: string, source: string, id = path.basename(path.dirname(skillMd))): SkillInfo | null { + if (!fs.existsSync(skillMd)) return null; + const content = fs.readFileSync(skillMd, "utf-8"); + const fm = parseFrontmatter(content); + return { + id, + name: fm.name || id, + description: fm.description || "", + emoji: fm.emoji || "🔧", + source, + location: skillMd, + usedBy: [], + }; +} + +function scanSkillsDir(dir: string, source: string): SkillInfo[] { + const skills: SkillInfo[] = []; + if (!fs.existsSync(dir)) return skills; + for (const name of fs.readdirSync(dir).sort()) { + const skill = readSkillFile(path.join(dir, name, "SKILL.md"), source, name); + if (skill) skills.push(skill); + } + return skills; +} + +function getAgentSkillsFromSessions(): Record> { + const agentsDir = path.join(OPENCLAW_HOME, "agents"); + const result: Record> = {}; + if (!fs.existsSync(agentsDir)) return result; + + for (const agentId of fs.readdirSync(agentsDir)) { + const sessionsDir = path.join(agentsDir, agentId, "sessions"); + if (!fs.existsSync(sessionsDir)) continue; + + const jsonlFiles = fs.readdirSync(sessionsDir) + .filter((file) => file.endsWith(".jsonl")) + .sort(); + const skillNames = new Set(); + + for (const file of jsonlFiles.slice(-3)) { + const content = fs.readFileSync(path.join(sessionsDir, file), "utf-8"); + const idx = content.indexOf("skillsSnapshot"); + if (idx < 0) continue; + const chunk = content.slice(idx, idx + 5000); + const matches = chunk.matchAll(/\\?"name\\?":\s*\\?"([^"\\]+)\\?"/g); + for (const match of matches) { + const name = match[1]; + if (!["exec", "read", "edit", "write", "process", "message", "web_search", "web_fetch", + "browser", "tts", "gateway", "memory_search", "memory_get", "cron", "nodes", + "canvas", "session_status", "sessions_list", "sessions_history", "sessions_send", + "sessions_spawn", "agents_list"].includes(name) && name.length > 1) { + skillNames.add(name); + } + } + } + + if (skillNames.size > 0) result[agentId] = skillNames; + } + + return result; +} + +export function listOpenclawSkills(): { skills: SkillInfo[]; agents: Record; total: number } { + const builtinSkills = scanSkillsDir(path.join(OPENCLAW_PKG, "skills"), "builtin"); + + const extDir = path.join(OPENCLAW_PKG, "extensions"); + const extSkills: SkillInfo[] = []; + if (fs.existsSync(extDir)) { + for (const ext of fs.readdirSync(extDir)) { + const extSkill = readSkillFile(path.join(extDir, ext, "SKILL.md"), `extension:${ext}`, ext); + if (extSkill) extSkills.push(extSkill); + + const skillsDir = path.join(extDir, ext, "skills"); + if (fs.existsSync(skillsDir)) { + extSkills.push(...scanSkillsDir(skillsDir, `extension:${ext}`)); + } + } + } + + const customSkills = scanSkillsDir(path.join(OPENCLAW_HOME, "skills"), "custom"); + const allSkills = [...builtinSkills, ...extSkills, ...customSkills]; + + const agentSkills = getAgentSkillsFromSessions(); + for (const skill of allSkills) { + for (const [agentId, skills] of Object.entries(agentSkills)) { + if (skills.has(skill.id) || skills.has(skill.name)) { + skill.usedBy.push(agentId); + } + } + } + + const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); + const agentList = config.agents?.list || []; + const agents: Record = {}; + for (const agent of agentList) { + agents[agent.id] = { + name: agent.identity?.name || agent.name || agent.id, + emoji: agent.identity?.emoji || "🤖", + }; + } + + return { skills: allSkills, agents, total: allSkills.length }; +} + +export function getOpenclawSkillContent(source: string, id: string): { skill: SkillInfo; content: string } | null { + const { skills } = listOpenclawSkills(); + const skill = skills.find((entry) => entry.source === source && entry.id === id); + if (!skill) return null; + return { + skill, + content: fs.readFileSync(skill.location, "utf-8"), + }; +}