mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-08-14 00:47:49 +00:00
Merge pull request #50 from PinkR1ver/fix/workspace-skill-discovery
Fix workspace skill discovery in dashboard
This commit is contained in:
+146
-71
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
@@ -18,6 +18,8 @@ interface AgentInfo {
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
type SkillFilter = "all" | "builtin" | "extension" | "custom" | "workspace";
|
||||
|
||||
function normalizeSkill(raw: unknown): Skill | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
@@ -59,13 +61,19 @@ function normalizeAgents(raw: unknown): Record<string, AgentInfo> {
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function getWorkspaceIdFromSource(source: string): string | null {
|
||||
if (!source.startsWith("workspace:")) return null;
|
||||
return source.slice("workspace:".length) || null;
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useI18n();
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [agents, setAgents] = useState<Record<string, AgentInfo>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"all" | "builtin" | "extension" | "custom">("all");
|
||||
const [filter, setFilter] = useState<SkillFilter>("all");
|
||||
const [workspaceFilter, setWorkspaceFilter] = useState<string>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||
const [skillContent, setSkillContent] = useState<Record<string, string>>({});
|
||||
@@ -170,30 +178,63 @@ export default function SkillsPage() {
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [selectedSkill]);
|
||||
|
||||
const workspaceOptions = useMemo(() => {
|
||||
const ids = Array.from(
|
||||
new Set(
|
||||
skills
|
||||
.map((skill) => getWorkspaceIdFromSource(skill.source))
|
||||
.filter((workspaceId): workspaceId is string => Boolean(workspaceId))
|
||||
)
|
||||
).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return ids.map((id) => ({
|
||||
id,
|
||||
label: agents[id]?.name || (id === "main" ? t("skills.workspace.main") : id),
|
||||
emoji: agents[id]?.emoji || (id === "main" ? "🏠" : "📁"),
|
||||
}));
|
||||
}, [agents, skills, t]);
|
||||
|
||||
const filtered = skills.filter((skill) => {
|
||||
if (filter === "builtin" && skill.source !== "builtin") return false;
|
||||
if (filter === "extension" && !skill.source.startsWith("extension:")) return false;
|
||||
if (filter === "custom" && skill.source !== "custom") return false;
|
||||
if (filter === "workspace" && !skill.source.startsWith("workspace:")) return false;
|
||||
|
||||
if (workspaceFilter !== "all") {
|
||||
const workspaceId = getWorkspaceIdFromSource(skill.source);
|
||||
if (workspaceId !== workspaceFilter) return false;
|
||||
}
|
||||
|
||||
if (!search) return true;
|
||||
|
||||
const query = search.toLowerCase();
|
||||
const workspaceId = getWorkspaceIdFromSource(skill.source) || "";
|
||||
const workspaceName = workspaceId ? (agents[workspaceId]?.name || workspaceId).toLowerCase() : "";
|
||||
return (
|
||||
skill.name.toLowerCase().includes(query) ||
|
||||
skill.description.toLowerCase().includes(query) ||
|
||||
skill.id.toLowerCase().includes(query)
|
||||
skill.id.toLowerCase().includes(query) ||
|
||||
workspaceName.includes(query) ||
|
||||
workspaceId.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const sourceLabel = (source: string) => {
|
||||
if (source === "builtin") return t("skills.source.builtin");
|
||||
if (source.startsWith("extension:")) return source.replace("extension:", `${t("skills.extension")}:`);
|
||||
if (source.startsWith("workspace:")) {
|
||||
const workspaceId = getWorkspaceIdFromSource(source);
|
||||
if (!workspaceId) return t("skills.source.workspace");
|
||||
if (workspaceId === "main") return `${t("skills.source.workspace")}: ${t("skills.workspace.main")}`;
|
||||
return `${t("skills.source.workspace")}: ${agents[workspaceId]?.name || workspaceId}`;
|
||||
}
|
||||
return t("skills.source.custom");
|
||||
};
|
||||
|
||||
const sourceBadgeClass = (source: string) => {
|
||||
if (source === "builtin") return "bg-blue-500/20 text-blue-400";
|
||||
if (source.startsWith("extension:")) return "bg-purple-500/20 text-purple-400";
|
||||
if (source.startsWith("workspace:")) return "bg-amber-500/20 text-amber-300";
|
||||
return "bg-green-500/20 text-green-400";
|
||||
};
|
||||
|
||||
@@ -219,6 +260,7 @@ export default function SkillsPage() {
|
||||
const builtinCount = skills.filter((skill) => skill.source === "builtin").length;
|
||||
const extensionCount = skills.filter((skill) => skill.source.startsWith("extension:")).length;
|
||||
const customCount = skills.filter((skill) => skill.source === "custom").length;
|
||||
const workspaceCount = skills.filter((skill) => skill.source.startsWith("workspace:")).length;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
|
||||
@@ -226,7 +268,7 @@ export default function SkillsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("skills.title")}</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
共 {skills.length} {t("skills.count")}({t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extensionCount} / {t("skills.custom")} {customCount})
|
||||
共 {skills.length} {t("skills.count")}({t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extensionCount} / {t("skills.custom")} {customCount} / {t("skills.workspace")} {workspaceCount})
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -239,38 +281,58 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 mb-6 md:flex-row md:items-center">
|
||||
<div className="flex flex-wrap rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{(["all", "builtin", "extension", "custom"] as const).map((nextFilter) => (
|
||||
<button
|
||||
key={nextFilter}
|
||||
onClick={() => setFilter(nextFilter)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition cursor-pointer ${
|
||||
filter === nextFilter
|
||||
? "bg-[var(--accent)] text-[var(--bg)]"
|
||||
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
}`}
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
|
||||
<div className="flex flex-wrap rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{(["all", "builtin", "extension", "custom", "workspace"] as const).map((nextFilter) => (
|
||||
<button
|
||||
key={nextFilter}
|
||||
onClick={() => setFilter(nextFilter)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition cursor-pointer ${
|
||||
filter === nextFilter
|
||||
? "bg-[var(--accent)] text-[var(--bg)]"
|
||||
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
}`}
|
||||
>
|
||||
{nextFilter === "all"
|
||||
? t("skills.all")
|
||||
: nextFilter === "builtin"
|
||||
? t("skills.builtin")
|
||||
: nextFilter === "extension"
|
||||
? t("skills.extension")
|
||||
: nextFilter === "custom"
|
||||
? t("skills.custom")
|
||||
: t("skills.workspace")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{workspaceOptions.length > 0 && (
|
||||
<select
|
||||
value={workspaceFilter}
|
||||
onChange={(event) => setWorkspaceFilter(event.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-full md:w-auto"
|
||||
>
|
||||
{nextFilter === "all"
|
||||
? t("skills.all")
|
||||
: nextFilter === "builtin"
|
||||
? t("skills.builtin")
|
||||
: nextFilter === "extension"
|
||||
? t("skills.extension")
|
||||
: t("skills.custom")}
|
||||
</button>
|
||||
))}
|
||||
<option value="all">{t("skills.workspaceFilter.all")}</option>
|
||||
{workspaceOptions.map((workspace) => (
|
||||
<option key={workspace.id} value={workspace.id}>
|
||||
{workspace.emoji} {workspace.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("skills.search")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-full md:w-64"
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{t("skills.showing")} {filtered.length} {t("skills.unit")}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("skills.search")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-full md:w-64"
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{t("skills.showing")} {filtered.length} {t("skills.unit")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -279,45 +341,57 @@ export default function SkillsPage() {
|
||||
{t("common.noData")}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((skill) => (
|
||||
<button
|
||||
key={`${skill.source}-${skill.id}`}
|
||||
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">
|
||||
<span className="text-xl shrink-0">{skill.emoji}</span>
|
||||
<span className="font-semibold text-sm truncate">{skill.name}</span>
|
||||
filtered.map((skill) => {
|
||||
const workspaceId = getWorkspaceIdFromSource(skill.source);
|
||||
const workspaceAgent = workspaceId ? agents[workspaceId] : null;
|
||||
return (
|
||||
<button
|
||||
key={`${skill.source}-${skill.id}`}
|
||||
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">
|
||||
<span className="text-xl shrink-0">{skill.emoji}</span>
|
||||
<span className="font-semibold text-sm truncate">{skill.name}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0 ${sourceBadgeClass(skill.source)}`}>
|
||||
{sourceLabel(skill.source)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0 ${sourceBadgeClass(skill.source)}`}>
|
||||
{sourceLabel(skill.source)}
|
||||
</span>
|
||||
</div>
|
||||
<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) => {
|
||||
const agent = agents[agentId];
|
||||
return (
|
||||
<span
|
||||
key={agentId}
|
||||
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-[10px] font-medium"
|
||||
>
|
||||
{agent?.emoji || "🤖"} {agent?.name || agentId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
|
||||
{skill.description || t("skills.noDesc")}
|
||||
</p>
|
||||
{workspaceId && (
|
||||
<div className="mb-3 text-[11px] text-[var(--text-muted)] flex flex-wrap items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded bg-[var(--bg)] border border-[var(--border)]">
|
||||
{(workspaceAgent?.emoji || (workspaceId === "main" ? "🏠" : "📁"))} {workspaceAgent?.name || (workspaceId === "main" ? t("skills.workspace.main") : workspaceId)}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-[var(--text-muted)]/80">{skill.source}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 text-[10px] text-[var(--accent)]">
|
||||
{t("skills.viewSource")}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
{skill.usedBy.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.usedBy.map((agentId) => {
|
||||
const agent = agents[agentId];
|
||||
return (
|
||||
<span
|
||||
key={agentId}
|
||||
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-[10px] font-medium"
|
||||
>
|
||||
{agent?.emoji || "🤖"} {agent?.name || agentId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -338,6 +412,7 @@ export default function SkillsPage() {
|
||||
<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 className="text-[10px] text-[var(--text-muted)] mt-1">{sourceLabel(selectedSkill.source)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -233,6 +233,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.builtin": "內建",
|
||||
"skills.extension": "擴充",
|
||||
"skills.custom": "自訂",
|
||||
"skills.workspace": "工作區",
|
||||
"skills.all": "全部",
|
||||
"skills.search": "搜尋技能...",
|
||||
"skills.showing": "顯示",
|
||||
@@ -240,6 +241,9 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.noDesc": "無描述",
|
||||
"skills.source.builtin": "內建",
|
||||
"skills.source.custom": "自訂",
|
||||
"skills.source.workspace": "工作區",
|
||||
"skills.workspace.main": "主工作區",
|
||||
"skills.workspaceFilter.all": "所有工作區",
|
||||
"skills.viewSource": "查看 SKILL.md",
|
||||
"skills.contentTitle": "SKILL.md 內容",
|
||||
"skills.loadingContent": "正在載入技能內容...",
|
||||
@@ -521,6 +525,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.builtin": "内置",
|
||||
"skills.extension": "扩展",
|
||||
"skills.custom": "自定义",
|
||||
"skills.workspace": "工作区",
|
||||
"skills.all": "全部",
|
||||
"skills.search": "搜索技能...",
|
||||
"skills.showing": "显示",
|
||||
@@ -528,6 +533,9 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.noDesc": "无描述",
|
||||
"skills.source.builtin": "内置",
|
||||
"skills.source.custom": "自定义",
|
||||
"skills.source.workspace": "工作区",
|
||||
"skills.workspace.main": "主工作区",
|
||||
"skills.workspaceFilter.all": "所有工作区",
|
||||
"skills.viewSource": "查看 SKILL.md",
|
||||
"skills.contentTitle": "SKILL.md 内容",
|
||||
"skills.loadingContent": "正在加载技能内容...",
|
||||
@@ -809,6 +817,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.builtin": "Built-in",
|
||||
"skills.extension": "Extension",
|
||||
"skills.custom": "Custom",
|
||||
"skills.workspace": "Workspace",
|
||||
"skills.all": "All",
|
||||
"skills.search": "Search skills...",
|
||||
"skills.showing": "Showing",
|
||||
@@ -816,6 +825,9 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.noDesc": "No description",
|
||||
"skills.source.builtin": "Built-in",
|
||||
"skills.source.custom": "Custom",
|
||||
"skills.source.workspace": "Workspace",
|
||||
"skills.workspace.main": "Main workspace",
|
||||
"skills.workspaceFilter.all": "All workspaces",
|
||||
"skills.viewSource": "View SKILL.md",
|
||||
"skills.contentTitle": "SKILL.md",
|
||||
"skills.loadingContent": "Loading skill content...",
|
||||
|
||||
+59
-2
@@ -71,6 +71,61 @@ function scanSkillsDir(dir: string, source: string): SkillInfo[] {
|
||||
return skills;
|
||||
}
|
||||
|
||||
function getConfiguredAgentWorkspaces(): Array<{ id: string; workspace?: string }> {
|
||||
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) return [];
|
||||
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
|
||||
const agentList = Array.isArray(config.agents?.list) ? config.agents.list : [];
|
||||
return agentList
|
||||
.filter((agent: unknown): agent is { id: string; workspace?: string } => {
|
||||
return Boolean(agent && typeof agent === "object" && typeof (agent as { id?: string }).id === "string");
|
||||
})
|
||||
.map((agent: { id: string; workspace?: string }) => ({ id: agent.id, workspace: agent.workspace }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkspaceSkillSources(): Array<{ dir: string; source: string }> {
|
||||
const sources: Array<{ dir: string; source: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const addSource = (dir: string | undefined, source: string) => {
|
||||
if (!dir) return;
|
||||
const resolved = path.resolve(dir);
|
||||
if (seen.has(resolved)) return;
|
||||
seen.add(resolved);
|
||||
sources.push({ dir: resolved, source });
|
||||
};
|
||||
|
||||
addSource(path.join(OPENCLAW_HOME, "workspace", "skills"), "workspace:main");
|
||||
|
||||
for (const agent of getConfiguredAgentWorkspaces()) {
|
||||
if (!agent.workspace) continue;
|
||||
addSource(path.join(agent.workspace, "skills"), `workspace:${agent.id}`);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function mergeSkillsByLocation(skills: SkillInfo[]): SkillInfo[] {
|
||||
const merged = new Map<string, SkillInfo>();
|
||||
|
||||
for (const skill of skills) {
|
||||
const key = `${skill.location}::${skill.id}`;
|
||||
if (!merged.has(key)) {
|
||||
merged.set(key, skill);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = merged.get(key)!;
|
||||
existing.usedBy = Array.from(new Set([...existing.usedBy, ...skill.usedBy])).sort();
|
||||
}
|
||||
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function getAgentSkillsFromSessions(): Record<string, Set<string>> {
|
||||
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||
const result: Record<string, Set<string>> = {};
|
||||
@@ -125,8 +180,9 @@ export function listOpenclawSkills(): { skills: SkillInfo[]; agents: Record<stri
|
||||
}
|
||||
}
|
||||
|
||||
const customSkills = scanSkillsDir(path.join(OPENCLAW_HOME, "skills"), "custom");
|
||||
const allSkills = [...builtinSkills, ...extSkills, ...customSkills];
|
||||
const legacyCustomSkills = scanSkillsDir(path.join(OPENCLAW_HOME, "skills"), "custom");
|
||||
const workspaceSkills = getWorkspaceSkillSources().flatMap(({ dir, source }) => scanSkillsDir(dir, source));
|
||||
const allSkills = mergeSkillsByLocation([...builtinSkills, ...extSkills, ...legacyCustomSkills, ...workspaceSkills]);
|
||||
|
||||
const agentSkills = getAgentSkillsFromSessions();
|
||||
for (const skill of allSkills) {
|
||||
@@ -135,6 +191,7 @@ export function listOpenclawSkills(): { skills: SkillInfo[]; agents: Record<stri
|
||||
skill.usedBy.push(agentId);
|
||||
}
|
||||
}
|
||||
skill.usedBy = Array.from(new Set(skill.usedBy)).sort();
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
|
||||
|
||||
Reference in New Issue
Block a user