mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
1276 lines
41 KiB
TypeScript
1276 lines
41 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useRef } from "react";
|
||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||
import { useI18n } from "@/hooks/useI18n";
|
||
import type {
|
||
SkillInfo as Skill,
|
||
SkillInstallScope,
|
||
SkillSearchResult,
|
||
SkillsResponse,
|
||
SkillUpdateResult,
|
||
} from "@/lib/api-types";
|
||
|
||
function shortenPath(p: string): string {
|
||
// Match common home dir patterns: /Users/xxx, /home/xxx
|
||
return p.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||
}
|
||
|
||
function sourceLabel(skill: Skill): string {
|
||
const src = skill.sourceInfo?.source;
|
||
const scope = skill.sourceInfo?.scope;
|
||
if (scope === "user" || src === "user") return "app";
|
||
if (scope === "project" || src === "project") return "project";
|
||
return "path";
|
||
}
|
||
|
||
function updateKey(skill: Skill): string | null {
|
||
return skill.install
|
||
? `${skill.install.scope}\0${skill.install.package}`
|
||
: null;
|
||
}
|
||
|
||
function shortVersion(version?: string): string {
|
||
return version ? version.slice(0, 8) : "unknown";
|
||
}
|
||
|
||
function Toggle({
|
||
enabled,
|
||
loading,
|
||
onToggle,
|
||
}: {
|
||
enabled: boolean;
|
||
loading: boolean;
|
||
onToggle: () => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
return (
|
||
<button
|
||
onClick={onToggle}
|
||
disabled={loading}
|
||
title={
|
||
enabled
|
||
? t("i18n.visibleInPrompt")
|
||
: t("i18n.hiddenFromPrompt")
|
||
}
|
||
style={{
|
||
flexShrink: 0,
|
||
width: 40,
|
||
height: 22,
|
||
borderRadius: 11,
|
||
border: "none",
|
||
padding: 0,
|
||
cursor: loading ? "wait" : "pointer",
|
||
background: enabled ? "var(--accent)" : "var(--border)",
|
||
position: "relative",
|
||
transition: "background 0.18s",
|
||
outline: "none",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
position: "absolute",
|
||
top: 3,
|
||
left: enabled ? 21 : 3,
|
||
width: 16,
|
||
height: 16,
|
||
borderRadius: "50%",
|
||
background: "var(--bg)",
|
||
boxShadow: "0 1px 4px rgba(0,0,0,0.22)",
|
||
transition: "left 0.18s cubic-bezier(.4,0,.2,1)",
|
||
}}
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function SkillDetail({
|
||
skill,
|
||
cwd,
|
||
onToggle,
|
||
toggling,
|
||
saveError,
|
||
updateStatus,
|
||
checkingUpdate,
|
||
updating,
|
||
updateError,
|
||
onCheckUpdate,
|
||
onUpdate,
|
||
}: {
|
||
skill: Skill;
|
||
cwd: string;
|
||
onToggle: (skill: Skill) => void;
|
||
toggling: boolean;
|
||
saveError: string | null;
|
||
updateStatus?: SkillUpdateResult;
|
||
checkingUpdate: boolean;
|
||
updating: boolean;
|
||
updateError: string | null;
|
||
onCheckUpdate: () => void;
|
||
onUpdate: () => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const label = sourceLabel(skill);
|
||
const enabled = !skill.disableModelInvocation;
|
||
|
||
function displayPath(p: string): string {
|
||
if (label === "project" && p.startsWith(cwd)) {
|
||
const rel = p.slice(cwd.length).replace(/^[/\\]/, "");
|
||
return `./${rel}`;
|
||
}
|
||
return shortenPath(p);
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||
{/* Path + tag + toggle */}
|
||
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||
<span
|
||
style={{
|
||
fontSize: 10,
|
||
padding: "1px 5px",
|
||
borderRadius: 3,
|
||
flexShrink: 0,
|
||
background:
|
||
label === "project"
|
||
? "rgba(99,102,241,0.12)"
|
||
: "rgba(120,120,120,0.12)",
|
||
color:
|
||
label === "project" ? "rgba(99,102,241,0.8)" : "var(--text-dim)",
|
||
}}
|
||
>
|
||
{label}
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontFamily: "var(--font-mono)",
|
||
fontSize: 11,
|
||
color: "var(--text-dim)",
|
||
flex: 1,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{displayPath(skill.filePath)}
|
||
</span>
|
||
<Toggle
|
||
enabled={enabled}
|
||
loading={toggling}
|
||
onToggle={() => onToggle(skill)}
|
||
/>
|
||
{saveError && (
|
||
<span style={{ fontSize: 12, color: "#f87171", flexShrink: 0 }}>
|
||
{saveError}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{skill.install?.skillsShUrl && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||
<span
|
||
style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}
|
||
>
|
||
Source
|
||
</span>
|
||
<a
|
||
href={skill.install.skillsShUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title={skill.install.skillsShUrl}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
width: "fit-content",
|
||
maxWidth: "100%",
|
||
color: "var(--accent)",
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontFamily: "var(--font-mono)",
|
||
fontSize: 12,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{skill.install.skillsShUrl.replace(/^https?:\/\//, "")} ↗
|
||
</span>
|
||
</a>
|
||
</div>
|
||
)}
|
||
|
||
{skill.install && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
||
<span
|
||
style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}
|
||
>
|
||
Version
|
||
</span>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 10,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontFamily: "var(--font-mono)",
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
}}
|
||
>
|
||
{shortVersion(updateStatus?.currentVersion ?? skill.install.versionHash)}
|
||
</span>
|
||
{skill.install.canCheckForUpdates && (
|
||
<button
|
||
onClick={onCheckUpdate}
|
||
disabled={checkingUpdate || updating}
|
||
style={{
|
||
padding: "4px 9px",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 5,
|
||
background: "none",
|
||
color: "var(--text-muted)",
|
||
cursor: checkingUpdate || updating ? "not-allowed" : "pointer",
|
||
opacity: checkingUpdate || updating ? 0.5 : 1,
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
{t("i18n.check")}
|
||
</button>
|
||
)}
|
||
{updateStatus?.state === "update-available" && (
|
||
<span
|
||
style={{
|
||
fontFamily: "var(--font-mono)",
|
||
fontSize: 12,
|
||
color: "#d97706",
|
||
}}
|
||
>
|
||
{shortVersion(updateStatus.latestVersion)}
|
||
</span>
|
||
)}
|
||
{(checkingUpdate ||
|
||
(updateStatus && updateStatus.state !== "update-available")) && (
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: checkingUpdate
|
||
? "var(--accent)"
|
||
: updateStatus?.state === "up-to-date"
|
||
? "#16a34a"
|
||
: updateStatus?.state === "error"
|
||
? "#ef4444"
|
||
: "var(--text-dim)",
|
||
}}
|
||
>
|
||
{checkingUpdate
|
||
? t("i18n.checking")
|
||
: updateStatus?.state === "up-to-date"
|
||
? t("i18n.upToDate")
|
||
: updateStatus?.state === "unsupported"
|
||
? t("i18n.automaticChecksUnavailable")
|
||
: updateStatus?.message || t("i18n.checkFailed")}
|
||
</span>
|
||
)}
|
||
{updateStatus?.state === "update-available" && (
|
||
<button
|
||
onClick={onUpdate}
|
||
disabled={updating || checkingUpdate}
|
||
style={{
|
||
padding: "4px 10px",
|
||
border: "none",
|
||
borderRadius: 5,
|
||
background: "var(--accent)",
|
||
color: "#fff",
|
||
cursor: updating || checkingUpdate ? "not-allowed" : "pointer",
|
||
opacity: updating || checkingUpdate ? 0.5 : 1,
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{updating ? t("i18n.updating") : t("i18n.update")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{updateError && (
|
||
<span style={{ fontSize: 12, color: "#ef4444" }}>{updateError}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||
<span
|
||
style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}
|
||
>
|
||
Name
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontFamily: "var(--font-mono)",
|
||
fontSize: 14,
|
||
color: "var(--text)",
|
||
}}
|
||
>
|
||
{skill.name}
|
||
</span>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||
<span
|
||
style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}
|
||
>
|
||
Description
|
||
</span>
|
||
<span
|
||
style={{ fontSize: 14, color: "var(--text-muted)", lineHeight: 1.6 }}
|
||
>
|
||
{skill.description}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AddSkillPanel({
|
||
cwd,
|
||
installedPackages,
|
||
onInstalled,
|
||
}: {
|
||
cwd: string;
|
||
installedPackages: Record<SkillInstallScope, ReadonlySet<string>>;
|
||
onInstalled: () => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<SkillSearchResult[]>([]);
|
||
const [searching, setSearching] = useState(false);
|
||
const [searchError, setSearchError] = useState<string | null>(null);
|
||
const [installing, setInstalling] = useState<string | null>(null);
|
||
const [installError, setInstallError] = useState<string | null>(null);
|
||
const [newlyInstalledPkgs, setNewlyInstalledPkgs] = useState<Set<string>>(
|
||
new Set(),
|
||
);
|
||
const scope = "global" as const;
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
inputRef.current?.focus();
|
||
}, []);
|
||
|
||
const search = useCallback(async (q: string) => {
|
||
if (!q.trim()) return;
|
||
setSearching(true);
|
||
setSearchError(null);
|
||
setResults([]);
|
||
try {
|
||
const res = await fetch("/api/skills/search", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ query: q.trim() }),
|
||
});
|
||
const d = (await res.json()) as {
|
||
results?: SkillSearchResult[];
|
||
error?: string;
|
||
};
|
||
if (d.error) {
|
||
setSearchError(d.error);
|
||
return;
|
||
}
|
||
setResults(d.results ?? []);
|
||
if ((d.results ?? []).length === 0) setSearchError("No skills found");
|
||
} catch (e) {
|
||
setSearchError(String(e));
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
}, []);
|
||
|
||
const install = useCallback(
|
||
async (pkg: string) => {
|
||
setInstalling(pkg);
|
||
setInstallError(null);
|
||
try {
|
||
const res = await fetch("/api/skills/install", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ package: pkg, scope, cwd }),
|
||
});
|
||
const d = (await res.json()) as { success?: boolean; error?: string };
|
||
if (!res.ok || d.error) {
|
||
setInstallError(d.error ?? `HTTP ${res.status}`);
|
||
return;
|
||
}
|
||
setNewlyInstalledPkgs((prev) =>
|
||
new Set(prev).add(`${scope}:${pkg}`),
|
||
);
|
||
onInstalled();
|
||
} catch (e) {
|
||
setInstallError(String(e));
|
||
} finally {
|
||
setInstalling(null);
|
||
}
|
||
},
|
||
[onInstalled, cwd],
|
||
);
|
||
|
||
const installPath = "data/skills-home/.agents/skills/";
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||
{/* ── Header area ── */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 12,
|
||
marginBottom: 20,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text)" }}>
|
||
{t("i18n.addSkill")}
|
||
</div>
|
||
|
||
{/* Search row */}
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
<input
|
||
ref={inputRef}
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") search(query);
|
||
}}
|
||
placeholder={t("i18n.skillSearchPlaceholder")}
|
||
style={{
|
||
flex: 1,
|
||
padding: "7px 10px",
|
||
fontSize: 13,
|
||
background: "var(--bg-panel)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 6,
|
||
color: "var(--text)",
|
||
outline: "none",
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => search(query)}
|
||
disabled={searching || !query.trim()}
|
||
style={{
|
||
padding: "7px 16px",
|
||
fontSize: 13,
|
||
borderRadius: 6,
|
||
border: "none",
|
||
background: "var(--accent)",
|
||
color: "#fff",
|
||
cursor: searching || !query.trim() ? "not-allowed" : "pointer",
|
||
opacity: searching || !query.trim() ? 0.5 : 1,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{searching ? t("i18n.searching") : t("i18n.search")}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Scope + install path row */}
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
borderRadius: 5,
|
||
border: "1px solid var(--border)",
|
||
overflow: "hidden",
|
||
fontSize: 12,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<span style={{ padding: "3px 10px", color: "var(--text)", fontWeight: 600 }}>
|
||
app
|
||
</span>
|
||
</div>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--text-dim)",
|
||
fontFamily: "var(--font-mono)",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
→ {installPath}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Errors */}
|
||
{searchError && (
|
||
<div style={{ fontSize: 12, color: "#f87171" }}>{searchError}</div>
|
||
)}
|
||
{installError && (
|
||
<div
|
||
style={{ fontSize: 12, color: "#f87171", wordBreak: "break-word" }}
|
||
>
|
||
{installError}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Results list ── */}
|
||
{results.length > 0 ? (
|
||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||
{results.map((r) => {
|
||
const isInstalled =
|
||
installedPackages[scope].has(r.package) ||
|
||
newlyInstalledPkgs.has(`${scope}:${r.package}`);
|
||
const isInstalling = installing === r.package;
|
||
// split "owner/repo@skill" for cleaner display
|
||
const atIdx = r.package.indexOf("@");
|
||
const repopart = atIdx > -1 ? r.package.slice(0, atIdx) : r.package;
|
||
const skillpart = atIdx > -1 ? r.package.slice(atIdx + 1) : null;
|
||
return (
|
||
<div
|
||
key={r.package}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 14,
|
||
padding: "12px 0",
|
||
borderBottom: "1px solid var(--border)",
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{/* skill name prominent */}
|
||
<div
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
color: "var(--text)",
|
||
marginBottom: 3,
|
||
}}
|
||
>
|
||
{skillpart ?? repopart}
|
||
</div>
|
||
{/* repo + installs + link row */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 10,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontFamily: "var(--font-mono)",
|
||
fontSize: 11,
|
||
color: "var(--text-dim)",
|
||
}}
|
||
>
|
||
{repopart}
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{r.installs}
|
||
</span>
|
||
{r.url && (
|
||
<a
|
||
href={r.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--accent)",
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
skills.sh ↗
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() =>
|
||
!isInstalled && !isInstalling && install(r.package)
|
||
}
|
||
disabled={isInstalled || isInstalling || installing !== null}
|
||
style={{
|
||
flexShrink: 0,
|
||
padding: "5px 14px",
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
borderRadius: 5,
|
||
border: "1px solid var(--border)",
|
||
cursor:
|
||
isInstalled || isInstalling || installing !== null
|
||
? "not-allowed"
|
||
: "pointer",
|
||
background: isInstalled ? "rgba(34,197,94,0.1)" : "none",
|
||
color: isInstalled
|
||
? "#16a34a"
|
||
: isInstalling
|
||
? "var(--accent)"
|
||
: "var(--text-muted)",
|
||
transition: "color 0.12s",
|
||
}}
|
||
>
|
||
{isInstalled
|
||
? `✓ ${t("i18n.installed")}`
|
||
: isInstalling
|
||
? t("i18n.installing")
|
||
: t("i18n.install")}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
!searchError &&
|
||
!searching && (
|
||
<div
|
||
style={{ fontSize: 13, color: "var(--text-dim)", lineHeight: 1.8 }}
|
||
>
|
||
Search{" "}
|
||
<a
|
||
href="https://skills.sh"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
style={{ color: "var(--accent)", textDecoration: "none" }}
|
||
>
|
||
skills.sh
|
||
</a>{" "}
|
||
to discover and install skills for your agent.
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SkillsConfig({
|
||
cwd,
|
||
onClose,
|
||
}: {
|
||
cwd: string;
|
||
onClose: () => void;
|
||
}) {
|
||
const isMobile = useIsMobile();
|
||
const { t } = useI18n();
|
||
const [skills, setSkills] = useState<Skill[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [selected, setSelected] = useState<string | null>(null);
|
||
const [toggling, setToggling] = useState<Set<string>>(new Set());
|
||
const [saveError, setSaveError] = useState<string | null>(null);
|
||
const [addMode, setAddMode] = useState(false);
|
||
const [updateStatuses, setUpdateStatuses] = useState<Record<string, SkillUpdateResult>>({});
|
||
const [checkingUpdates, setCheckingUpdates] = useState<Set<string>>(new Set());
|
||
const [checkingAll, setCheckingAll] = useState(false);
|
||
const [updatingSkill, setUpdatingSkill] = useState<string | null>(null);
|
||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||
|
||
const loadSkills = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(`/api/skills?cwd=${encodeURIComponent(cwd)}`);
|
||
const d = (await res.json()) as Partial<SkillsResponse> & { error?: string };
|
||
if (!res.ok || d.error) throw new Error(d.error ?? `HTTP ${res.status}`);
|
||
const list = d.skills ?? [];
|
||
setSkills(list);
|
||
if (list.length > 0 && !selected) setSelected(list[0].filePath);
|
||
return list;
|
||
} catch (e) {
|
||
setError(String(e));
|
||
return [];
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [cwd, selected]);
|
||
|
||
useEffect(() => {
|
||
setUpdateStatuses({});
|
||
setUpdateError(null);
|
||
void loadSkills();
|
||
}, [cwd]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const checkForUpdates = useCallback(async (skill?: Skill) => {
|
||
const targets = skill
|
||
? [skill]
|
||
: skills.filter((item) => Boolean(item.install));
|
||
const keys = targets
|
||
.map(updateKey)
|
||
.filter((key): key is string => Boolean(key));
|
||
if (keys.length === 0) return;
|
||
|
||
setUpdateError(null);
|
||
setCheckingUpdates((current) => new Set([...current, ...keys]));
|
||
if (!skill) setCheckingAll(true);
|
||
try {
|
||
const res = await fetch("/api/skills/check", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
cwd,
|
||
package: skill?.install?.package,
|
||
scope: skill?.install?.scope,
|
||
}),
|
||
});
|
||
const data = (await res.json()) as {
|
||
updates?: SkillUpdateResult[];
|
||
error?: string;
|
||
};
|
||
if (!res.ok || data.error) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||
setUpdateStatuses((current) => {
|
||
const next = { ...current };
|
||
for (const update of data.updates ?? []) {
|
||
next[`${update.scope}\0${update.package}`] = update;
|
||
}
|
||
return next;
|
||
});
|
||
} catch (e) {
|
||
setUpdateError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setCheckingUpdates((current) => {
|
||
const next = new Set(current);
|
||
for (const key of keys) next.delete(key);
|
||
return next;
|
||
});
|
||
if (!skill) setCheckingAll(false);
|
||
}
|
||
}, [cwd, skills]);
|
||
|
||
const updateInstalledSkill = useCallback(async (skill: Skill) => {
|
||
if (!skill.install) return;
|
||
const key = updateKey(skill)!;
|
||
setUpdatingSkill(key);
|
||
setUpdateError(null);
|
||
try {
|
||
const res = await fetch("/api/skills/update", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
cwd,
|
||
package: skill.install.package,
|
||
scope: skill.install.scope,
|
||
}),
|
||
});
|
||
const data = (await res.json()) as {
|
||
success?: boolean;
|
||
skill?: Skill;
|
||
error?: string;
|
||
};
|
||
if (!res.ok || data.error || !data.success) {
|
||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||
}
|
||
await loadSkills();
|
||
const versionHash = data.skill?.install?.versionHash;
|
||
setUpdateStatuses((current) => ({
|
||
...current,
|
||
[key]: {
|
||
package: skill.install!.package,
|
||
scope: skill.install!.scope,
|
||
state: "up-to-date",
|
||
currentVersion: versionHash,
|
||
latestVersion: versionHash,
|
||
},
|
||
}));
|
||
} catch (e) {
|
||
setUpdateError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setUpdatingSkill(null);
|
||
}
|
||
}, [cwd, loadSkills]);
|
||
|
||
const toggle = useCallback(async (skill: Skill) => {
|
||
const next = !skill.disableModelInvocation;
|
||
setToggling((s) => new Set(s).add(skill.filePath));
|
||
setSaveError(null);
|
||
try {
|
||
const res = await fetch("/api/skills", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
filePath: skill.filePath,
|
||
disableModelInvocation: next,
|
||
}),
|
||
});
|
||
const d = (await res.json()) as { success?: boolean; error?: string };
|
||
if (!res.ok || d.error) {
|
||
setSaveError(d.error ?? `HTTP ${res.status}`);
|
||
return;
|
||
}
|
||
setSkills((prev) =>
|
||
prev.map((s) =>
|
||
s.filePath === skill.filePath
|
||
? { ...s, disableModelInvocation: next }
|
||
: s,
|
||
),
|
||
);
|
||
} catch (e) {
|
||
setSaveError(String(e));
|
||
} finally {
|
||
setToggling((s) => {
|
||
const n = new Set(s);
|
||
n.delete(skill.filePath);
|
||
return n;
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
const selectedSkill = skills.find((s) => s.filePath === selected) ?? null;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
inset: 0,
|
||
zIndex: 1000,
|
||
background: "rgba(0,0,0,0.35)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) onClose();
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: isMobile ? "calc(100vw - 16px)" : 860,
|
||
maxWidth: "calc(100vw - 16px)",
|
||
height: isMobile ? "calc(100dvh - 16px)" : "78vh",
|
||
maxHeight: "calc(100dvh - 16px)",
|
||
background: "var(--bg)",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 10,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "12px 18px",
|
||
borderBottom: "1px solid var(--border)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||
<span
|
||
style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}
|
||
>
|
||
{t("common.skills")}
|
||
</span>
|
||
<code
|
||
style={{
|
||
fontSize: 11,
|
||
color: "var(--text-muted)",
|
||
fontFamily: "var(--font-mono)",
|
||
maxWidth: 320,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{shortenPath(cwd)}
|
||
</code>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
color: "var(--text-muted)",
|
||
cursor: "pointer",
|
||
fontSize: 20,
|
||
lineHeight: 1,
|
||
padding: "2px 6px",
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
|
||
{/* Left: skill list */}
|
||
<div
|
||
style={{
|
||
width: isMobile ? "100%" : 210,
|
||
maxHeight: isMobile ? "40vh" : undefined,
|
||
borderRight: isMobile ? "none" : "1px solid var(--border)",
|
||
borderBottom: isMobile ? "1px solid var(--border)" : "none",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
flexShrink: 0,
|
||
background: "var(--bg-panel)",
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
|
||
{loading ? (
|
||
<div
|
||
style={{
|
||
padding: "10px 8px",
|
||
fontSize: 12,
|
||
color: "var(--text-muted)",
|
||
}}
|
||
>
|
||
{t("i18n.loading")}
|
||
</div>
|
||
) : error ? (
|
||
<div
|
||
style={{
|
||
padding: "10px 8px",
|
||
fontSize: 11,
|
||
color: "#f87171",
|
||
}}
|
||
>
|
||
{error}
|
||
</div>
|
||
) : skills.length === 0 ? (
|
||
<div
|
||
style={{
|
||
padding: "10px 8px",
|
||
fontSize: 11,
|
||
color: "var(--text-dim)",
|
||
}}
|
||
>
|
||
{t("i18n.noSkills")}
|
||
</div>
|
||
) : (
|
||
(() => {
|
||
const groups: { label: string; skills: typeof skills }[] = [];
|
||
const groupDefinitions = [
|
||
{
|
||
label: "project / skills.sh",
|
||
matches: (skill: Skill) =>
|
||
sourceLabel(skill) === "project" &&
|
||
Boolean(skill.install?.skillsShUrl),
|
||
},
|
||
{
|
||
label: "project",
|
||
matches: (skill: Skill) =>
|
||
sourceLabel(skill) === "project" &&
|
||
!skill.install?.skillsShUrl,
|
||
},
|
||
{
|
||
label: "app / skills.sh",
|
||
matches: (skill: Skill) =>
|
||
sourceLabel(skill) === "app" &&
|
||
Boolean(skill.install?.skillsShUrl),
|
||
},
|
||
{
|
||
label: "app",
|
||
matches: (skill: Skill) =>
|
||
sourceLabel(skill) === "app" &&
|
||
!skill.install?.skillsShUrl,
|
||
},
|
||
{
|
||
label: "path",
|
||
matches: (skill: Skill) => sourceLabel(skill) === "path",
|
||
},
|
||
];
|
||
for (const { label, matches } of groupDefinitions) {
|
||
const grpSkills = skills.filter(matches);
|
||
if (grpSkills.length > 0)
|
||
groups.push({ label, skills: grpSkills });
|
||
}
|
||
return groups.map(
|
||
({ label: grpLabel, skills: grpSkills }) => (
|
||
<div key={grpLabel} style={{ marginBottom: 6 }}>
|
||
<div
|
||
style={{
|
||
padding: "4px 8px 3px",
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
color: "var(--text-dim)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.06em",
|
||
}}
|
||
>
|
||
{grpLabel}
|
||
</div>
|
||
{grpSkills.map((skill) => {
|
||
const isSelected =
|
||
!addMode && selected === skill.filePath;
|
||
const disabled = skill.disableModelInvocation;
|
||
return (
|
||
<div
|
||
key={skill.filePath}
|
||
onClick={() => {
|
||
setSelected(skill.filePath);
|
||
setAddMode(false);
|
||
}}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 7,
|
||
padding: "8px 8px",
|
||
borderRadius: 5,
|
||
cursor: "pointer",
|
||
background: isSelected
|
||
? "var(--bg-selected)"
|
||
: "none",
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isSelected)
|
||
e.currentTarget.style.background =
|
||
"var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isSelected)
|
||
e.currentTarget.style.background = "none";
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
flexShrink: 0,
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: "50%",
|
||
background: disabled
|
||
? "var(--border)"
|
||
: "var(--accent)",
|
||
boxShadow: disabled
|
||
? "none"
|
||
: "0 0 4px var(--accent)",
|
||
transition:
|
||
"background 0.15s, box-shadow 0.15s",
|
||
}}
|
||
/>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: isSelected ? 600 : 400,
|
||
color: disabled
|
||
? "var(--text-dim)"
|
||
: "var(--text)",
|
||
fontFamily: "var(--font-mono)",
|
||
flex: 1,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{skill.name}
|
||
</span>
|
||
{(() => {
|
||
const key = updateKey(skill);
|
||
const status = key ? updateStatuses[key] : undefined;
|
||
if (status?.state !== "update-available") return null;
|
||
return (
|
||
<span
|
||
title={t("i18n.updateAvailable")}
|
||
style={{
|
||
color: "#d97706",
|
||
fontSize: 13,
|
||
lineHeight: 1,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
↑
|
||
</span>
|
||
);
|
||
})()}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
),
|
||
);
|
||
})()
|
||
)}
|
||
</div>
|
||
{/* Add skill button */}
|
||
<div
|
||
style={{
|
||
padding: "8px 6px",
|
||
borderTop: "1px solid var(--border)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<div
|
||
onClick={() => setAddMode(true)}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
padding: "7px 8px",
|
||
borderRadius: 5,
|
||
cursor: "pointer",
|
||
background: addMode ? "var(--bg-selected)" : "none",
|
||
color: addMode ? "var(--accent)" : "var(--text-dim)",
|
||
fontSize: 12,
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!addMode)
|
||
e.currentTarget.style.background = "var(--bg-hover)";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!addMode) e.currentTarget.style.background = "none";
|
||
}}
|
||
>
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<line x1="12" y1="5" x2="12" y2="19" />
|
||
<line x1="5" y1="12" x2="19" y2="12" />
|
||
</svg>
|
||
{t("i18n.addSkill")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: detail or add panel */}
|
||
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
|
||
{addMode ? (
|
||
<AddSkillPanel
|
||
cwd={cwd}
|
||
installedPackages={{
|
||
global: new Set(
|
||
skills
|
||
.filter((skill) => skill.install?.scope === "global")
|
||
.map((skill) => skill.install!.package),
|
||
),
|
||
project: new Set(
|
||
skills
|
||
.filter((skill) => skill.install?.scope === "project")
|
||
.map((skill) => skill.install!.package),
|
||
),
|
||
}}
|
||
onInstalled={() => {
|
||
void loadSkills();
|
||
}}
|
||
/>
|
||
) : loading ? null : selectedSkill ? (
|
||
<SkillDetail
|
||
key={selectedSkill.filePath}
|
||
skill={selectedSkill}
|
||
cwd={cwd}
|
||
onToggle={toggle}
|
||
toggling={toggling.has(selectedSkill.filePath)}
|
||
saveError={saveError}
|
||
updateStatus={
|
||
updateKey(selectedSkill)
|
||
? updateStatuses[updateKey(selectedSkill)!]
|
||
: undefined
|
||
}
|
||
checkingUpdate={
|
||
updateKey(selectedSkill)
|
||
? checkingUpdates.has(updateKey(selectedSkill)!)
|
||
: false
|
||
}
|
||
updating={updatingSkill === updateKey(selectedSkill)}
|
||
updateError={updateError}
|
||
onCheckUpdate={() => void checkForUpdates(selectedSkill)}
|
||
onUpdate={() => void updateInstalledSkill(selectedSkill)}
|
||
/>
|
||
) : (
|
||
<div
|
||
style={{
|
||
height: "100%",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
color: "var(--text-dim)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{t("i18n.selectSkill")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "10px 18px",
|
||
borderTop: "1px solid var(--border)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||
{skills.some((skill) => Boolean(skill.install)) && (
|
||
<button
|
||
onClick={() => void checkForUpdates()}
|
||
disabled={checkingAll || updatingSkill !== null}
|
||
style={{
|
||
padding: "6px 12px",
|
||
background: "none",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 6,
|
||
color: "var(--text-muted)",
|
||
cursor:
|
||
checkingAll || updatingSkill !== null
|
||
? "not-allowed"
|
||
: "pointer",
|
||
opacity: checkingAll || updatingSkill !== null ? 0.5 : 1,
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{checkingAll ? t("i18n.checking") : t("i18n.checkUpdates")}
|
||
</button>
|
||
)}
|
||
{Object.values(updateStatuses).filter(
|
||
(status) => status.state === "update-available",
|
||
).length > 0 && (
|
||
<span style={{ fontSize: 12, color: "#d97706" }}>
|
||
{
|
||
Object.values(updateStatuses).filter(
|
||
(status) => status.state === "update-available",
|
||
).length
|
||
}{" "}
|
||
{Object.values(updateStatuses).filter(
|
||
(status) => status.state === "update-available",
|
||
).length === 1
|
||
? t("i18n.update")
|
||
: t("i18n.updates")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
padding: "6px 14px",
|
||
background: "none",
|
||
border: "1px solid var(--border)",
|
||
borderRadius: 6,
|
||
color: "var(--text-muted)",
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{t("i18n.close")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|