Add skill source viewer in dashboard

This commit is contained in:
xmanrui
2026-03-12 23:04:14 +08:00
parent 87456d09a2
commit 6e5e862dda
6 changed files with 322 additions and 161 deletions
+28
View File
@@ -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 });
}
}
+2 -156
View File
@@ -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<string, string> {
const result: Record<string, string> = {};
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<string, Set<string>> {
// Parse skillsSnapshot from session JSONL files
const agentsDir = path.join(OPENCLAW_HOME, "agents");
const result: Record<string, Set<string>> = {};
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<string>();
// 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<string, { name: string; emoji: string }> = {};
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 });
}
+117 -4
View File
@@ -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<Skill | null>(null);
const [skillContent, setSkillContent] = useState<Record<string, string>>({});
const [contentLoading, setContentLoading] = useState(false);
const [contentError, setContentError] = useState<string | null>(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 (
<div className="min-h-screen flex items-center justify-center">
@@ -217,9 +280,11 @@ export default function SkillsPage() {
</div>
) : (
filtered.map((skill) => (
<div
<button
key={`${skill.source}-${skill.id}`}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition"
type="button"
onClick={() => setSelectedSkill(skill)}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition text-left cursor-pointer"
>
<div className="flex items-start justify-between mb-2 gap-2">
<div className="flex items-center gap-2 min-w-0">
@@ -233,6 +298,9 @@ export default function SkillsPage() {
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
{skill.description || t("skills.noDesc")}
</p>
<div className="mb-3 text-[10px] text-[var(--accent)]">
{t("skills.viewSource")}
</div>
{skill.usedBy.length > 0 && (
<div className="flex flex-wrap gap-1">
{skill.usedBy.map((agentId) => {
@@ -248,10 +316,55 @@ export default function SkillsPage() {
})}
</div>
)}
</div>
</button>
))
)}
</div>
{selectedSkill && (
<div className="fixed inset-0 z-[70]">
<button
type="button"
className="absolute inset-0 bg-black/60"
aria-label={t("common.close")}
onClick={() => {
setSelectedSkill(null);
setContentError(null);
setContentLoading(false);
}}
/>
<div className="absolute inset-x-4 inset-y-6 md:inset-x-10 lg:inset-x-24 xl:inset-x-40 rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-2xl flex flex-col overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{selectedSkill.emoji} {selectedSkill.name}</div>
<div className="text-xs text-[var(--text-muted)]">{t("skills.contentTitle")}</div>
</div>
<button
type="button"
onClick={() => {
setSelectedSkill(null);
setContentError(null);
setContentLoading(false);
}}
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] text-sm hover:border-[var(--accent)] transition"
>
{t("common.close")}
</button>
</div>
<div className="flex-1 overflow-auto bg-[var(--bg)]/40">
{contentLoading && !selectedSkillContent ? (
<div className="p-4 text-sm text-[var(--text-muted)]">{t("skills.loadingContent")}</div>
) : contentError ? (
<div className="p-4 text-sm text-red-400">{t("skills.contentLoadFailed")}: {contentError}</div>
) : (
<pre className="p-4 text-xs md:text-sm leading-6 whitespace-pre-wrap break-words text-[var(--text)] font-mono">
{selectedSkillContent}
</pre>
)}
</div>
</div>
</div>
)}
</main>
);
}
}
+12
View File
@@ -238,6 +238,10 @@ const translations: Record<Locale, Record<string, string>> = {
"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<Locale, Record<string, string>> = {
"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<Locale, Record<string, string>> = {
"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",
+2 -1
View File
@@ -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",
]);
}
}
+161
View File
@@ -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<string, string> {
const result: Record<string, string> = {};
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<string, Set<string>> {
const agentsDir = path.join(OPENCLAW_HOME, "agents");
const result: Record<string, Set<string>> = {};
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<string>();
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<string, SkillAgentInfo>; 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<string, SkillAgentInfo> = {};
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"),
};
}