mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
Fix skills page empty state handling
This commit is contained in:
+160
-71
@@ -18,54 +18,133 @@ interface AgentInfo {
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
function normalizeSkill(raw: unknown): Skill | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const value = raw as Record<string, unknown>;
|
||||
const id = typeof value.id === "string" ? value.id : "";
|
||||
const name = typeof value.name === "string" && value.name.trim() ? value.name : id;
|
||||
const source = typeof value.source === "string" && value.source.trim() ? value.source : "custom";
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: typeof value.description === "string" ? value.description : "",
|
||||
emoji: typeof value.emoji === "string" && value.emoji.trim() ? value.emoji : "🧩",
|
||||
source,
|
||||
usedBy: Array.isArray(value.usedBy)
|
||||
? value.usedBy.filter((agentId): agentId is string => typeof agentId === "string" && agentId.trim().length > 0)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgents(raw: unknown): Record<string, AgentInfo> {
|
||||
if (!raw || typeof raw !== "object") return {};
|
||||
|
||||
const entries = Object.entries(raw as Record<string, unknown>)
|
||||
.map(([agentId, info]) => {
|
||||
if (!info || typeof info !== "object") return null;
|
||||
const value = info as Record<string, unknown>;
|
||||
return [
|
||||
agentId,
|
||||
{
|
||||
name: typeof value.name === "string" && value.name.trim() ? value.name : agentId,
|
||||
emoji: typeof value.emoji === "string" && value.emoji.trim() ? value.emoji : "🤖",
|
||||
},
|
||||
] as const;
|
||||
})
|
||||
.filter((entry): entry is readonly [string, AgentInfo] => Boolean(entry));
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
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 [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch("/api/skills")
|
||||
.then((r) => r.json())
|
||||
.then(async (response) => {
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.error) setError(data.error);
|
||||
else {
|
||||
setSkills(data.skills);
|
||||
setAgents(data.agents);
|
||||
if (cancelled) return;
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawSkills = Array.isArray(data?.skills) ? (data.skills as unknown[]) : [];
|
||||
const normalizedSkills = rawSkills
|
||||
.map(normalizeSkill)
|
||||
.filter((skill: Skill | null): skill is Skill => skill !== null);
|
||||
|
||||
setSkills(normalizedSkills);
|
||||
setAgents(normalizeAgents(data?.agents));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filtered = skills.filter((s) => {
|
||||
if (filter === "builtin" && s.source !== "builtin") return false;
|
||||
if (filter === "extension" && !s.source.startsWith("extension:")) return false;
|
||||
if (filter === "custom" && s.source !== "custom") return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.description.toLowerCase().includes(q) ||
|
||||
s.id.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
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 (!search) return true;
|
||||
|
||||
const query = search.toLowerCase();
|
||||
return (
|
||||
skill.name.toLowerCase().includes(query) ||
|
||||
skill.description.toLowerCase().includes(query) ||
|
||||
skill.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const sourceLabel = (s: string) => {
|
||||
if (s === "builtin") return t("skills.source.builtin");
|
||||
if (s.startsWith("extension:")) return s.replace("extension:", `${t("skills.extension")}:`);
|
||||
const sourceLabel = (source: string) => {
|
||||
if (source === "builtin") return t("skills.source.builtin");
|
||||
if (source.startsWith("extension:")) return source.replace("extension:", `${t("skills.extension")}:`);
|
||||
return t("skills.source.custom");
|
||||
};
|
||||
|
||||
const sourceBadgeClass = (s: string) => {
|
||||
if (s === "builtin") return "bg-blue-500/20 text-blue-400";
|
||||
if (s.startsWith("extension:")) return "bg-purple-500/20 text-purple-400";
|
||||
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";
|
||||
return "bg-green-500/20 text-green-400";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-[var(--text-muted)]">{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -74,9 +153,9 @@ export default function SkillsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const builtinCount = skills.filter((s) => s.source === "builtin").length;
|
||||
const extCount = skills.filter((s) => s.source.startsWith("extension:")).length;
|
||||
const customCount = skills.filter((s) => s.source === "custom").length;
|
||||
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;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
|
||||
@@ -84,7 +163,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")} {extCount} / {t("skills.custom")} {customCount})
|
||||
共 {skills.length} {t("skills.count")}({t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extensionCount} / {t("skills.custom")} {customCount})
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -97,20 +176,25 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<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((f) => (
|
||||
{(["all", "builtin", "extension", "custom"] as const).map((nextFilter) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
key={nextFilter}
|
||||
onClick={() => setFilter(nextFilter)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition cursor-pointer ${
|
||||
filter === f
|
||||
filter === nextFilter
|
||||
? "bg-[var(--accent)] text-[var(--bg)]"
|
||||
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? t("skills.all") : f === "builtin" ? t("skills.builtin") : f === "extension" ? t("skills.extension") : t("skills.custom")}
|
||||
{nextFilter === "all"
|
||||
? t("skills.all")
|
||||
: nextFilter === "builtin"
|
||||
? t("skills.builtin")
|
||||
: nextFilter === "extension"
|
||||
? t("skills.extension")
|
||||
: t("skills.custom")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -118,7 +202,7 @@ export default function SkillsPage() {
|
||||
type="text"
|
||||
placeholder={t("skills.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
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)]">
|
||||
@@ -126,43 +210,48 @@ export default function SkillsPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Skills Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((skill) => (
|
||||
<div
|
||||
key={`${skill.source}-${skill.id}`}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{skill.emoji}</span>
|
||||
<span className="font-semibold text-sm">{skill.name}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${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>
|
||||
{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>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="col-span-full rounded-xl border border-[var(--border)] bg-[var(--card)] p-8 text-center text-[var(--text-muted)] text-sm">
|
||||
{t("common.noData")}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
filtered.map((skill) => (
|
||||
<div
|
||||
key={`${skill.source}-${skill.id}`}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition"
|
||||
>
|
||||
<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>
|
||||
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
|
||||
{skill.description || t("skills.noDesc")}
|
||||
</p>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
-6
@@ -1,17 +1,34 @@
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
export const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(os.homedir(), ".openclaw");
|
||||
const home = os.homedir();
|
||||
|
||||
export const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(home, ".openclaw");
|
||||
export const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
|
||||
export const OPENCLAW_AGENTS_DIR = path.join(OPENCLAW_HOME, "agents");
|
||||
export const OPENCLAW_PIXEL_OFFICE_DIR = path.join(OPENCLAW_HOME, "pixel-office");
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined>): string[] {
|
||||
return Array.from(new Set(paths.filter((value): value is string => Boolean(value && value.trim()))));
|
||||
}
|
||||
|
||||
export function getOpenclawPackageCandidates(version = process.version): string[] {
|
||||
const home = os.homedir();
|
||||
return [
|
||||
const appData = process.env.APPDATA;
|
||||
const homebrewPrefix = process.env.HOMEBREW_PREFIX;
|
||||
const npmPrefix = process.env.npm_config_prefix || process.env.PREFIX;
|
||||
|
||||
return uniquePaths([
|
||||
process.env.OPENCLAW_PACKAGE_DIR,
|
||||
npmPrefix ? path.join(npmPrefix, "node_modules", "openclaw") : undefined,
|
||||
path.join(home, ".nvm", "versions", "node", version, "lib", "node_modules", "openclaw"),
|
||||
path.join(home, "AppData", "Roaming", "npm", "node_modules", "openclaw"),
|
||||
path.join(home, ".fnm", "node-versions", version, "installation", "lib", "node_modules", "openclaw"),
|
||||
path.join(home, ".npm-global", "lib", "node_modules", "openclaw"),
|
||||
path.join(home, ".local", "share", "pnpm", "global", "5", "node_modules", "openclaw"),
|
||||
path.join(home, "Library", "pnpm", "global", "5", "node_modules", "openclaw"),
|
||||
appData ? path.join(appData, "npm", "node_modules", "openclaw") : undefined,
|
||||
homebrewPrefix ? path.join(homebrewPrefix, "lib", "node_modules", "openclaw") : undefined,
|
||||
"/opt/homebrew/lib/node_modules/openclaw",
|
||||
"/usr/local/lib/node_modules/openclaw",
|
||||
"/usr/lib/node_modules/openclaw",
|
||||
];
|
||||
}
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user