feat(dashboard): profile-scoped skills & toolsets management
'Set as active' on the Profiles page only flips the sticky active_profile file (future CLI/gateway runs) — it never retargets the running dashboard process. The skills/toolsets endpoints called bare load_config()/ save_config(), so after 'activating' a profile in the web UI, deactivating a skill silently wrote into the dashboard's own profile and the activated profile was untouched. Backend: - _profile_scope() context manager on the skills/toolsets endpoints: context-local HERMES_HOME override for call-time config resolution + cron-style locked swap of tools.skills_tool's import-time SKILLS_DIR - profile param on /api/skills, /api/skills/toggle, /api/tools/toolsets* (list/toggle/config/provider/env), hub sources/search installed-state - hub install/uninstall/update spawn 'hermes -p <profile> skills ...' so the child rebinds skills_hub.SKILLS_DIR at import (the override cannot reach import-time globals); profile validated -> 404/400 before spawn Frontend: - Skills page: profile selector (deep-linkable /skills?profile=<name>), amber banner naming the managed profile, threaded through skill toggles, toolset drawer, and hub browser - Profiles page: 'Manage skills & tools' action per card; 'Set as active' toast now says it applies to new CLI/gateway runs only Omitted profile keeps legacy behavior (dashboard's own profile).
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import spinners from "unicode-animations";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ActiveProfileInfo, ProfileInfo } from "@/lib/api";
|
||||
@@ -96,6 +97,7 @@ function ProfileActionsMenu({
|
||||
onEditDescription,
|
||||
onEditModel,
|
||||
onEditSoul,
|
||||
onManageSkills,
|
||||
onRename,
|
||||
onSetActive,
|
||||
}: ProfileActionsMenuProps) {
|
||||
@@ -201,6 +203,16 @@ function ProfileActionsMenu({
|
||||
{labels.editSoul}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={itemClass}
|
||||
onClick={run(onManageSkills)}
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
{labels.manageSkills}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -241,6 +253,7 @@ function ProfileActionsMenu({
|
||||
}
|
||||
|
||||
export default function ProfilesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [activeInfo, setActiveInfo] = useState<ActiveProfileInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -291,6 +304,10 @@ export default function ProfilesPage() {
|
||||
modelSaved: p.modelSaved ?? "Model updated",
|
||||
modelSelect: p.modelSelect ?? "Select a model",
|
||||
actions: p.actions ?? "Actions",
|
||||
manageSkills: p.manageSkills ?? "Manage skills & tools",
|
||||
activeSetHint:
|
||||
p.activeSetHint ??
|
||||
"Applies to new CLI/gateway runs. This dashboard still manages its own profile — use “Manage skills & tools” to edit {name}.",
|
||||
};
|
||||
}, [t.profiles]);
|
||||
|
||||
@@ -480,7 +497,14 @@ export default function ProfilesPage() {
|
||||
// The backend normalizes/validates the name; trust the canonical
|
||||
// value it returns rather than the raw input.
|
||||
const { active } = await api.setActiveProfile(name);
|
||||
showToast(`${L.activeSet}: ${active}`, "success");
|
||||
// "Set as active" only flips the sticky default for FUTURE CLI/gateway
|
||||
// invocations — it does NOT retarget this running dashboard. Say so,
|
||||
// or users assume skill/tool toggles now apply to the activated
|
||||
// profile (they don't — that's what "Manage skills & tools" is for).
|
||||
showToast(
|
||||
`${L.activeSet}: ${active} — ${L.activeSetHint.replace("{name}", active)}`,
|
||||
"success",
|
||||
);
|
||||
setActiveInfo((prev) =>
|
||||
prev ? { ...prev, active } : { active, current: active },
|
||||
);
|
||||
@@ -1110,6 +1134,7 @@ export default function ProfilesPage() {
|
||||
editModel: L.editModel,
|
||||
editDescription: L.editDescription,
|
||||
editSoul: t.profiles.editSoul,
|
||||
manageSkills: L.manageSkills,
|
||||
openInTerminal: t.profiles.openInTerminal,
|
||||
rename: t.profiles.rename,
|
||||
delete: t.common.delete,
|
||||
@@ -1121,6 +1146,11 @@ export default function ProfilesPage() {
|
||||
onEditDescription={() => openDescEditor(p)}
|
||||
onEditModel={() => openModelEditor(p)}
|
||||
onEditSoul={() => openSoulEditor(p.name)}
|
||||
onManageSkills={() =>
|
||||
navigate(
|
||||
`/skills?profile=${encodeURIComponent(p.name)}`,
|
||||
)
|
||||
}
|
||||
onRename={() => {
|
||||
setRenamingFrom(p.name);
|
||||
setRenameTo(p.name);
|
||||
@@ -1375,6 +1405,7 @@ interface ProfileActionsMenuProps {
|
||||
editDescription: string;
|
||||
editModel: string;
|
||||
editSoul: string;
|
||||
manageSkills: string;
|
||||
openInTerminal: string;
|
||||
rename: string;
|
||||
setActive: string;
|
||||
@@ -1385,6 +1416,7 @@ interface ProfileActionsMenuProps {
|
||||
onEditDescription: () => void;
|
||||
onEditModel: () => void;
|
||||
onEditSoul: () => void;
|
||||
onManageSkills: () => void;
|
||||
onRename: () => void;
|
||||
onSetActive: () => void;
|
||||
}
|
||||
|
||||
+134
-18
@@ -25,6 +25,7 @@ import {
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
@@ -35,7 +36,9 @@ import type {
|
||||
SkillHubInstalledEntry,
|
||||
SkillHubPreview,
|
||||
SkillHubScan,
|
||||
ProfileInfo,
|
||||
} from "@/lib/api";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
@@ -133,21 +136,79 @@ export default function SkillsPage() {
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
||||
// ── Profile scoping ──
|
||||
// The dashboard process runs under ONE profile, but skills/toolsets are
|
||||
// per-profile state. Without an explicit selector, users who "activated"
|
||||
// a profile on the Profiles page (which only affects FUTURE CLI/gateway
|
||||
// runs) toggled skills here and silently wrote into the dashboard's own
|
||||
// profile. The selector makes the write target explicit and deep-linkable
|
||||
// via /skills?profile=<name>.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [currentProfile, setCurrentProfile] = useState<string>("");
|
||||
const urlProfile = searchParams.get("profile") ?? "";
|
||||
// "" = the dashboard's own profile (legacy behavior).
|
||||
const selectedProfile = urlProfile;
|
||||
|
||||
const setSelectedProfile = useCallback(
|
||||
(name: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (name) next.set("profile", name);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// The profile actually being managed, for display purposes.
|
||||
const managedProfile = selectedProfile || currentProfile || "default";
|
||||
const managingOtherProfile =
|
||||
!!selectedProfile && selectedProfile !== currentProfile;
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getSkills(), api.getToolsets()])
|
||||
// Profile list + the dashboard's own profile, for the selector. Failure
|
||||
// leaves the selector hidden — the page still works profile-unscoped.
|
||||
api
|
||||
.getProfiles()
|
||||
.then((res) => setProfiles(res.profiles))
|
||||
.catch(() => {});
|
||||
api
|
||||
.getActiveProfile()
|
||||
.then((info) => setCurrentProfile(info.current || "default"))
|
||||
.catch(() => setCurrentProfile("default"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Promise-chain shape: setState fires only inside async callbacks so the
|
||||
// effect body stays lint-clean (react-hooks/set-state-in-effect). On a
|
||||
// profile switch the old list stays visible until the new one arrives.
|
||||
let cancelled = false;
|
||||
Promise.all([
|
||||
api.getSkills(selectedProfile || undefined),
|
||||
api.getToolsets(selectedProfile || undefined),
|
||||
])
|
||||
.then(([s, tsets]) => {
|
||||
if (cancelled) return;
|
||||
setSkills(s);
|
||||
setToolsets(tsets);
|
||||
})
|
||||
.catch(() => showToast(t.common.loading, "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
.catch(() => !cancelled && showToast(t.common.loading, "error"))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProfile]);
|
||||
|
||||
/* ---- Toggle skill ---- */
|
||||
const handleToggleSkill = async (skill: SkillInfo) => {
|
||||
setTogglingSkills((prev) => new Set(prev).add(skill.name));
|
||||
try {
|
||||
await api.toggleSkill(skill.name, !skill.enabled);
|
||||
await api.toggleSkill(skill.name, !skill.enabled, selectedProfile || undefined);
|
||||
setSkills((prev) =>
|
||||
prev.map((s) =>
|
||||
s.name === skill.name ? { ...s, enabled: !s.enabled } : s,
|
||||
@@ -233,10 +294,37 @@ export default function SkillsPage() {
|
||||
return;
|
||||
}
|
||||
setAfterTitle(
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{t.skills.enabledOf
|
||||
.replace("{enabled}", String(enabledCount))
|
||||
.replace("{total}", String(skills.length))}
|
||||
{profiles.length > 1 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
<select
|
||||
aria-label={t.skills.profileSelector ?? "Profile"}
|
||||
className="h-6 rounded-none border border-border bg-background px-1 text-xs text-foreground"
|
||||
value={selectedProfile}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setSelectedProfile(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{(t.skills.currentProfile ?? "current ({name})").replace(
|
||||
"{name}",
|
||||
currentProfile || "default",
|
||||
)}
|
||||
</option>
|
||||
{profiles
|
||||
.filter((p) => p.name !== currentProfile)
|
||||
.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
)}
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
@@ -265,7 +353,19 @@ export default function SkillsPage() {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]);
|
||||
}, [
|
||||
enabledCount,
|
||||
loading,
|
||||
search,
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
skills.length,
|
||||
t,
|
||||
profiles,
|
||||
selectedProfile,
|
||||
currentProfile,
|
||||
setSelectedProfile,
|
||||
]);
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
return toolsets.filter(
|
||||
@@ -291,6 +391,18 @@ export default function SkillsPage() {
|
||||
<PluginSlot name="skills:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
{managingOtherProfile && (
|
||||
<div className="flex items-center gap-2 border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{(
|
||||
t.skills.managingProfile ??
|
||||
"Managing profile “{name}” — toggles apply to that profile, not this dashboard’s."
|
||||
).replace("{name}", managedProfile)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-0">
|
||||
@@ -540,13 +652,14 @@ export default function SkillsPage() {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<HubBrowser showToast={showToast} />
|
||||
<HubBrowser showToast={showToast} profile={selectedProfile || undefined} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{configToolset && (
|
||||
<ToolsetConfigDrawer
|
||||
toolset={configToolset}
|
||||
profile={selectedProfile || undefined}
|
||||
onClose={() => setConfigToolset(null)}
|
||||
onChanged={() => void refreshToolsets()}
|
||||
/>
|
||||
@@ -668,8 +781,11 @@ const SEVERITY_TONE: Record<string, "destructive" | "warning" | "secondary" | "o
|
||||
|
||||
function HubBrowser({
|
||||
showToast,
|
||||
profile,
|
||||
}: {
|
||||
showToast: (msg: string, kind: "success" | "error") => void;
|
||||
/** Optional profile scoping installs + installed-state badges. */
|
||||
profile?: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SkillHubResult[]>([]);
|
||||
@@ -699,7 +815,7 @@ function HubBrowser({
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSkillHubSources()
|
||||
.getSkillHubSources(profile)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setSources(r.sources);
|
||||
@@ -715,7 +831,7 @@ function HubBrowser({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [profile]);
|
||||
|
||||
/* ---- Search ---- */
|
||||
const runSearch = useCallback(async () => {
|
||||
@@ -725,7 +841,7 @@ function HubBrowser({
|
||||
setSearched(true);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await api.searchSkillsHub(q);
|
||||
const r = await api.searchSkillsHub(q, "all", 20, profile);
|
||||
setResults(r.results);
|
||||
setSourceCounts(r.source_counts || {});
|
||||
setTimedOut(r.timed_out || []);
|
||||
@@ -739,7 +855,7 @@ function HubBrowser({
|
||||
setSearchMs(Math.round(performance.now() - t0));
|
||||
setSearching(false);
|
||||
}
|
||||
}, [query, showToast]);
|
||||
}, [query, showToast, profile]);
|
||||
|
||||
/* ---- Poll a spawned action's log until it exits ---- */
|
||||
useEffect(() => {
|
||||
@@ -757,7 +873,7 @@ function HubBrowser({
|
||||
} else {
|
||||
// Install finished — refresh installed-state so badges update.
|
||||
api
|
||||
.getSkillHubSources()
|
||||
.getSkillHubSources(profile)
|
||||
.then((r) => !cancelled && setInstalled(r.installed))
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -770,12 +886,12 @@ function HubBrowser({
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [action]);
|
||||
}, [action, profile]);
|
||||
|
||||
const install = useCallback(
|
||||
async (identifier: string) => {
|
||||
try {
|
||||
const res = await api.installSkillFromHub(identifier);
|
||||
const res = await api.installSkillFromHub(identifier, profile);
|
||||
showToast(`Installing ${identifier}…`, "success");
|
||||
setActionLog([]);
|
||||
setActionRunning(true);
|
||||
@@ -785,12 +901,12 @@ function HubBrowser({
|
||||
showToast(`Install failed: ${e}`, "error");
|
||||
}
|
||||
},
|
||||
[showToast],
|
||||
[showToast, profile],
|
||||
);
|
||||
|
||||
const updateAll = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.updateSkillsFromHub();
|
||||
const res = await api.updateSkillsFromHub(profile);
|
||||
showToast("Updating installed skills…", "success");
|
||||
setActionLog([]);
|
||||
setActionRunning(true);
|
||||
@@ -798,7 +914,7 @@ function HubBrowser({
|
||||
} catch (e) {
|
||||
showToast(`Update failed: ${e}`, "error");
|
||||
}
|
||||
}, [showToast]);
|
||||
}, [showToast, profile]);
|
||||
|
||||
const isInstalled = useCallback(
|
||||
(identifier: string) => Boolean(installed[identifier]),
|
||||
|
||||
Reference in New Issue
Block a user