mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)
Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting the Ink/npm flow unconditionally; with the dual-engine dispatch merged in, _resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built in the repo, routing the call away from the path under test (first subprocess became 'node --version' instead of 'npm run build'). Pin the engine to ink via an autouse fixture, mirroring the existing pinning precedent in test_tui_resume_flow.py.
This commit is contained in:
parent
ab37440ce6
commit
e1067dbbe5
756 changed files with 79874 additions and 19585 deletions
|
|
@ -26,6 +26,7 @@ import {
|
|||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Globe,
|
||||
Heart,
|
||||
|
|
@ -63,17 +64,23 @@ import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint
|
|||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { AuthWidget } from "@/components/AuthWidget";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import { ProfileProvider } from "@/contexts/ProfileProvider";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { ProfileSwitcher } from "@/components/ProfileSwitcher";
|
||||
import { ProfileScopeBanner } from "@/components/ProfileScopeBanner";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import DocsPage from "@/pages/DocsPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
import FilesPage from "@/pages/FilesPage";
|
||||
import SessionsPage from "@/pages/SessionsPage";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||
import ModelsPage from "@/pages/ModelsPage";
|
||||
import CronPage from "@/pages/CronPage";
|
||||
import ProfilesPage from "@/pages/ProfilesPage";
|
||||
import ProfileBuilderPage from "@/pages/ProfileBuilderPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
import PluginsPage from "@/pages/PluginsPage";
|
||||
import McpPage from "@/pages/McpPage";
|
||||
|
|
@ -124,6 +131,7 @@ const CHAT_NAV_ITEM: NavItem = {
|
|||
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/": RootRedirect,
|
||||
"/sessions": SessionsPage,
|
||||
"/files": FilesPage,
|
||||
"/analytics": AnalyticsPage,
|
||||
"/models": ModelsPage,
|
||||
"/logs": LogsPage,
|
||||
|
|
@ -136,6 +144,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
|||
"/webhooks": WebhooksPage,
|
||||
"/system": SystemPage,
|
||||
"/profiles": ProfilesPage,
|
||||
"/profiles/new": ProfileBuilderPage,
|
||||
"/config": ConfigPage,
|
||||
"/env": EnvPage,
|
||||
"/docs": DocsPage,
|
||||
|
|
@ -156,6 +165,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
|
|||
label: "Sessions",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||
{
|
||||
path: "/analytics",
|
||||
labelKey: "analytics",
|
||||
|
|
@ -194,6 +204,7 @@ const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
|||
Clock,
|
||||
Cpu,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
Package,
|
||||
|
|
@ -467,6 +478,7 @@ export default function App() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<ProfileProvider>
|
||||
<div
|
||||
data-layout-variant={layoutVariant}
|
||||
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
|
||||
|
|
@ -521,6 +533,7 @@ export default function App() {
|
|||
)}
|
||||
|
||||
<PluginSlot name="header-banner" />
|
||||
<ProfileScopeBanner />
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
|
|
@ -595,6 +608,8 @@ export default function App() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<ProfileSwitcher collapsed={isDesktopCollapsed} />
|
||||
|
||||
<nav
|
||||
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
|
||||
aria-label={t.app.navigation}
|
||||
|
|
@ -720,17 +735,19 @@ export default function App() {
|
|||
"min-h-0 flex flex-1 flex-col",
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
{routes.map(({ key, path, element }) => (
|
||||
<Route key={key} path={path} element={element} />
|
||||
))}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<ProfileKeyedRoutes>
|
||||
<Routes>
|
||||
{routes.map(({ key, path, element }) => (
|
||||
<Route key={key} path={path} element={element} />
|
||||
))}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ProfileKeyedRoutes>
|
||||
|
||||
{embeddedChat &&
|
||||
!chatOverriddenByPlugin &&
|
||||
|
|
@ -768,9 +785,25 @@ export default function App() {
|
|||
|
||||
<PluginSlot name="overlay" />
|
||||
</div>
|
||||
</ProfileProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remounts the entire routed page tree when the global management profile
|
||||
* changes. Pages load their data on mount; without this, a page opened
|
||||
* under profile A would keep showing A's state while writes (via the
|
||||
* fetchJSON ?profile= injection) silently targeted the newly selected
|
||||
* profile B — the exact stale-target footgun the switcher exists to kill.
|
||||
* Keying by profile resets every page's local state so it refetches under
|
||||
* the new scope. The persistent ChatPage host below handles its own
|
||||
* remount (channel keyed on scopedProfile).
|
||||
*/
|
||||
function ProfileKeyedRoutes({ children }: { children: ReactNode }) {
|
||||
const { profile } = useProfileScope();
|
||||
return <div key={profile || "__own__"} className="contents">{children}</div>;
|
||||
}
|
||||
|
||||
function SidebarNavLink({
|
||||
closeMobile,
|
||||
collapsed,
|
||||
|
|
|
|||
222
web/src/components/AutomationBlueprints.tsx
Normal file
222
web/src/components/AutomationBlueprints.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Clock, Wand2 } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AutomationBlueprint, AutomationBlueprintField } from "@/lib/api";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
interface AutomationBlueprintsProps {
|
||||
profile: string;
|
||||
/** Called after a blueprint is instantiated so the parent can refresh its job list. */
|
||||
onCreated?: () => void;
|
||||
}
|
||||
|
||||
/** Initial form values for a blueprint = each field's default (or ""). */
|
||||
function initialValues(blueprint: AutomationBlueprint): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const f of blueprint.fields) out[f.name] = f.default ?? "";
|
||||
return out;
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: AutomationBlueprintField;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
if (field.type === "enum" || field.type === "weekdays") {
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v)}>
|
||||
{field.options.map((opt) => (
|
||||
<SelectOption key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
if (field.type === "time") {
|
||||
return (
|
||||
<Input
|
||||
type="time"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// text
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={field.help || field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BlueprintCard({
|
||||
blueprint,
|
||||
profile,
|
||||
showToast,
|
||||
onCreated,
|
||||
}: {
|
||||
blueprint: AutomationBlueprint;
|
||||
profile: string;
|
||||
showToast: (message: string, type: "error" | "success") => void;
|
||||
onCreated?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, string>>(() => initialValues(blueprint));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const job = await api.instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile);
|
||||
const when = job.schedule_display ? ` — ${job.schedule_display}` : "";
|
||||
showToast(`${blueprint.title} scheduled${when}`, "success");
|
||||
setOpen(false);
|
||||
setValues(initialValues(blueprint));
|
||||
onCreated?.();
|
||||
} catch (e) {
|
||||
// 422 from the API carries the slot-level validation message.
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg.replace(/^\d+:\s*/, ""));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [blueprint, values, profile, showToast, onCreated]);
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", themedBody)}>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="h-4 w-4 shrink-0 opacity-70" />
|
||||
<span className="font-medium">{blueprint.title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm opacity-70">{blueprint.description}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{blueprint.tags.map((t) => (
|
||||
<Badge key={t} tone="secondary">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
ghost={open}
|
||||
size="sm"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{open ? "Cancel" : "Set up"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{blueprint.fields.map((f) => (
|
||||
<div key={f.name} className="space-y-1">
|
||||
<Label htmlFor={`${blueprint.key}-${f.name}`}>{f.label}</Label>
|
||||
<FieldInput
|
||||
field={f}
|
||||
value={values[f.name] ?? ""}
|
||||
onChange={(v) => setValues((prev) => ({ ...prev, [f.name]: v }))}
|
||||
/>
|
||||
{f.help && f.type !== "text" ? (
|
||||
<p className="text-xs opacity-60">{f.help}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{error ? (
|
||||
<p className="text-sm text-red-500" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => void submit()} disabled={submitting}>
|
||||
{submitting ? <Spinner className="h-4 w-4" /> : <Clock className="h-4 w-4" />}
|
||||
Schedule it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automation Blueprints gallery — the form-where-there's-a-screen surface. Each blueprint
|
||||
* card expands into an inline form (one field per typed slot); submitting POSTs
|
||||
* to /api/cron/blueprints/instantiate which fills the blueprint and creates the job
|
||||
* via the same create_job path as everything else.
|
||||
*/
|
||||
export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprintsProps) {
|
||||
const { toast, showToast } = useToast();
|
||||
const [blueprints, setBlueprints] = useState<AutomationBlueprint[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getAutomationBlueprints()
|
||||
.then((r) => {
|
||||
if (!cancelled) setBlueprints(r.blueprints);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loadError) {
|
||||
return <p className="text-sm text-red-500">Couldn't load blueprints: {loadError}</p>;
|
||||
}
|
||||
if (blueprints === null) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 opacity-70">
|
||||
<Spinner className="h-4 w-4" /> Loading blueprints…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (blueprints.length === 0) {
|
||||
return <p className="opacity-70">No automation blueprints available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast toast={toast} />
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{blueprints.map((r) => (
|
||||
<BlueprintCard
|
||||
key={r.key}
|
||||
blueprint={r}
|
||||
profile={profile}
|
||||
showToast={showToast}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutomationBlueprints;
|
||||
|
|
@ -120,7 +120,11 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
return gw.request<{ session_id: string }>("session.create", {});
|
||||
// close_on_disconnect: the gateway reaps this sidecar session (and its
|
||||
// slash_worker subprocess) when the WS drops, instead of leaking it.
|
||||
return gw.request<{ session_id: string }>("session.create", {
|
||||
close_on_disconnect: true,
|
||||
});
|
||||
})
|
||||
.then((created) => {
|
||||
if (cancelled || !created?.session_id) {
|
||||
|
|
@ -288,24 +292,6 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
setVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
// Picker hands us a fully-formed slash command (e.g. "/model anthropic/...").
|
||||
// Fire-and-forget through `slash.exec`; the TUI pane will render the result
|
||||
// via PTY, so the sidebar doesn't need to surface output of its own.
|
||||
const onModelSubmit = useCallback(
|
||||
(slashCommand: string) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void gw.request("slash.exec", {
|
||||
session_id: sessionId,
|
||||
command: slashCommand,
|
||||
});
|
||||
setModelOpen(false);
|
||||
},
|
||||
[gw, sessionId],
|
||||
);
|
||||
|
||||
const canPickModel = state === "open" && !!sessionId;
|
||||
const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—";
|
||||
const banner = error ?? info.credential_warning ?? null;
|
||||
|
|
@ -386,7 +372,6 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
gw={gw}
|
||||
sessionId={sessionId}
|
||||
onClose={() => setModelOpen(false)}
|
||||
onSubmit={onModelSubmit}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
|
|
|||
122
web/src/components/ConfirmDialog.tsx
Normal file
122
web/src/components/ConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
description?: string;
|
||||
destructive?: boolean;
|
||||
loading?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
cancelLabel = "Cancel",
|
||||
confirmLabel = "Confirm",
|
||||
description,
|
||||
destructive = false,
|
||||
loading = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
open,
|
||||
title,
|
||||
}: ConfirmDialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const prevActive = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current
|
||||
?.querySelector<HTMLButtonElement>("[data-confirm]")
|
||||
?.focus();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
prevActive?.focus?.();
|
||||
};
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby={description ? "confirm-dialog-desc" : undefined}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
themedBody,
|
||||
"relative w-full max-w-md border border-border bg-card shadow-2xl",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4 border-b border-border">
|
||||
{destructive && (
|
||||
<div aria-hidden className="mt-0.5 shrink-0 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<h2
|
||||
id="confirm-dialog-title"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{description && (
|
||||
<p
|
||||
id="confirm-dialog-desc"
|
||||
className="text-xs text-muted-foreground leading-relaxed whitespace-pre-line"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button type="button" outlined onClick={onCancel} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
data-confirm
|
||||
type="button"
|
||||
destructive={destructive}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "…" : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
|||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import type { GatewayClient } from "@/lib/gatewayClient";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -21,9 +22,8 @@ import { fuzzyRank } from "@/lib/fuzzy";
|
|||
* Two invocation modes:
|
||||
*
|
||||
* 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker
|
||||
* loads options via `model.options` JSON-RPC and emits the result as a
|
||||
* slash command string (`/model <model> --provider <slug> [--global]`)
|
||||
* through `onSubmit`, which the ChatPage pipes to `slashExec`.
|
||||
* loads options via `model.options` JSON-RPC and applies the choice via
|
||||
* `config.set`, so expensive-model confirmation can happen before switch.
|
||||
*
|
||||
* 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
|
||||
* `onApply`. The picker fetches options via the REST endpoint and calls
|
||||
|
|
@ -47,6 +47,23 @@ interface ModelOptionsResponse {
|
|||
providers?: ModelOptionProvider[];
|
||||
}
|
||||
|
||||
interface ExpensiveModelConfirmResponse {
|
||||
confirm_message?: string;
|
||||
confirm_required?: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
interface ConfigSetResponse extends ExpensiveModelConfirmResponse {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface PendingExpensiveConfirm {
|
||||
message: string;
|
||||
model: string;
|
||||
persistGlobal: boolean;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Chat-mode: when present, picker emits a slash command via onSubmit. */
|
||||
gw?: GatewayClient;
|
||||
|
|
@ -56,10 +73,14 @@ interface Props {
|
|||
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
|
||||
loader?(): Promise<ModelOptionsResponse>;
|
||||
onApply?(args: {
|
||||
confirmExpensiveModel?: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
persistGlobal: boolean;
|
||||
}): Promise<void> | void;
|
||||
}):
|
||||
| Promise<ExpensiveModelConfirmResponse | void>
|
||||
| ExpensiveModelConfirmResponse
|
||||
| void;
|
||||
|
||||
onClose(): void;
|
||||
title?: string;
|
||||
|
|
@ -90,6 +111,8 @@ export function ModelPickerDialog(props: Props) {
|
|||
const [query, setQuery] = useState("");
|
||||
const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [pendingConfirm, setPendingConfirm] =
|
||||
useState<PendingExpensiveConfirm | null>(null);
|
||||
const closedRef = useRef(false);
|
||||
|
||||
// Load providers + models on open.
|
||||
|
|
@ -179,16 +202,65 @@ export function ModelPickerDialog(props: Props) {
|
|||
|
||||
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
|
||||
|
||||
const confirm = async () => {
|
||||
if (!canConfirm || !selectedProvider) return;
|
||||
const applySelection = async (
|
||||
confirmExpensiveModel = false,
|
||||
forced?: PendingExpensiveConfirm,
|
||||
) => {
|
||||
const providerSlug = forced?.provider ?? selectedProvider?.slug ?? "";
|
||||
const model = forced?.model ?? selectedModel;
|
||||
const shouldPersistGlobal = forced?.persistGlobal ?? persistGlobal;
|
||||
|
||||
if (!providerSlug || !model || applying) return;
|
||||
|
||||
if (standalone && onApply) {
|
||||
setApplying(true);
|
||||
try {
|
||||
await onApply({
|
||||
provider: selectedProvider.slug,
|
||||
model: selectedModel,
|
||||
persistGlobal,
|
||||
const result = await onApply({
|
||||
confirmExpensiveModel,
|
||||
provider: providerSlug,
|
||||
model,
|
||||
persistGlobal: shouldPersistGlobal,
|
||||
});
|
||||
if (result?.confirm_required) {
|
||||
setPendingConfirm({
|
||||
provider: providerSlug,
|
||||
model,
|
||||
persistGlobal: shouldPersistGlobal,
|
||||
message:
|
||||
result.confirm_message ||
|
||||
result.warning ||
|
||||
"This model has unusually high known pricing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
} else if (gw && sessionId) {
|
||||
setApplying(true);
|
||||
try {
|
||||
const global = shouldPersistGlobal ? " --global" : "";
|
||||
const result = await gw.request<ConfigSetResponse>("config.set", {
|
||||
confirm_expensive_model: confirmExpensiveModel,
|
||||
key: "model",
|
||||
session_id: sessionId,
|
||||
value: `${model} --provider ${providerSlug}${global}`,
|
||||
});
|
||||
if (result?.confirm_required) {
|
||||
setPendingConfirm({
|
||||
provider: providerSlug,
|
||||
model,
|
||||
persistGlobal: shouldPersistGlobal,
|
||||
message:
|
||||
result.confirm_message ||
|
||||
result.warning ||
|
||||
"This model has unusually high known pricing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
|
@ -196,14 +268,17 @@ export function ModelPickerDialog(props: Props) {
|
|||
setApplying(false);
|
||||
}
|
||||
} else if (onSubmit) {
|
||||
const global = persistGlobal ? " --global" : "";
|
||||
onSubmit(
|
||||
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
|
||||
);
|
||||
const global = shouldPersistGlobal ? " --global" : "";
|
||||
onSubmit(`/model ${model} --provider ${providerSlug}${global}`);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (!canConfirm) return;
|
||||
void applySelection();
|
||||
};
|
||||
|
||||
// Portal to document.body: the main dashboard column in App.tsx is
|
||||
// `relative z-2`, which creates a stacking context that traps fixed
|
||||
// descendants below the app sidebar (z-50). Without the portal this
|
||||
|
|
@ -280,8 +355,12 @@ export function ModelPickerDialog(props: Props) {
|
|||
onSelect={setSelectedModel}
|
||||
onConfirm={(m) => {
|
||||
setSelectedModel(m);
|
||||
// Confirm on next tick so state settles.
|
||||
window.setTimeout(confirm, 0);
|
||||
void applySelection(false, {
|
||||
provider: selectedProvider?.slug ?? "",
|
||||
model: m,
|
||||
persistGlobal,
|
||||
message: "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -320,6 +399,22 @@ export function ModelPickerDialog(props: Props) {
|
|||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={!!pendingConfirm}
|
||||
title="Expensive Model Warning"
|
||||
description={pendingConfirm?.message}
|
||||
destructive
|
||||
confirmLabel="Switch anyway"
|
||||
cancelLabel="Cancel"
|
||||
loading={applying}
|
||||
onCancel={() => setPendingConfirm(null)}
|
||||
onConfirm={() => {
|
||||
const pending = pendingConfirm;
|
||||
if (!pending) return;
|
||||
setPendingConfirm(null);
|
||||
void applySelection(true, pending);
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
|
|
|||
30
web/src/components/ProfileScopeBanner.tsx
Normal file
30
web/src/components/ProfileScopeBanner.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Users } from "lucide-react";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/**
|
||||
* App-wide amber banner shown while the global switcher targets a profile
|
||||
* OTHER than the dashboard's own — every management write (config, keys,
|
||||
* skills, MCPs, model) and new Chat sessions land in that profile.
|
||||
*/
|
||||
export function ProfileScopeBanner() {
|
||||
const { profile, currentProfile } = useProfileScope();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!profile || profile === currentProfile) return null;
|
||||
|
||||
return (
|
||||
// mt-14 on mobile clears the fixed lg:hidden header (h-14, z-40) so the
|
||||
// scope banner — the main safety signal for scoped writes — is never
|
||||
// hidden behind it; lg:mt-0 restores desktop flow.
|
||||
<div className="mt-14 lg:mt-0 flex items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-1.5 text-xs text-amber-300">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{(
|
||||
t.app.managingProfileBanner ??
|
||||
"Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile."
|
||||
).replace("{name}", profile)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
web/src/components/ProfileSwitcher.tsx
Normal file
67
web/src/components/ProfileSwitcher.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Users } from "lucide-react";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The machine dashboard's single write-target selector.
|
||||
*
|
||||
* Rendered in the sidebar above the nav. Every management page (Config,
|
||||
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
|
||||
* fetchJSON ?profile= injection. Hidden when only one profile exists.
|
||||
*/
|
||||
export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
|
||||
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (profiles.length < 2) return null;
|
||||
|
||||
const managed = profile || currentProfile || "default";
|
||||
const isOther = !!profile && profile !== currentProfile;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
|
||||
collapsed && "lg:justify-center lg:px-0",
|
||||
)}
|
||||
title={t.app.managingProfile ?? "Managing profile"}
|
||||
>
|
||||
<Users
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isOther ? "text-amber-300" : "text-text-tertiary",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
aria-label={t.app.managingProfile ?? "Managing profile"}
|
||||
className={cn(
|
||||
"h-7 w-full min-w-0 rounded-none border bg-background px-1 text-xs",
|
||||
isOther
|
||||
? "border-amber-500/50 text-amber-300"
|
||||
: "border-border text-text-secondary",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
|
||||
"{name}",
|
||||
currentProfile || "default",
|
||||
)}
|
||||
</option>
|
||||
{profiles
|
||||
.filter((name) => name !== currentProfile)
|
||||
.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{collapsed && (
|
||||
<span className="sr-only">{managed}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
web/src/components/SkillEditorDialog.tsx
Normal file
215
web/src/components/SkillEditorDialog.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@nous-research/ui/ui/components/dialog";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SkillEditorDialog — create or edit a SKILL.md from the dashboard */
|
||||
/* */
|
||||
/* Headless/VPS users have no editor besides this: the only other way */
|
||||
/* to author a custom skill is SSH + a terminal editor. Create mode */
|
||||
/* posts a brand-new skill (name + optional category + SKILL.md); */
|
||||
/* edit mode loads the existing SKILL.md raw text and rewrites it. */
|
||||
/* Validation (frontmatter, name, size) happens server-side via the */
|
||||
/* same path the agent's skill_manage tool uses, so errors come back */
|
||||
/* as actionable messages rendered inline. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const CREATE_TEMPLATE = `---
|
||||
name: my-skill
|
||||
description: One-line description of when to use this skill.
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
Numbered steps, exact commands, and pitfalls go here.
|
||||
`;
|
||||
|
||||
export interface SkillEditorDialogProps {
|
||||
open: boolean;
|
||||
/** Skill name to edit, or null for create mode. */
|
||||
editName: string | null;
|
||||
/** Profile to scope reads/writes to ("" = the dashboard's own profile). */
|
||||
profile?: string;
|
||||
onClose: () => void;
|
||||
/** Called after a successful save so the page can refresh its list. */
|
||||
onSaved: (name: string) => void;
|
||||
}
|
||||
|
||||
export function SkillEditorDialog({
|
||||
open,
|
||||
editName,
|
||||
profile,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: SkillEditorDialogProps) {
|
||||
// The body is remounted via `key` every time the dialog opens or the
|
||||
// target skill changes, so all form state initializes through useState
|
||||
// initializers — no reset-on-open effect (react-hooks/set-state-in-effect).
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
{open && (
|
||||
<EditorBody
|
||||
key={editName ?? "__create__"}
|
||||
editName={editName}
|
||||
profile={profile}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorBody({
|
||||
editName,
|
||||
profile,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: Omit<SkillEditorDialogProps, "open">) {
|
||||
const isEdit = editName !== null;
|
||||
const [name, setName] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [content, setContent] = useState(isEdit ? "" : CREATE_TEMPLATE);
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editName) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSkillContent(editName, profile || undefined)
|
||||
.then((res) => !cancelled && setContent(res.content))
|
||||
.catch((e) => !cancelled && setError(String(e)))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [editName, profile]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
if (!isEdit && !name.trim()) {
|
||||
setError("Skill name is required.");
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError("SKILL.md content is required.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.updateSkillContent(editName, content, profile || undefined);
|
||||
onSaved(editName);
|
||||
} else {
|
||||
const trimmed = name.trim();
|
||||
await api.createSkill(
|
||||
{
|
||||
name: trimmed,
|
||||
content,
|
||||
category: category.trim() || undefined,
|
||||
},
|
||||
profile || undefined,
|
||||
);
|
||||
onSaved(trimmed);
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? `Edit skill: ${editName}` : "New skill"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Rewrite this skill's SKILL.md. Frontmatter (name, description) is validated on save."
|
||||
: "Author a custom skill — YAML frontmatter plus markdown instructions. It becomes available to the agent and attachable to cron jobs."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{!isEdit && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="skill-editor-name">Name</Label>
|
||||
<Input
|
||||
id="skill-editor-name"
|
||||
autoFocus
|
||||
placeholder="my-skill"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="skill-editor-category">Category (optional)</Label>
|
||||
<Input
|
||||
id="skill-editor-category"
|
||||
placeholder="devops"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="skill-editor-content">SKILL.md</Label>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="text-xl text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
id="skill-editor-content"
|
||||
spellCheck={false}
|
||||
className="min-h-[320px] max-h-[55vh] w-full resize-y border border-border bg-background/40 px-3 py-2 font-mono text-xs leading-relaxed shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="whitespace-pre-wrap text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button ghost size="sm" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
prefix={saving ? <Spinner /> : undefined}
|
||||
>
|
||||
{saving ? "Saving…" : isEdit ? "Save changes" : "Create skill"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,9 @@ import { cn, themedBody } from "@/lib/utils";
|
|||
interface Props {
|
||||
/** The toolset whose backends are being configured. */
|
||||
toolset: ToolsetInfo;
|
||||
/** Optional profile to scope config reads/writes to (Skills page profile
|
||||
* selector). Omitted = the dashboard process's own profile. */
|
||||
profile?: string;
|
||||
onClose: () => void;
|
||||
/** Called after a toggle/provider/key change so the parent grid refreshes. */
|
||||
onChanged: () => void;
|
||||
|
|
@ -31,7 +34,7 @@ interface Props {
|
|||
* the toolset on/off, pick a provider, enter API keys, and run a provider's
|
||||
* post-setup install hook (npm/pip/binary) with a live log tail.
|
||||
*/
|
||||
export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
|
||||
export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Props) {
|
||||
const { toast, showToast } = useToast();
|
||||
const [config, setConfig] = useState<ToolsetConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -60,7 +63,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
|
|||
// react-hooks/set-state-in-effect — setState only fires inside the
|
||||
// async .then/.catch/.finally callbacks.
|
||||
return api
|
||||
.getToolsetConfig(toolset.name)
|
||||
.getToolsetConfig(toolset.name, profile)
|
||||
.then((cfg) => {
|
||||
setConfig(cfg);
|
||||
setActiveProvider(cfg.active_provider);
|
||||
|
|
@ -72,7 +75,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
|
|||
})
|
||||
.catch(() => showToast("Failed to load toolset config", "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [toolset.name, showToast]);
|
||||
}, [toolset.name, profile, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
|
|
@ -121,7 +124,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
|
|||
const handleToggle = async (next: boolean) => {
|
||||
setToggling(true);
|
||||
try {
|
||||
await api.toggleToolset(toolset.name, next);
|
||||
await api.toggleToolset(toolset.name, next, profile);
|
||||
setEnabled(next);
|
||||
showToast(
|
||||
`${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`,
|
||||
|
|
@ -138,7 +141,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
|
|||
const handleSelectProvider = async (provider: ToolsetProvider) => {
|
||||
setSelecting(provider.name);
|
||||
try {
|
||||
await api.selectToolsetProvider(toolset.name, provider.name);
|
||||
await api.selectToolsetProvider(toolset.name, provider.name, profile);
|
||||
setActiveProvider(provider.name);
|
||||
showToast(`Provider set to ${provider.name}`, "success");
|
||||
onChanged();
|
||||
|
|
@ -164,7 +167,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
|
|||
}
|
||||
setSavingProvider(provider.name);
|
||||
try {
|
||||
const res = await api.saveToolsetEnv(toolset.name, env);
|
||||
const res = await api.saveToolsetEnv(toolset.name, env, profile);
|
||||
setIsSet((prev) => ({ ...prev, ...res.is_set }));
|
||||
// Clear saved drafts so the inputs reset to the "saved" placeholder.
|
||||
setDrafts((prev) => {
|
||||
|
|
@ -195,7 +198,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
|
|||
setPostSetupLog([]);
|
||||
setPostSetupKey(provider.post_setup);
|
||||
try {
|
||||
await api.runToolsetPostSetup(toolset.name, provider.post_setup);
|
||||
await api.runToolsetPostSetup(toolset.name, provider.post_setup, profile);
|
||||
// Bump the trigger so the poll effect (re)starts tailing the log.
|
||||
setPostSetupTrigger((n) => n + 1);
|
||||
} catch (e) {
|
||||
|
|
|
|||
115
web/src/contexts/ProfileProvider.tsx
Normal file
115
web/src/contexts/ProfileProvider.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { api, setManagementProfile } from "@/lib/api";
|
||||
import { ProfileContext } from "@/contexts/profile-context";
|
||||
|
||||
/**
|
||||
* Machine-level management-profile scope.
|
||||
*
|
||||
* One switcher (rendered in the sidebar) decides which profile every
|
||||
* management page reads/writes. React STATE is the source of truth; the
|
||||
* URL (`?profile=<name>`) is a synchronized projection of it so deep links
|
||||
* land scoped and refresh survives. The selection is mirrored into the api
|
||||
* module so `fetchJSON` transparently appends it to the profile-scoped
|
||||
* endpoint families. "" = the dashboard's own profile.
|
||||
*
|
||||
* Why state-first instead of URL-first: sidebar nav links are bare paths
|
||||
* (`/config`, `/skills`). A URL-derived scope would silently reset to the
|
||||
* dashboard's own profile on every nav click — the switcher would LOOK
|
||||
* global while normal navigation dropped the write target. With state as
|
||||
* truth, the effect below re-asserts `?profile=` onto the new location
|
||||
* after each navigation, so the scope survives nav and stays deep-linkable.
|
||||
*
|
||||
* This exists because "Set as active" on the Profiles page only flips the
|
||||
* sticky active_profile file (future CLI/gateway runs) — it cannot retarget
|
||||
* the running dashboard. The switcher is the dashboard's own, visible,
|
||||
* write-target selector.
|
||||
*/
|
||||
export function ProfileProvider({ children }: { children: ReactNode }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
const [profiles, setProfiles] = useState<string[]>([]);
|
||||
const [currentProfile, setCurrentProfile] = useState("default");
|
||||
|
||||
// Initial value comes from the URL (deep link / refresh / unified-launch
|
||||
// preselect); afterwards state leads and the URL follows.
|
||||
const [profile, setProfileState] = useState(
|
||||
() => searchParams.get("profile") ?? "",
|
||||
);
|
||||
|
||||
// Mirror into the api module synchronously on every render where it
|
||||
// changed, so fetches fired by child effects in the same commit see it.
|
||||
setManagementProfile(profile);
|
||||
|
||||
// A profile param arriving via in-app navigation (e.g. the Profiles
|
||||
// page's "Manage skills & tools" linking to /skills?profile=X) must win
|
||||
// over current state — it's an explicit scope request.
|
||||
const urlProfile = searchParams.get("profile");
|
||||
useEffect(() => {
|
||||
if (urlProfile !== null && urlProfile !== profile) {
|
||||
setManagementProfile(urlProfile);
|
||||
setProfileState(urlProfile);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlProfile]);
|
||||
|
||||
// Re-assert ?profile= after navigations that dropped it (bare nav links).
|
||||
// Runs on every pathname/profile change; no-ops when already in sync.
|
||||
useEffect(() => {
|
||||
const inUrl = searchParams.get("profile") ?? "";
|
||||
if ((profile || "") === inUrl) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (profile) next.set("profile", profile);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getProfiles()
|
||||
.then((res) => setProfiles(res.profiles.map((p) => p.name)))
|
||||
.catch(() => {});
|
||||
api
|
||||
.getActiveProfile()
|
||||
.then((info) => setCurrentProfile(info.current || "default"))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const setProfile = useCallback(
|
||||
(name: string) => {
|
||||
setManagementProfile(name);
|
||||
setProfileState(name);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (name) next.set("profile", name);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ profile, currentProfile, profiles, setProfile }),
|
||||
[profile, currentProfile, profiles, setProfile],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
|
||||
);
|
||||
}
|
||||
19
web/src/contexts/profile-context.ts
Normal file
19
web/src/contexts/profile-context.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createContext } from "react";
|
||||
|
||||
export interface ProfileContextValue {
|
||||
/** Profile every management surface reads/writes ("" = the dashboard
|
||||
* process's own profile). */
|
||||
profile: string;
|
||||
/** The profile the dashboard process itself runs under. */
|
||||
currentProfile: string;
|
||||
/** Known profile names (includes "default"). */
|
||||
profiles: string[];
|
||||
setProfile: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ProfileContext = createContext<ProfileContextValue>({
|
||||
profile: "",
|
||||
currentProfile: "default",
|
||||
profiles: [],
|
||||
setProfile: () => {},
|
||||
});
|
||||
6
web/src/contexts/useProfileScope.ts
Normal file
6
web/src/contexts/useProfileScope.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { useContext } from "react";
|
||||
import { ProfileContext } from "@/contexts/profile-context";
|
||||
|
||||
export function useProfileScope() {
|
||||
return useContext(ProfileContext);
|
||||
}
|
||||
|
|
@ -93,6 +93,10 @@ export const en: Translations = {
|
|||
statusOverview: "Status overview",
|
||||
system: "System",
|
||||
webUi: "Web UI",
|
||||
managingProfile: "Managing profile",
|
||||
currentProfileOption: "this dashboard ({name})",
|
||||
managingProfileBanner:
|
||||
"Managing profile \u201c{name}\u201d \u2014 config, keys, skills, MCPs, model, and new chats apply to that profile.",
|
||||
},
|
||||
|
||||
status: {
|
||||
|
|
@ -362,7 +366,7 @@ export const en: Translations = {
|
|||
inactive: "inactive",
|
||||
installBtn: "Install",
|
||||
installHeading: "Install from GitHub / Git URL",
|
||||
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
|
||||
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL. For a plugin in a subdirectory, append the path: owner/repo/path/to/plugin (or <url>#path/to/plugin).",
|
||||
memoryProviderLabel: "Memory provider",
|
||||
missingEnvWarn: "Set these in Keys before the plugin can run:",
|
||||
noDashboardTab: "No dashboard tab",
|
||||
|
|
@ -408,6 +412,10 @@ export const en: Translations = {
|
|||
setupNeeded: "Setup needed",
|
||||
disabledForCli: "Disabled for CLI",
|
||||
more: "+{count} more",
|
||||
profileSelector: "Profile",
|
||||
currentProfile: "current ({name})",
|
||||
managingProfile:
|
||||
"Managing profile \u201c{name}\u201d — toggles apply to that profile, not this dashboard\u2019s.",
|
||||
},
|
||||
|
||||
config: {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,10 @@ export interface Translations {
|
|||
statusOverview: string;
|
||||
system: string;
|
||||
webUi: string;
|
||||
/** Optional — fall back to English literals until translated. */
|
||||
managingProfile?: string;
|
||||
currentProfileOption?: string;
|
||||
managingProfileBanner?: string;
|
||||
};
|
||||
|
||||
// ── Status page ──
|
||||
|
|
@ -404,6 +408,8 @@ export interface Translations {
|
|||
modelSaved?: string;
|
||||
modelSelect?: string;
|
||||
actions?: string;
|
||||
manageSkills?: string;
|
||||
activeSetHint?: string;
|
||||
};
|
||||
|
||||
// ── Skills page ──
|
||||
|
|
@ -425,6 +431,10 @@ export interface Translations {
|
|||
setupNeeded: string;
|
||||
disabledForCli: string;
|
||||
more: string;
|
||||
/** Optional — fall back to English literals until translated. */
|
||||
profileSelector?: string;
|
||||
currentProfile?: string;
|
||||
managingProfile?: string;
|
||||
};
|
||||
|
||||
// ── Config page ──
|
||||
|
|
|
|||
|
|
@ -41,11 +41,54 @@ function setSessionHeader(headers: Headers, token: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Global management-profile scope ──────────────────────────────────
|
||||
// The dashboard is a machine-level management surface: one header switcher
|
||||
// (ProfileProvider in App.tsx) decides which profile the management pages
|
||||
// read/write, and fetchJSON transparently appends ?profile=<name> to the
|
||||
// profile-scoped endpoint families below. "" = the dashboard process's own
|
||||
// profile (legacy behavior). Calls that already carry an explicit profile
|
||||
// (e.g. ProfileBuilder writes) are left untouched — explicit beats global.
|
||||
let _managementProfile = "";
|
||||
|
||||
export function setManagementProfile(name: string): void {
|
||||
_managementProfile = (name || "").trim();
|
||||
}
|
||||
|
||||
export function getManagementProfile(): string {
|
||||
return _managementProfile;
|
||||
}
|
||||
|
||||
// Endpoint families that honor ?profile= on the backend (web_server.py
|
||||
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
|
||||
// channels, cron (which has its own per-job profile params), profiles
|
||||
// themselves — is machine-global or self-scoped and must NOT be rewritten.
|
||||
const PROFILE_SCOPED_PREFIXES = [
|
||||
"/api/skills",
|
||||
"/api/tools/toolsets",
|
||||
"/api/config",
|
||||
"/api/env",
|
||||
"/api/mcp",
|
||||
"/api/model/info",
|
||||
"/api/model/set",
|
||||
"/api/model/auxiliary",
|
||||
"/api/model/options",
|
||||
];
|
||||
|
||||
function withManagementProfile(url: string): string {
|
||||
if (!_managementProfile) return url;
|
||||
if (url.includes("profile=")) return url; // explicit param wins
|
||||
const path = url.split("?")[0];
|
||||
if (!PROFILE_SCOPED_PREFIXES.some((p) => path.startsWith(p))) return url;
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}profile=${encodeURIComponent(_managementProfile)}`;
|
||||
}
|
||||
|
||||
export async function fetchJSON<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: FetchJSONOptions,
|
||||
): Promise<T> {
|
||||
url = withManagementProfile(url);
|
||||
// Inject the session token into all /api/ requests.
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
|
|
@ -249,6 +292,14 @@ export async function buildWsUrl(
|
|||
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
|
||||
}
|
||||
|
||||
/** Build a ``?profile=<name>`` query suffix, or "" when unset.
|
||||
*
|
||||
* Used by the skills/toolsets endpoints so the dashboard can manage a
|
||||
* profile other than the one the server process runs under. */
|
||||
function profileQuery(profile?: string): string {
|
||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
/**
|
||||
|
|
@ -325,6 +376,32 @@ export const api = {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ older_than_days, source }),
|
||||
}),
|
||||
listFiles: (path?: string) => {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
return fetchJSON<ManagedFilesResponse>(`/api/files${query}`);
|
||||
},
|
||||
readFile: (path: string) =>
|
||||
fetchJSON<ManagedFileReadResponse>(
|
||||
`/api/files/read?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
uploadFile: (path: string, dataUrl: string, overwrite = true) =>
|
||||
fetchJSON<ManagedFileWriteResponse>("/api/files/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, data_url: dataUrl, overwrite }),
|
||||
}),
|
||||
createDirectory: (path: string) =>
|
||||
fetchJSON<ManagedFileWriteResponse>("/api/files/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
deleteFile: (path: string, recursive = false) =>
|
||||
fetchJSON<{ ok: boolean; path: string }>("/api/files", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, recursive }),
|
||||
}),
|
||||
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.file) qs.set("file", params.file);
|
||||
|
|
@ -355,7 +432,7 @@ export const api = {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
}),
|
||||
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
|
||||
getConfigRaw: () => fetchJSON<{ yaml: string; path?: string }>("/api/config/raw"),
|
||||
saveConfigRaw: (yaml_text: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/config/raw", {
|
||||
method: "PUT",
|
||||
|
|
@ -392,7 +469,7 @@ export const api = {
|
|||
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
|
||||
getCronDeliveryTargets: () =>
|
||||
fetchJSON<{ targets: CronDeliveryTarget[] }>("/api/cron/delivery-targets"),
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") =>
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string; skills?: string[] }, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -402,7 +479,7 @@ export const api = {
|
|||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||
updateCronJob: (
|
||||
id: string,
|
||||
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string },
|
||||
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string; skills?: string[] },
|
||||
profile = "default",
|
||||
) =>
|
||||
fetchJSON<CronJob>(
|
||||
|
|
@ -420,6 +497,19 @@ export const api = {
|
|||
deleteCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
|
||||
|
||||
// Automation Blueprints — parameterized automation blueprints
|
||||
getAutomationBlueprints: () =>
|
||||
fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"),
|
||||
instantiateAutomationBlueprint: (
|
||||
body: { blueprint: string; values: Record<string, string> },
|
||||
profile = "default",
|
||||
) =>
|
||||
fetchJSON<CronJob>(`/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
// Profiles
|
||||
getProfiles: () =>
|
||||
fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"),
|
||||
|
|
@ -439,8 +529,19 @@ export const api = {
|
|||
description?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mcp_servers?: McpServerCreate[];
|
||||
keep_skills?: string[];
|
||||
hub_skills?: string[];
|
||||
}) =>
|
||||
fetchJSON<{ ok: boolean; name: string; path: string; model_set?: boolean }>("/api/profiles", {
|
||||
fetchJSON<{
|
||||
ok: boolean;
|
||||
name: string;
|
||||
path: string;
|
||||
model_set?: boolean;
|
||||
mcp_written?: number;
|
||||
skills_disabled?: number;
|
||||
hub_installs?: Array<{ identifier: string; pid: number | null }>;
|
||||
}>("/api/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
|
|
@ -505,52 +606,74 @@ export const api = {
|
|||
),
|
||||
|
||||
// Skills & Toolsets
|
||||
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
|
||||
toggleSkill: (name: string, enabled: boolean) =>
|
||||
//
|
||||
// All calls accept an optional ``profile`` so the Skills page can manage
|
||||
// any profile's skills/toolsets — not just the one the dashboard process
|
||||
// runs under. Omitted/empty profile = the dashboard's own profile.
|
||||
getSkills: (profile?: string) =>
|
||||
fetchJSON<SkillInfo[]>(`/api/skills${profileQuery(profile)}`),
|
||||
toggleSkill: (name: string, enabled: boolean, profile?: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, enabled }),
|
||||
body: JSON.stringify({ name, enabled, profile: profile || undefined }),
|
||||
}),
|
||||
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
|
||||
toggleToolset: (name: string, enabled: boolean) =>
|
||||
getSkillContent: (name: string, profile?: string) =>
|
||||
fetchJSON<SkillContent>(
|
||||
`/api/skills/content?name=${encodeURIComponent(name)}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`,
|
||||
),
|
||||
createSkill: (skill: { name: string; content: string; category?: string }, profile?: string) =>
|
||||
fetchJSON<SkillWriteResult>("/api/skills", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...skill, profile: profile || undefined }),
|
||||
}),
|
||||
updateSkillContent: (name: string, content: string, profile?: string) =>
|
||||
fetchJSON<SkillWriteResult>("/api/skills/content", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, content, profile: profile || undefined }),
|
||||
}),
|
||||
getToolsets: (profile?: string) =>
|
||||
fetchJSON<ToolsetInfo[]>(`/api/tools/toolsets${profileQuery(profile)}`),
|
||||
toggleToolset: (name: string, enabled: boolean, profile?: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
body: JSON.stringify({ enabled, profile: profile || undefined }),
|
||||
},
|
||||
),
|
||||
getToolsetConfig: (name: string) =>
|
||||
getToolsetConfig: (name: string, profile?: string) =>
|
||||
fetchJSON<ToolsetConfig>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/config`,
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/config${profileQuery(profile)}`,
|
||||
),
|
||||
selectToolsetProvider: (name: string, provider: string) =>
|
||||
selectToolsetProvider: (name: string, provider: string, profile?: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string; provider: string }>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider }),
|
||||
body: JSON.stringify({ provider, profile: profile || undefined }),
|
||||
},
|
||||
),
|
||||
saveToolsetEnv: (name: string, env: Record<string, string>) =>
|
||||
saveToolsetEnv: (name: string, env: Record<string, string>, profile?: string) =>
|
||||
fetchJSON<ToolsetEnvResult>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/env`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env }),
|
||||
body: JSON.stringify({ env, profile: profile || undefined }),
|
||||
},
|
||||
),
|
||||
runToolsetPostSetup: (name: string, key: string) =>
|
||||
runToolsetPostSetup: (name: string, key: string, profile?: string) =>
|
||||
fetchJSON<ActionResponse & { key: string }>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
body: JSON.stringify({ key, profile: profile || undefined }),
|
||||
},
|
||||
),
|
||||
|
||||
|
|
@ -815,6 +938,8 @@ export const api = {
|
|||
|
||||
// ── Admin: Webhooks ─────────────────────────────────────────────────
|
||||
getWebhooks: () => fetchJSON<WebhooksResponse>("/api/webhooks"),
|
||||
enableWebhooks: () =>
|
||||
fetchJSON<WebhookEnableResponse>("/api/webhooks/enable", { method: "POST" }),
|
||||
createWebhook: (body: WebhookCreate) =>
|
||||
fetchJSON<WebhookRoute & { secret: string }>("/api/webhooks", {
|
||||
method: "POST",
|
||||
|
|
@ -889,11 +1014,11 @@ export const api = {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ output }),
|
||||
}),
|
||||
runImport: (archive: string) =>
|
||||
runImport: (archive: string, force = false) =>
|
||||
fetchJSON<ActionResponse>("/api/ops/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ archive }),
|
||||
body: JSON.stringify({ archive, force }),
|
||||
}),
|
||||
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
|
||||
createHook: (body: HookCreate) =>
|
||||
|
|
@ -949,26 +1074,34 @@ export const api = {
|
|||
fetchJSON<ActionResponse>("/api/ops/checkpoints/prune", { method: "POST" }),
|
||||
|
||||
// ── Admin: Skills hub ───────────────────────────────────────────────
|
||||
installSkillFromHub: (identifier: string) =>
|
||||
// ``profile`` scopes install/uninstall/update and the installed-state
|
||||
// annotations to that profile (omitted = the dashboard's own profile).
|
||||
installSkillFromHub: (identifier: string, profile?: string) =>
|
||||
fetchJSON<ActionResponse>("/api/skills/hub/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identifier }),
|
||||
body: JSON.stringify({ identifier, profile: profile || undefined }),
|
||||
}),
|
||||
uninstallSkillFromHub: (name: string) =>
|
||||
uninstallSkillFromHub: (name: string, profile?: string) =>
|
||||
fetchJSON<ActionResponse>("/api/skills/hub/uninstall", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
body: JSON.stringify({ name, profile: profile || undefined }),
|
||||
}),
|
||||
updateSkillsFromHub: () =>
|
||||
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
|
||||
searchSkillsHub: (q: string, source = "all", limit = 20) =>
|
||||
updateSkillsFromHub: (profile?: string) =>
|
||||
fetchJSON<ActionResponse>("/api/skills/hub/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile: profile || undefined }),
|
||||
}),
|
||||
searchSkillsHub: (q: string, source = "all", limit = 20, profile?: string) =>
|
||||
fetchJSON<SkillHubSearchResponse>(
|
||||
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`,
|
||||
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`,
|
||||
),
|
||||
getSkillHubSources: (profile?: string) =>
|
||||
fetchJSON<SkillHubSourcesResponse>(
|
||||
`/api/skills/hub/sources${profileQuery(profile)}`,
|
||||
),
|
||||
getSkillHubSources: () =>
|
||||
fetchJSON<SkillHubSourcesResponse>("/api/skills/hub/sources"),
|
||||
previewSkillFromHub: (identifier: string) =>
|
||||
fetchJSON<SkillHubPreview>(
|
||||
`/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
|
||||
|
|
@ -1229,6 +1362,17 @@ export interface WebhooksResponse {
|
|||
subscriptions: WebhookRoute[];
|
||||
}
|
||||
|
||||
export interface WebhookEnableResponse {
|
||||
ok: boolean;
|
||||
platform: "webhook";
|
||||
enabled: true;
|
||||
needs_restart: boolean;
|
||||
restart_started?: boolean;
|
||||
restart_action?: string;
|
||||
restart_pid?: number | null;
|
||||
restart_error?: string;
|
||||
}
|
||||
|
||||
export interface WebhookCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
|
|
@ -1475,7 +1619,11 @@ export interface TelegramOnboardingApplyResponse {
|
|||
ok: boolean;
|
||||
platform: "telegram";
|
||||
bot_username?: string;
|
||||
needs_restart: true;
|
||||
needs_restart: boolean;
|
||||
restart_started?: boolean;
|
||||
restart_action?: string;
|
||||
restart_pid?: number | null;
|
||||
restart_error?: string;
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
|
|
@ -1500,6 +1648,44 @@ export interface LogsResponse {
|
|||
lines: string[];
|
||||
}
|
||||
|
||||
export interface ManagedFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
size: number | null;
|
||||
mtime: number;
|
||||
mime_type: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedFilesResponse {
|
||||
root: string | null;
|
||||
path: string;
|
||||
parent: string | null;
|
||||
locked_root: string | null;
|
||||
can_change_path: boolean;
|
||||
entries: ManagedFileEntry[];
|
||||
}
|
||||
|
||||
export interface ManagedFileReadResponse {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
data_url: string;
|
||||
root: string | null;
|
||||
locked_root: string | null;
|
||||
can_change_path: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedFileWriteResponse {
|
||||
ok: boolean;
|
||||
path: string;
|
||||
entry: ManagedFileEntry;
|
||||
root: string | null;
|
||||
locked_root: string | null;
|
||||
can_change_path: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsDailyEntry {
|
||||
day: string;
|
||||
input_tokens: number;
|
||||
|
|
@ -1634,6 +1820,7 @@ export interface CronJob {
|
|||
name?: string | null;
|
||||
prompt?: string | null;
|
||||
script?: string | null;
|
||||
skills?: string[] | null;
|
||||
schedule?: { kind?: string; expr?: string; display?: string };
|
||||
schedule_display?: string | null;
|
||||
enabled: boolean;
|
||||
|
|
@ -1651,6 +1838,29 @@ export interface CronDeliveryTarget {
|
|||
home_env_var: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationBlueprintField {
|
||||
name: string;
|
||||
type: "time" | "enum" | "text" | "weekdays";
|
||||
label: string;
|
||||
default: string | null;
|
||||
options: string[];
|
||||
optional: boolean;
|
||||
/** When false, options are suggestions — any value is accepted. */
|
||||
strict?: boolean;
|
||||
help: string;
|
||||
}
|
||||
|
||||
export interface AutomationBlueprint {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
fields: AutomationBlueprintField[];
|
||||
command: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
|
|
@ -1658,6 +1868,19 @@ export interface SkillInfo {
|
|||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SkillContent {
|
||||
name: string;
|
||||
content: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SkillWriteResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ToolsetInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
|
|
@ -1763,9 +1986,12 @@ export interface AuxiliaryModelsResponse {
|
|||
}
|
||||
|
||||
export interface ModelAssignmentRequest {
|
||||
confirm_expensive_model?: boolean;
|
||||
scope: "main" | "auxiliary";
|
||||
provider: string;
|
||||
model: string;
|
||||
/** Optional OpenAI-compatible endpoint URL for custom/local main providers. */
|
||||
base_url?: string;
|
||||
/** For auxiliary: task slot name, "" for all, "__reset__" to reset all. */
|
||||
task?: string;
|
||||
}
|
||||
|
|
@ -1779,6 +2005,8 @@ export interface StaleAuxAssignment {
|
|||
}
|
||||
|
||||
export interface ModelAssignmentResponse {
|
||||
confirm_message?: string;
|
||||
confirm_required?: boolean;
|
||||
ok: boolean;
|
||||
scope?: string;
|
||||
provider?: string;
|
||||
|
|
|
|||
|
|
@ -599,6 +599,32 @@ function TelegramOnboardingPanel({
|
|||
setNewAllowedId("");
|
||||
};
|
||||
|
||||
// restart_started only means the `hermes gateway restart` child spawned —
|
||||
// not that the restart will succeed (e.g. systemd linger missing, service
|
||||
// manager failure). Poll the action status briefly and surface a non-zero
|
||||
// exit via the manual-restart banner. Note: in no-service installs the
|
||||
// child becomes the foreground gateway and never exits, so "still running
|
||||
// when the window closes" counts as success.
|
||||
const watchRestartOutcome = async () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
try {
|
||||
const st = await api.getActionStatus("gateway-restart", 5);
|
||||
if (st.running) continue;
|
||||
if (st.exit_code !== 0 && st.exit_code !== null) {
|
||||
onRestartNeeded();
|
||||
showToast(
|
||||
`Gateway restart failed (exit ${st.exit_code}) — restart manually`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
// transient fetch error; keep polling
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
if (!setup) return;
|
||||
if (allowedIds.length === 0) {
|
||||
|
|
@ -608,19 +634,29 @@ function TelegramOnboardingPanel({
|
|||
setPhase("applying");
|
||||
setError("");
|
||||
try {
|
||||
await api.applyTelegramOnboarding(setup.pairing_id, {
|
||||
const result = await api.applyTelegramOnboarding(setup.pairing_id, {
|
||||
allowed_user_ids: allowedIds,
|
||||
});
|
||||
resetSetup();
|
||||
showToast("Telegram saved", "success");
|
||||
try {
|
||||
await api.restartGateway();
|
||||
showToast("Gateway restarting…", "success");
|
||||
if (result.restart_started) {
|
||||
showToast("Telegram saved; gateway restarting…", "success");
|
||||
setRestartNeeded(false);
|
||||
setTimeout(() => void onChanged(), 4000);
|
||||
} catch (restartError) {
|
||||
void watchRestartOutcome();
|
||||
} else if (result.restart_started === undefined && result.needs_restart) {
|
||||
try {
|
||||
await api.restartGateway();
|
||||
showToast("Telegram saved; gateway restarting…", "success");
|
||||
setRestartNeeded(false);
|
||||
setTimeout(() => void onChanged(), 4000);
|
||||
} catch (restartError) {
|
||||
onRestartNeeded();
|
||||
showToast(`Telegram saved; gateway restart failed: ${restartError}`, "error");
|
||||
}
|
||||
} else {
|
||||
onRestartNeeded();
|
||||
showToast(`Telegram saved; restart failed: ${restartError}`, "error");
|
||||
const detail = result.restart_error ? `: ${result.restart_error}` : "";
|
||||
showToast(`Telegram saved; gateway restart failed${detail}`, "error");
|
||||
}
|
||||
await onChanged();
|
||||
} catch (applyError) {
|
||||
|
|
|
|||
|
|
@ -37,11 +37,13 @@ import { useI18n } from "@/i18n";
|
|||
import { api } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
|
||||
function buildWsUrl(
|
||||
authParam: [string, string],
|
||||
resume: string | null,
|
||||
channel: string,
|
||||
profile: string,
|
||||
): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// ``authParam`` is ``["token", <session>]`` in loopback mode and
|
||||
|
|
@ -49,6 +51,10 @@ function buildWsUrl(
|
|||
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
|
||||
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
|
||||
if (resume) qs.set("resume", resume);
|
||||
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
|
||||
// selected profile, so the conversation runs with that profile's model,
|
||||
// skills, memory, and sessions (see web_server._resolve_chat_argv).
|
||||
if (profile) qs.set("profile", profile);
|
||||
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +179,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
// treat the current resume target as part of the PTY identity and rebuild the
|
||||
// terminal session when it changes.
|
||||
const resumeParam = searchParams.get("resume");
|
||||
const channel = useMemo(() => generateChannelId(), [resumeParam]);
|
||||
// Profile-scoped chat: spawn the PTY under the globally selected
|
||||
// management profile. Changing it remounts the terminal (key below /
|
||||
// effect dep) so the user explicitly starts a fresh scoped session.
|
||||
const { profile: scopedProfile } = useProfileScope();
|
||||
const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumeParam) return;
|
||||
|
|
@ -576,7 +586,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
void (async () => {
|
||||
const authParam = await buildWsAuthParam();
|
||||
if (unmounting) return;
|
||||
const url = buildWsUrl(authParam, resumeParam, channel);
|
||||
const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
|
@ -714,7 +724,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
copyResetRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [channel, resumeParam]);
|
||||
}, [channel, resumeParam, scopedProfile]);
|
||||
|
||||
// When the user returns to the chat tab (isActive: false → true), the
|
||||
// terminal host just transitioned from display:none to display:flex.
|
||||
|
|
|
|||
|
|
@ -177,9 +177,19 @@ export default function ConfigPage() {
|
|||
.getDefaults()
|
||||
.then(setDefaults)
|
||||
.catch(() => {});
|
||||
// getConfigRaw is profile-scoped (fetchJSON appends ?profile=), so its
|
||||
// `path` reflects the switched profile's config.yaml. /api/status's
|
||||
// config_path is machine-global (the dashboard's own profile) — wrong
|
||||
// header under the global profile switcher, so it's only a fallback.
|
||||
api
|
||||
.getConfigRaw()
|
||||
.then((resp) => {
|
||||
if (resp.path) setConfigPath(resp.path);
|
||||
})
|
||||
.catch(() => {});
|
||||
api
|
||||
.getStatus()
|
||||
.then((resp) => setConfigPath(resp.config_path))
|
||||
.then((resp) => setConfigPath((prev) => prev ?? resp.config_path))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
|||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob, CronDeliveryTarget, ProfileInfo } from "@/lib/api";
|
||||
import type { CronJob, CronDeliveryTarget, ProfileInfo, SkillInfo } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import {
|
||||
DEFAULT_SCHEDULE_STATE,
|
||||
|
|
@ -29,6 +29,8 @@ import { Label } from "@nous-research/ui/ui/components/label";
|
|||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { Segmented } from "@nous-research/ui/ui/components/segmented";
|
||||
import { AutomationBlueprints } from "@/components/AutomationBlueprints";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
|
|
@ -51,6 +53,63 @@ function getJobPrompt(job: CronJob): string {
|
|||
return asText(job.prompt);
|
||||
}
|
||||
|
||||
/** Compact multi-select for attaching skills to a cron job.
|
||||
*
|
||||
* A checkbox list (native inputs — the `onValueChange` rule is Select-only)
|
||||
* capped to a scrollable box. Skills already on the job but missing from the
|
||||
* available list (e.g. removed from disk, or the job was created via CLI in
|
||||
* another profile) are still rendered so saving doesn't silently drop them.
|
||||
*/
|
||||
function SkillsPicker({
|
||||
id,
|
||||
available,
|
||||
selected,
|
||||
onChange,
|
||||
emptyLabel,
|
||||
}: {
|
||||
id: string;
|
||||
available: SkillInfo[];
|
||||
selected: string[];
|
||||
onChange: (skills: string[]) => void;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const names = available.map((s) => s.name);
|
||||
const orphaned = selected.filter((s) => !names.includes(s));
|
||||
const all = [...orphaned.map((name) => ({ name, description: "" })), ...available];
|
||||
|
||||
if (all.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground">{emptyLabel}</p>;
|
||||
}
|
||||
|
||||
const toggle = (name: string, checked: boolean) => {
|
||||
if (checked) onChange([...selected, name]);
|
||||
else onChange(selected.filter((s) => s !== name));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="max-h-36 overflow-y-auto border border-border bg-background/40 p-1"
|
||||
>
|
||||
{all.map((skill) => (
|
||||
<label
|
||||
key={skill.name}
|
||||
className="flex cursor-pointer items-center gap-2 px-2 py-1 text-xs hover:bg-muted/40"
|
||||
title={skill.description || undefined}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-foreground"
|
||||
checked={selected.includes(skill.name)}
|
||||
onChange={(e) => toggle(skill.name, e.target.checked)}
|
||||
/>
|
||||
<span className="font-mono-ui truncate">{skill.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getJobName(job: CronJob): string {
|
||||
return asText(job.name).trim();
|
||||
}
|
||||
|
|
@ -119,6 +178,7 @@ export default function CronPage() {
|
|||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [selectedProfile, setSelectedProfile] = useState("all");
|
||||
const [view, setView] = useState<"jobs" | "blueprints">("jobs");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t, locale } = useI18n();
|
||||
|
|
@ -157,6 +217,7 @@ export default function CronPage() {
|
|||
onClose: closeCreateModal,
|
||||
});
|
||||
const [deliver, setDeliver] = useState("local");
|
||||
const [jobSkills, setJobSkills] = useState<string[]>([]);
|
||||
const [deliveryTargets, setDeliveryTargets] = useState<CronDeliveryTarget[]>([
|
||||
{ id: "local", name: "Local", home_target_set: true, home_env_var: null },
|
||||
]);
|
||||
|
|
@ -169,6 +230,7 @@ export default function CronPage() {
|
|||
const [editSchedule, setEditSchedule] = useState("");
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDeliver, setEditDeliver] = useState("local");
|
||||
const [editSkills, setEditSkills] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const closeEditModal = useCallback(() => setEditJob(null), []);
|
||||
const editModalRef = useModalBehavior({
|
||||
|
|
@ -176,6 +238,12 @@ export default function CronPage() {
|
|||
onClose: closeEditModal,
|
||||
});
|
||||
|
||||
// Skills installed in the profile a job will run under, for the
|
||||
// attach-skill selector (parity with `hermes cron edit --add-skill`).
|
||||
// Keyed on the create-modal profile; the edit modal reuses the list —
|
||||
// a job's current skills are always shown even if not in it.
|
||||
const [availableSkills, setAvailableSkills] = useState<SkillInfo[]>([]);
|
||||
|
||||
const openEditModal = useCallback((job: CronJob) => {
|
||||
setEditJob(job);
|
||||
setEditPrompt(getJobPrompt(job));
|
||||
|
|
@ -184,6 +252,7 @@ export default function CronPage() {
|
|||
);
|
||||
setEditName(getJobName(job));
|
||||
setEditDeliver(asText(job.deliver) || "local");
|
||||
setEditSkills(Array.isArray(job.skills) ? job.skills.filter(Boolean) : []);
|
||||
}, []);
|
||||
|
||||
const loadJobs = useCallback(() => {
|
||||
|
|
@ -217,6 +286,25 @@ export default function CronPage() {
|
|||
loadJobs();
|
||||
}, [loadJobs]);
|
||||
|
||||
// Load installed skills for the profile new jobs will be created under.
|
||||
// "" / "default" maps to the dashboard's own profile via the optional
|
||||
// ?profile= scoping on /api/skills.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSkills(createProfile === "default" ? undefined : createProfile)
|
||||
.then((s) => {
|
||||
if (!cancelled)
|
||||
setAvailableSkills(
|
||||
[...s].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
})
|
||||
.catch(() => !cancelled && setAvailableSkills([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [createProfile]);
|
||||
|
||||
const scheduleString = buildScheduleString(scheduleState);
|
||||
|
||||
// Label for a delivery option. Configured platforms missing their cron home
|
||||
|
|
@ -284,6 +372,7 @@ export default function CronPage() {
|
|||
schedule: scheduleString,
|
||||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
skills: jobSkills.length > 0 ? jobSkills : undefined,
|
||||
},
|
||||
createProfile,
|
||||
);
|
||||
|
|
@ -292,6 +381,7 @@ export default function CronPage() {
|
|||
setScheduleState(DEFAULT_SCHEDULE_STATE);
|
||||
setName("");
|
||||
setDeliver("local");
|
||||
setJobSkills([]);
|
||||
setCreateModalOpen(false);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
|
|
@ -316,6 +406,7 @@ export default function CronPage() {
|
|||
schedule: editSchedule.trim(),
|
||||
name: editName.trim(),
|
||||
deliver: editDeliver,
|
||||
skills: editSkills,
|
||||
},
|
||||
getJobProfile(editJob),
|
||||
);
|
||||
|
|
@ -419,6 +510,23 @@ export default function CronPage() {
|
|||
<PluginSlot name="cron:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<Segmented
|
||||
value={view}
|
||||
onChange={(v) => setView(v as "jobs" | "blueprints")}
|
||||
options={[
|
||||
{ value: "jobs", label: "Jobs" },
|
||||
{ value: "blueprints", label: "Blueprints" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{view === "blueprints" && (
|
||||
<AutomationBlueprints
|
||||
profile={selectedProfile === "all" ? "default" : selectedProfile}
|
||||
onCreated={loadJobs}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={jobDelete.isOpen}
|
||||
onCancel={jobDelete.cancel}
|
||||
|
|
@ -524,6 +632,21 @@ export default function CronPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-skills">Skills (optional)</Label>
|
||||
<SkillsPicker
|
||||
id="cron-skills"
|
||||
available={availableSkills}
|
||||
selected={jobSkills}
|
||||
onChange={setJobSkills}
|
||||
emptyLabel="No skills installed for this profile."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected skills are loaded before the prompt runs — the cron
|
||||
sets when, the skill sets how.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
|
|
@ -616,6 +739,17 @@ export default function CronPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-skills">Skills</Label>
|
||||
<SkillsPicker
|
||||
id="edit-cron-skills"
|
||||
available={availableSkills}
|
||||
selected={editSkills}
|
||||
onChange={setEditSkills}
|
||||
emptyLabel="No skills installed for this profile."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
|
|
@ -632,6 +766,7 @@ export default function CronPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{view === "jobs" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<H2
|
||||
|
|
@ -691,6 +826,13 @@ export default function CronPage() {
|
|||
{deliver && deliver !== "local" && (
|
||||
<Badge tone="outline">{deliver}</Badge>
|
||||
)}
|
||||
{Array.isArray(job.skills) && job.skills.length > 0 && (
|
||||
<Badge tone="outline" title={job.skills.join(", ")}>
|
||||
{job.skills.length === 1
|
||||
? job.skills[0]
|
||||
: `${job.skills.length} skills`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasName && promptText && (
|
||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||
|
|
@ -767,6 +909,7 @@ export default function CronPage() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="cron:bottom" />
|
||||
</div>
|
||||
|
|
|
|||
538
web/src/pages/FilesPage.tsx
Normal file
538
web/src/pages/FilesPage.tsx
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent as ReactDragEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Download,
|
||||
FileIcon,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@nous-research/ui/ui/components/dialog";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ManagedFileEntry, ManagedFilesResponse } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
const cleanName = name.trim().replace(/^[\\/]+/, "");
|
||||
if (!cleanName) return base;
|
||||
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/";
|
||||
if (!base || base.endsWith("/") || base.endsWith("\\")) return `${base}${cleanName}`;
|
||||
return `${base}${separator}${cleanName}`;
|
||||
}
|
||||
|
||||
function formatBytes(size: number | null): string {
|
||||
if (size === null) return "-";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function readAsDataUrl(file: globalThis.File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
if (typeof reader.result === "string") resolve(reader.result);
|
||||
else reject(new Error("Could not read file"));
|
||||
});
|
||||
reader.addEventListener("error", () => reject(reader.error ?? new Error("Could not read file")));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadDataUrl(dataUrl: string, name: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = name || "download";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function displayPath(path: string | null | undefined): string {
|
||||
return path?.trim() || "Files";
|
||||
}
|
||||
|
||||
function transferHasFiles(event: ReactDragEvent<HTMLElement>): boolean {
|
||||
return Array.from(event.dataTransfer.types).includes("Files");
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const { toast, showToast } = useToast();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [listing, setListing] = useState<ManagedFilesResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [draggingFiles, setDraggingFiles] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState<ManagedFileEntry | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activePath = listing?.path ?? currentPath ?? "";
|
||||
const canChangePath = listing?.can_change_path ?? false;
|
||||
const canUpload = Boolean(activePath) && !uploading;
|
||||
const headerPath = displayPath(listing?.locked_root ?? listing?.path ?? currentPath);
|
||||
|
||||
const load = useCallback(
|
||||
async (path = currentPath) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.listFiles(path);
|
||||
setListing(result);
|
||||
setCurrentPath(result.path);
|
||||
setPathInput(result.path);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Existing dashboard data pages fetch from effects; keep this local and explicit
|
||||
// until the shared lint profile is updated for async page loaders.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void load(currentPath);
|
||||
}, [currentPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setAfterTitle(
|
||||
<Badge tone="outline" className="max-w-[22rem] truncate text-xs" title={headerPath}>
|
||||
{headerPath}
|
||||
</Badge>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
aria-label="Refresh files"
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [headerPath, load, loading, setAfterTitle, setEnd]);
|
||||
|
||||
const openDirectory = (entry: ManagedFileEntry) => {
|
||||
if (entry.is_directory) {
|
||||
setCurrentPath(entry.path);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPath = async () => {
|
||||
const nextPath = pathInput.trim();
|
||||
if (!nextPath) {
|
||||
showToast("Path required", "error");
|
||||
return;
|
||||
}
|
||||
await load(nextPath);
|
||||
};
|
||||
|
||||
const createDirectory = async () => {
|
||||
const name = folderName.trim();
|
||||
if (!activePath) {
|
||||
showToast("Directory unavailable", "error");
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
showToast("Folder name required", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createDirectory(joinPath(activePath, name));
|
||||
setFolderName("");
|
||||
setCreateDialogOpen(false);
|
||||
showToast("Folder created", "success");
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Create failed: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const dataUrl = await readAsDataUrl(file);
|
||||
await api.uploadFile(joinPath(activePath, file.name), dataUrl, true);
|
||||
}
|
||||
showToast(`${files.length} file${files.length === 1 ? "" : "s"} uploaded`, "success");
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Upload failed: ${e}`, "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setDraggingFiles(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDraggingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setDraggingFiles(false);
|
||||
void uploadFiles(event.dataTransfer.files);
|
||||
};
|
||||
|
||||
const downloadFile = async (entry: ManagedFileEntry) => {
|
||||
if (entry.is_directory) return;
|
||||
try {
|
||||
const file = await api.readFile(entry.path);
|
||||
downloadDataUrl(file.data_url, file.name);
|
||||
} catch (e) {
|
||||
showToast(`Download failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteFile(pendingDelete.path, pendingDelete.is_directory);
|
||||
showToast("Deleted", "success");
|
||||
setPendingDelete(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Delete failed: ${e}`, "error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-4">
|
||||
<Toast toast={toast} />
|
||||
<PluginSlot name="files:top" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => void uploadFiles(event.currentTarget.files)}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
{canChangePath ? (
|
||||
<form
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void goToPath();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={pathInput}
|
||||
onChange={(event) => setPathInput(event.target.value)}
|
||||
aria-label="Path"
|
||||
placeholder="Path"
|
||||
className="h-9 min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<Button type="submit" size="sm" outlined className="uppercase">
|
||||
Go
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="min-w-0 truncate font-mono text-sm text-text-secondary" title={activePath}>
|
||||
{activePath}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!canUpload}
|
||||
size="sm"
|
||||
outlined
|
||||
className="uppercase"
|
||||
prefix={uploading ? <Spinner /> : <Upload />}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
disabled={!activePath}
|
||||
size="sm"
|
||||
outlined
|
||||
className="uppercase"
|
||||
prefix={<FolderPlus />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canUpload && fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
disabled={!canUpload}
|
||||
aria-label="Upload files"
|
||||
className={`flex min-h-20 w-full min-w-0 items-center justify-between gap-4 border border-dashed px-4 py-3 text-left transition ${
|
||||
draggingFiles
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-background/20 text-text-secondary hover:border-text-tertiary hover:bg-background/35"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-border bg-background/45 text-text-tertiary">
|
||||
{uploading ? <Spinner /> : <Upload className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold uppercase tracking-[0.08em] text-foreground">
|
||||
{uploading ? "Uploading" : draggingFiles ? "Release to upload" : "Drop files here"}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-text-secondary" title={activePath}>
|
||||
{activePath || "Loading"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary sm:block">
|
||||
Choose files
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardContent className="overflow-x-auto p-0">
|
||||
{error && (
|
||||
<div className="border-b border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border px-4 py-2 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary">
|
||||
<span>Name</span>
|
||||
<span>Size</span>
|
||||
<span>Modified</span>
|
||||
<span className="text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
{listing?.parent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPath(listing.parent ?? undefined)}
|
||||
className="grid w-full min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-left text-sm transition hover:bg-background/40"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 font-mono text-text-secondary">
|
||||
<ArrowUp className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
..
|
||||
</span>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loading && !listing ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||
<Spinner />
|
||||
Loading files...
|
||||
</div>
|
||||
) : listing && listing.entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">No files</div>
|
||||
) : (
|
||||
listing?.entries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-sm last:border-b-0 hover:bg-background/35"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (entry.is_directory ? openDirectory(entry) : void downloadFile(entry))}
|
||||
className="flex min-w-0 items-center gap-2 text-left font-mono text-foreground"
|
||||
>
|
||||
{entry.is_directory ? (
|
||||
<Folder className="h-4 w-4 shrink-0 text-warning" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-text-secondary">{formatBytes(entry.size)}</span>
|
||||
<span className="truncate text-xs text-text-secondary">
|
||||
{Number.isFinite(entry.mtime) ? DATE_FORMAT.format(entry.mtime * 1000) : "-"}
|
||||
</span>
|
||||
<span className="flex justify-end gap-1">
|
||||
{entry.is_directory ? (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => openDirectory(entry)}
|
||||
aria-label={`Open ${entry.name}`}
|
||||
>
|
||||
<FolderOpen />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void downloadFile(entry)}
|
||||
aria-label={`Download ${entry.name}`}
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(entry)}
|
||||
aria-label={`Delete ${entry.name}`}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<PluginSlot name="files:bottom" />
|
||||
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (creating) return;
|
||||
setCreateDialogOpen(open);
|
||||
if (!open) setFolderName("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
Target: {activePath || "Loading"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="p-4">
|
||||
<Input
|
||||
autoFocus
|
||||
value={folderName}
|
||||
onChange={(event) => setFolderName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void createDirectory();
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
outlined
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(false);
|
||||
setFolderName("");
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void createDirectory()}
|
||||
disabled={creating}
|
||||
prefix={creating ? <Spinner /> : <FolderPlus />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={Boolean(pendingDelete)}
|
||||
loading={deleting}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
title={pendingDelete ? `Delete ${pendingDelete.name}?` : "Delete item?"}
|
||||
description={
|
||||
pendingDelete?.is_directory
|
||||
? "This removes the folder and everything inside it."
|
||||
: "This removes the file."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
|||
import { Stats } from "@nous-research/ui/ui/components/stats";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
|
@ -209,10 +209,16 @@ function UseAsMenu({
|
|||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingConfirm, setPendingConfirm] = useState<{
|
||||
message: string;
|
||||
scope: "main" | "auxiliary";
|
||||
task: string;
|
||||
} | null>(null);
|
||||
|
||||
const assign = async (
|
||||
scope: "main" | "auxiliary",
|
||||
task: string,
|
||||
confirmExpensiveModel = false,
|
||||
) => {
|
||||
if (!provider || !model) {
|
||||
setError("Missing provider/model");
|
||||
|
|
@ -221,7 +227,23 @@ function UseAsMenu({
|
|||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setModelAssignment({ scope, provider, model, task });
|
||||
const result = await api.setModelAssignment({
|
||||
confirm_expensive_model: confirmExpensiveModel,
|
||||
scope,
|
||||
provider,
|
||||
model,
|
||||
task,
|
||||
});
|
||||
if (result.confirm_required) {
|
||||
setPendingConfirm({
|
||||
scope,
|
||||
task,
|
||||
message:
|
||||
result.confirm_message ||
|
||||
"This model has unusually high known pricing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
onAssigned();
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
|
|
@ -310,6 +332,22 @@ function UseAsMenu({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!pendingConfirm}
|
||||
title="Expensive Model Warning"
|
||||
description={pendingConfirm?.message}
|
||||
destructive
|
||||
confirmLabel="Switch anyway"
|
||||
cancelLabel="Cancel"
|
||||
loading={busy}
|
||||
onCancel={() => setPendingConfirm(null)}
|
||||
onConfirm={() => {
|
||||
const pending = pendingConfirm;
|
||||
if (!pending) return;
|
||||
setPendingConfirm(null);
|
||||
void assign(pending.scope, pending.task, true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -619,14 +657,16 @@ function AuxiliaryTasksModal({
|
|||
AUX_TASKS.find((t) => t.key === picker.task)?.label ??
|
||||
picker.task
|
||||
}`}
|
||||
onApply={async ({ provider, model }) => {
|
||||
await api.setModelAssignment({
|
||||
onApply={async ({ provider, model, confirmExpensiveModel }) => {
|
||||
const result = await api.setModelAssignment({
|
||||
confirm_expensive_model: confirmExpensiveModel,
|
||||
scope: "auxiliary",
|
||||
task: picker.task,
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
onSaved();
|
||||
if (!result.confirm_required) onSaved();
|
||||
return result;
|
||||
}}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
|
|
@ -666,14 +706,23 @@ function ModelSettingsPanel({
|
|||
task,
|
||||
provider,
|
||||
model,
|
||||
confirmExpensiveModel,
|
||||
}: {
|
||||
confirmExpensiveModel?: boolean;
|
||||
scope: "main" | "auxiliary";
|
||||
task: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}) => {
|
||||
await api.setModelAssignment({ scope, task, provider, model });
|
||||
onSaved();
|
||||
const result = await api.setModelAssignment({
|
||||
confirm_expensive_model: confirmExpensiveModel,
|
||||
scope,
|
||||
task,
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
if (!result.confirm_required) onSaved();
|
||||
return result;
|
||||
};
|
||||
|
||||
// Count how many aux tasks have overrides
|
||||
|
|
@ -749,14 +798,15 @@ function ModelSettingsPanel({
|
|||
loader={api.getModelOptions}
|
||||
alwaysGlobal
|
||||
title="Set Main Model"
|
||||
onApply={async ({ provider, model }) => {
|
||||
await applyAssignment({
|
||||
onApply={({ provider, model, confirmExpensiveModel }) =>
|
||||
applyAssignment({
|
||||
confirmExpensiveModel,
|
||||
scope: "main",
|
||||
task: "",
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
}}
|
||||
})
|
||||
}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ export default function PluginsPage() {
|
|||
<Input
|
||||
className="font-mono-ui lowercase"
|
||||
id="install-url"
|
||||
placeholder="owner/repo or https://..."
|
||||
placeholder="owner/repo, owner/repo/subdir, or https://..."
|
||||
spellCheck={false}
|
||||
value={installId}
|
||||
onChange={(e) => setInstallId(e.target.value)}
|
||||
|
|
|
|||
611
web/src/pages/ProfileBuilderPage.tsx
Normal file
611
web/src/pages/ProfileBuilderPage.tsx
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { api } from "@/lib/api";
|
||||
import type { McpServerCreate, SkillInfo, SkillHubResult } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Profile name rule mirrors the backend (`^[a-z0-9][a-z0-9_-]{0,63}$`).
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
type StepId = "identity" | "model" | "skills" | "mcp" | "review";
|
||||
|
||||
const STEPS: { id: StepId; label: string }[] = [
|
||||
{ id: "identity", label: "Identity" },
|
||||
{ id: "model", label: "Model" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "mcp", label: "MCPs" },
|
||||
{ id: "review", label: "Review" },
|
||||
];
|
||||
|
||||
interface ModelChoice {
|
||||
provider: string;
|
||||
model: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-native, full-featured profile builder.
|
||||
*
|
||||
* Composes the same elements the standalone Models / Skills / MCP pages
|
||||
* manage — Name, Description, Model+Provider, Skills (built-in/optional +
|
||||
* hub), MCP servers — into one stepped create flow. Nothing is written to
|
||||
* disk until "Create profile" on the final step; the single POST /api/profiles
|
||||
* call commits model + MCPs + skill selection synchronously and spawns any
|
||||
* hub-skill installs (which the success toast reports as in-progress).
|
||||
*
|
||||
* Skills use REPLACE semantics: the default bundle is seeded server-side, then
|
||||
* every seeded skill the user did NOT keep is disabled. The "Start from full
|
||||
* bundle" toggle keeps everything (sends no keep list).
|
||||
*/
|
||||
export default function ProfileBuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
const [step, setStep] = useState<StepId>("identity");
|
||||
|
||||
// ── Step 1: identity ──────────────────────────────────────────────
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// ── Step 2: model ─────────────────────────────────────────────────
|
||||
const [modelChoices, setModelChoices] = useState<ModelChoice[] | null>(null);
|
||||
const [modelChoice, setModelChoice] = useState(""); // `${provider}\u0000${model}`
|
||||
const [modelFilter, setModelFilter] = useState("");
|
||||
const modelLoading = useRef(false);
|
||||
|
||||
// ── Step 3: skills ────────────────────────────────────────────────
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null);
|
||||
// keepAll = true: don't send a keep list (full bundle stays active).
|
||||
const [keepAll, setKeepAll] = useState(true);
|
||||
const [keptSkills, setKeptSkills] = useState<Set<string>>(new Set());
|
||||
const [skillFilter, setSkillFilter] = useState("");
|
||||
const skillsLoading = useRef(false);
|
||||
// Hub search
|
||||
const [hubQuery, setHubQuery] = useState("");
|
||||
const [hubResults, setHubResults] = useState<SkillHubResult[]>([]);
|
||||
const [hubSearching, setHubSearching] = useState(false);
|
||||
const [hubSkills, setHubSkills] = useState<SkillHubResult[]>([]);
|
||||
|
||||
// ── Step 4: MCPs ──────────────────────────────────────────────────
|
||||
const [mcpServers, setMcpServers] = useState<McpServerCreate[]>([]);
|
||||
const [mcpDraft, setMcpDraft] = useState<{
|
||||
name: string;
|
||||
url: string;
|
||||
command: string;
|
||||
args: string;
|
||||
}>({ name: "", url: "", command: "", args: "" });
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const nameValid = PROFILE_NAME_RE.test(name.trim());
|
||||
|
||||
// Lazy-load model choices when the model step is first shown.
|
||||
const loadModels = useCallback(() => {
|
||||
if (modelChoices !== null || modelLoading.current) return;
|
||||
modelLoading.current = true;
|
||||
api
|
||||
.getModelOptions()
|
||||
.then((res) => {
|
||||
const flat: ModelChoice[] = [];
|
||||
for (const prov of res.providers ?? []) {
|
||||
for (const m of prov.models ?? []) {
|
||||
flat.push({ provider: prov.slug, model: m, label: `${prov.name} · ${m}` });
|
||||
}
|
||||
}
|
||||
setModelChoices(flat);
|
||||
})
|
||||
.catch(() => setModelChoices([]))
|
||||
.finally(() => {
|
||||
modelLoading.current = false;
|
||||
});
|
||||
}, [modelChoices]);
|
||||
|
||||
const loadSkills = useCallback(() => {
|
||||
if (skills !== null || skillsLoading.current) return;
|
||||
skillsLoading.current = true;
|
||||
api
|
||||
.getSkills()
|
||||
.then((res) => {
|
||||
setSkills(res);
|
||||
// Default keep = all currently-enabled skills (matches the seeded set).
|
||||
setKeptSkills(new Set(res.filter((s) => s.enabled).map((s) => s.name)));
|
||||
})
|
||||
.catch(() => setSkills([]))
|
||||
.finally(() => {
|
||||
skillsLoading.current = false;
|
||||
});
|
||||
}, [skills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "model") loadModels();
|
||||
if (step === "skills") loadSkills();
|
||||
}, [step, loadModels, loadSkills]);
|
||||
|
||||
const runHubSearch = useCallback(() => {
|
||||
const q = hubQuery.trim();
|
||||
if (!q) return;
|
||||
setHubSearching(true);
|
||||
api
|
||||
.searchSkillsHub(q, "all", 20)
|
||||
.then((res) => setHubResults(res.results ?? []))
|
||||
.catch(() => setHubResults([]))
|
||||
.finally(() => setHubSearching(false));
|
||||
}, [hubQuery]);
|
||||
|
||||
const toggleKeep = (skillName: string) => {
|
||||
setKeptSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(skillName)) next.delete(skillName);
|
||||
else next.add(skillName);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addHubSkill = (r: SkillHubResult) => {
|
||||
setHubSkills((prev) =>
|
||||
prev.some((x) => x.identifier === r.identifier) ? prev : [...prev, r],
|
||||
);
|
||||
};
|
||||
const removeHubSkill = (identifier: string) =>
|
||||
setHubSkills((prev) => prev.filter((x) => x.identifier !== identifier));
|
||||
|
||||
const addMcpDraft = () => {
|
||||
const n = mcpDraft.name.trim();
|
||||
if (!n) {
|
||||
showToast("MCP server needs a name", "error");
|
||||
return;
|
||||
}
|
||||
if (!mcpDraft.url.trim() && !mcpDraft.command.trim()) {
|
||||
showToast("Give the MCP server a URL or a command", "error");
|
||||
return;
|
||||
}
|
||||
const entry: McpServerCreate = { name: n };
|
||||
if (mcpDraft.url.trim()) entry.url = mcpDraft.url.trim();
|
||||
if (mcpDraft.command.trim()) {
|
||||
entry.command = mcpDraft.command.trim();
|
||||
const args = mcpDraft.args.trim();
|
||||
if (args) entry.args = args.split(/\s+/);
|
||||
}
|
||||
setMcpServers((prev) => [...prev.filter((s) => s.name !== n), entry]);
|
||||
setMcpDraft({ name: "", url: "", command: "", args: "" });
|
||||
};
|
||||
const removeMcp = (n: string) =>
|
||||
setMcpServers((prev) => prev.filter((s) => s.name !== n));
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!modelChoices) return [];
|
||||
const f = modelFilter.trim().toLowerCase();
|
||||
if (!f) return modelChoices;
|
||||
return modelChoices.filter((c) => c.label.toLowerCase().includes(f));
|
||||
}, [modelChoices, modelFilter]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!skills) return [];
|
||||
const f = skillFilter.trim().toLowerCase();
|
||||
if (!f) return skills;
|
||||
return skills.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(f) ||
|
||||
(s.description || "").toLowerCase().includes(f) ||
|
||||
(s.category || "").toLowerCase().includes(f),
|
||||
);
|
||||
}, [skills, skillFilter]);
|
||||
|
||||
const pickedModel = useMemo(
|
||||
() =>
|
||||
modelChoice
|
||||
? modelChoices?.find((c) => `${c.provider}\u0000${c.model}` === modelChoice)
|
||||
: undefined,
|
||||
[modelChoice, modelChoices],
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const n = name.trim();
|
||||
if (!PROFILE_NAME_RE.test(n)) {
|
||||
showToast("Invalid profile name (lowercase, digits, - and _)", "error");
|
||||
setStep("identity");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await api.createProfile({
|
||||
name: n,
|
||||
clone_from_default: false,
|
||||
description: description.trim() || undefined,
|
||||
provider: pickedModel?.provider,
|
||||
model: pickedModel?.model,
|
||||
mcp_servers: mcpServers.length ? mcpServers : undefined,
|
||||
keep_skills: keepAll ? undefined : Array.from(keptSkills),
|
||||
hub_skills: hubSkills.length ? hubSkills.map((s) => s.identifier) : undefined,
|
||||
});
|
||||
const pending = (res.hub_installs ?? []).filter((h) => h.pid).length;
|
||||
showToast(
|
||||
pending
|
||||
? `Profile "${n}" created — ${pending} hub skill${pending === 1 ? "" : "s"} installing`
|
||||
: `Profile "${n}" created`,
|
||||
"success",
|
||||
);
|
||||
navigate("/profiles");
|
||||
} catch (e) {
|
||||
showToast(`Create failed: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepIndex = STEPS.findIndex((s) => s.id === step);
|
||||
const canAdvance = step !== "identity" || nameValid;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<H2>New profile</H2>
|
||||
<Button ghost onClick={() => navigate("/profiles")}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
// Identity must be valid before jumping ahead.
|
||||
disabled={i > 0 && !nameValid}
|
||||
onClick={() => setStep(s.id)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 transition-colors",
|
||||
s.id === step
|
||||
? "bg-primary text-primary-foreground"
|
||||
: i <= stepIndex
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground",
|
||||
i > 0 && !nameValid && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
{i + 1}. {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
{step === "identity" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pb-name">Profile name</Label>
|
||||
<Input
|
||||
id="pb-name"
|
||||
placeholder="coder"
|
||||
value={name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
|
||||
/>
|
||||
{name && !nameValid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Lowercase letters, digits, hyphens and underscores; must start with a letter or digit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pb-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="pb-desc"
|
||||
placeholder="What this agent profile is for"
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setDescription(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "model" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick the model+provider for this profile. Skip to use the default.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Filter models…"
|
||||
value={modelFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setModelFilter(e.target.value)
|
||||
}
|
||||
/>
|
||||
{modelChoices === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading models…</p>
|
||||
) : (
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => setModelChoice("")}
|
||||
className={cn(
|
||||
"block w-full rounded px-3 py-2 text-left text-sm",
|
||||
modelChoice === "" ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
Use default (set later)
|
||||
</button>
|
||||
{filteredModels.map((c) => {
|
||||
const key = `${c.provider}\u0000${c.model}`;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setModelChoice(key)}
|
||||
className={cn(
|
||||
"block w-full rounded px-3 py-2 text-left text-sm",
|
||||
modelChoice === key ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "skills" && (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={keepAll}
|
||||
onCheckedChange={(v) => setKeepAll(Boolean(v))}
|
||||
/>
|
||||
Start from the full default skill bundle (recommended)
|
||||
</label>
|
||||
{!keepAll && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which built-in / optional skills to keep active. Unchecked skills are disabled in the new profile.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Filter skills…"
|
||||
value={skillFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSkillFilter(e.target.value)
|
||||
}
|
||||
/>
|
||||
{skills === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading skills…</p>
|
||||
) : (
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
||||
{filteredSkills.map((s) => (
|
||||
<label
|
||||
key={s.name}
|
||||
className="flex items-start gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
checked={keptSkills.has(s.name)}
|
||||
onCheckedChange={() => toggleKeep(s.name)}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
{s.category && (
|
||||
<Badge tone="secondary" className="ml-2">
|
||||
{s.category}
|
||||
</Badge>
|
||||
)}
|
||||
{s.description && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{s.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills hub */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label>Add from the skills hub</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search the hub (e.g. linear, hyperliquid)…"
|
||||
value={hubQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setHubQuery(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") runHubSearch();
|
||||
}}
|
||||
/>
|
||||
<Button outlined onClick={runHubSearch} disabled={hubSearching}>
|
||||
{hubSearching ? "Searching…" : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
{hubResults.length > 0 && (
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{hubResults.map((r) => (
|
||||
<div
|
||||
key={r.identifier}
|
||||
className="flex items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<Badge tone="secondary" className="ml-2">
|
||||
{r.source}
|
||||
</Badge>
|
||||
{r.description && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{r.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button size="sm" ghost onClick={() => addHubSkill(r)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hubSkills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{hubSkills.map((r) => (
|
||||
<Badge key={r.identifier} className="gap-1">
|
||||
{r.name}
|
||||
<button
|
||||
className="ml-1 text-xs"
|
||||
onClick={() => removeHubSkill(r.identifier)}
|
||||
aria-label={`Remove ${r.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "mcp" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add MCP servers for this profile. HTTP servers take a URL; stdio servers take a command + args.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
placeholder="Server name"
|
||||
value={mcpDraft.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="URL (https://…/mcp)"
|
||||
value={mcpDraft.url}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, url: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Command (e.g. npx)"
|
||||
value={mcpDraft.command}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, command: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Args (space-separated)"
|
||||
value={mcpDraft.args}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, args: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button outlined onClick={addMcpDraft}>
|
||||
Add server
|
||||
</Button>
|
||||
{mcpServers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{mcpServers.map((s) => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="flex items-center justify-between rounded bg-muted px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span>
|
||||
<span className="font-medium">{s.name}</span>{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.url || `${s.command} ${(s.args || []).join(" ")}`}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
className="text-xs text-destructive"
|
||||
onClick={() => removeMcp(s.name)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<ReviewRow label="Name" value={name.trim() || "—"} />
|
||||
<ReviewRow label="Description" value={description.trim() || "—"} />
|
||||
<ReviewRow
|
||||
label="Model"
|
||||
value={pickedModel ? pickedModel.label : "Default (set later)"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="Skills"
|
||||
value={
|
||||
keepAll
|
||||
? "Full default bundle"
|
||||
: `${keptSkills.size} built-in/optional kept` +
|
||||
(hubSkills.length ? ` + ${hubSkills.length} hub` : "")
|
||||
}
|
||||
/>
|
||||
{!keepAll && hubSkills.length > 0 && (
|
||||
<p className="pl-24 text-xs text-muted-foreground">
|
||||
Hub: {hubSkills.map((s) => s.name).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{keepAll && hubSkills.length > 0 && (
|
||||
<ReviewRow
|
||||
label="Hub skills"
|
||||
value={hubSkills.map((s) => s.name).join(", ")}
|
||||
/>
|
||||
)}
|
||||
<ReviewRow
|
||||
label="MCP servers"
|
||||
value={mcpServers.length ? mcpServers.map((s) => s.name).join(", ") : "None"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Nav buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
ghost
|
||||
disabled={stepIndex === 0}
|
||||
onClick={() => setStep(STEPS[Math.max(0, stepIndex - 1)].id)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{step === "review" ? (
|
||||
<Button onClick={handleCreate} disabled={creating || !nameValid}>
|
||||
{creating ? "Creating…" : "Create profile"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canAdvance}
|
||||
onClick={() => setStep(STEPS[Math.min(STEPS.length - 1, stepIndex + 1)].id)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Toast toast={toast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="flex-1 break-words">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlignLeft,
|
||||
Check,
|
||||
|
|
@ -95,6 +96,7 @@ function ProfileActionsMenu({
|
|||
onEditDescription,
|
||||
onEditModel,
|
||||
onEditSoul,
|
||||
onManageSkills,
|
||||
onRename,
|
||||
onSetActive,
|
||||
}: ProfileActionsMenuProps) {
|
||||
|
|
@ -200,6 +202,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"
|
||||
|
|
@ -240,6 +252,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);
|
||||
|
|
@ -289,6 +302,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]);
|
||||
|
||||
|
|
@ -478,7 +495,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 },
|
||||
);
|
||||
|
|
@ -722,21 +746,31 @@ export default function ProfilesPage() {
|
|||
: base;
|
||||
})();
|
||||
|
||||
// Put "Create" button in page header
|
||||
// Put "Build" (full builder) + "Create" (quick modal) buttons in header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => navigate("/profiles/new")}
|
||||
>
|
||||
Build
|
||||
</Button>
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t.common.create}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, t.common.create, loading]);
|
||||
}, [setEnd, t.common.create, loading, navigate]);
|
||||
|
||||
const cloning = cloneAll || cloneFromDefault;
|
||||
|
||||
|
|
@ -782,7 +816,7 @@ export default function ProfilesPage() {
|
|||
<div
|
||||
className={cn(
|
||||
themedBody,
|
||||
"relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col max-h-[90vh] overflow-y-auto",
|
||||
"relative w-full max-w-md border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
|
|
@ -804,7 +838,7 @@ export default function ProfilesPage() {
|
|||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="min-h-0 overflow-y-auto p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="profile-name">{t.profiles.name}</Label>
|
||||
|
||||
|
|
@ -1098,6 +1132,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,
|
||||
|
|
@ -1109,6 +1144,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);
|
||||
|
|
@ -1195,7 +1235,7 @@ export default function ProfilesPage() {
|
|||
<div
|
||||
className={cn(
|
||||
themedBody,
|
||||
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh] overflow-y-auto",
|
||||
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
|
|
@ -1222,7 +1262,12 @@ export default function ProfilesPage() {
|
|||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"p-5 grid gap-4",
|
||||
editorKind === "soul" && "min-h-0 overflow-y-auto",
|
||||
)}
|
||||
>
|
||||
{editorKind === "model" &&
|
||||
(modelChoices !== null && modelChoices.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{L.modelNone}</p>
|
||||
|
|
@ -1358,6 +1403,7 @@ interface ProfileActionsMenuProps {
|
|||
editDescription: string;
|
||||
editModel: string;
|
||||
editSoul: string;
|
||||
manageSkills: string;
|
||||
openInTerminal: string;
|
||||
rename: string;
|
||||
setActive: string;
|
||||
|
|
@ -1368,6 +1414,7 @@ interface ProfileActionsMenuProps {
|
|||
onEditDescription: () => void;
|
||||
onEditModel: () => void;
|
||||
onEditSoul: () => void;
|
||||
onManageSkills: () => void;
|
||||
onRename: () => void;
|
||||
onSetActive: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import {
|
|||
AlertTriangle,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
|
|
@ -36,7 +38,9 @@ import type {
|
|||
SkillHubPreview,
|
||||
SkillHubScan,
|
||||
} from "@/lib/api";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
|
||||
import { SkillEditorDialog } from "@/components/SkillEditorDialog";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
|
|
@ -129,25 +133,50 @@ export default function SkillsPage() {
|
|||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
||||
const [configToolset, setConfigToolset] = useState<ToolsetInfo | null>(null);
|
||||
// Skill editor dialog: open + which skill is being edited (null = create).
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorSkill, setEditorSkill] = useState<string | null>(null);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
||||
// ── Profile scoping ──
|
||||
// The write target comes from the GLOBAL profile switcher (sidebar) via
|
||||
// ProfileContext — one selector for the whole dashboard, deep-linkable
|
||||
// as ?profile=<name>. This page just consumes it: the fetchJSON layer
|
||||
// appends the param automatically; we still pass it explicitly where the
|
||||
// call signature supports it (clearer, and robust if a caller bypasses
|
||||
// the auto-injection).
|
||||
const {
|
||||
profile: selectedProfile,
|
||||
} = useProfileScope();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getSkills(), api.getToolsets()])
|
||||
// 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,
|
||||
|
|
@ -178,6 +207,28 @@ export default function SkillsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
/* ---- Skill editor (create / edit SKILL.md) ---- */
|
||||
const openCreateEditor = useCallback(() => {
|
||||
setEditorSkill(null);
|
||||
setEditorOpen(true);
|
||||
}, []);
|
||||
const openEditEditor = useCallback((skillName: string) => {
|
||||
setEditorSkill(skillName);
|
||||
setEditorOpen(true);
|
||||
}, []);
|
||||
const handleEditorSaved = useCallback(
|
||||
(skillName: string) => {
|
||||
showToast(`${skillName} saved ✓`, "success");
|
||||
// Reload the list so a newly created skill (or an edited description)
|
||||
// shows up immediately.
|
||||
api
|
||||
.getSkills(selectedProfile || undefined)
|
||||
.then(setSkills)
|
||||
.catch(() => {});
|
||||
},
|
||||
[selectedProfile, showToast],
|
||||
);
|
||||
|
||||
/* ---- Derived data ---- */
|
||||
const lowerSearch = search.toLowerCase();
|
||||
const isSearching = search.trim().length > 0;
|
||||
|
|
@ -233,7 +284,7 @@ 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))}
|
||||
|
|
@ -265,7 +316,15 @@ export default function SkillsPage() {
|
|||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]);
|
||||
}, [
|
||||
enabledCount,
|
||||
loading,
|
||||
search,
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
skills.length,
|
||||
t,
|
||||
]);
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
return toolsets.filter(
|
||||
|
|
@ -405,6 +464,7 @@ export default function SkillsPage() {
|
|||
skill={skill}
|
||||
toggling={togglingSkills.has(skill.name)}
|
||||
onToggle={() => handleToggleSkill(skill)}
|
||||
onEdit={() => openEditEditor(skill.name)}
|
||||
noDescriptionLabel={t.skills.noDescription}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -426,11 +486,22 @@ export default function SkillsPage() {
|
|||
)
|
||||
: t.skills.all}
|
||||
</CardTitle>
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{t.skills.skillCount
|
||||
.replace("{count}", String(activeSkills.length))
|
||||
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{t.skills.skillCount
|
||||
.replace("{count}", String(activeSkills.length))
|
||||
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
|
||||
</Badge>
|
||||
<Button
|
||||
size="xs"
|
||||
outlined
|
||||
className="uppercase"
|
||||
onClick={openCreateEditor}
|
||||
prefix={<Plus />}
|
||||
>
|
||||
New skill
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
|
|
@ -448,6 +519,7 @@ export default function SkillsPage() {
|
|||
skill={skill}
|
||||
toggling={togglingSkills.has(skill.name)}
|
||||
onToggle={() => handleToggleSkill(skill)}
|
||||
onEdit={() => openEditEditor(skill.name)}
|
||||
noDescriptionLabel={t.skills.noDescription}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -540,17 +612,25 @@ 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()}
|
||||
/>
|
||||
)}
|
||||
<SkillEditorDialog
|
||||
open={editorOpen}
|
||||
editName={editorSkill}
|
||||
profile={selectedProfile || undefined}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSaved={handleEditorSaved}
|
||||
/>
|
||||
<PluginSlot name="skills:bottom" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -560,6 +640,7 @@ function SkillRow({
|
|||
skill,
|
||||
toggling,
|
||||
onToggle,
|
||||
onEdit,
|
||||
noDescriptionLabel,
|
||||
}: SkillRowProps) {
|
||||
return (
|
||||
|
|
@ -585,6 +666,16 @@ function SkillRow({
|
|||
{skill.description || noDescriptionLabel}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:text-foreground"
|
||||
title="Edit SKILL.md"
|
||||
aria-label={`Edit ${skill.name}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -616,6 +707,7 @@ interface PanelItemProps {
|
|||
interface SkillRowProps {
|
||||
noDescriptionLabel: string;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
skill: SkillInfo;
|
||||
toggling: boolean;
|
||||
}
|
||||
|
|
@ -668,8 +760,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 +794,7 @@ function HubBrowser({
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSkillHubSources()
|
||||
.getSkillHubSources(profile)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setSources(r.sources);
|
||||
|
|
@ -715,7 +810,7 @@ function HubBrowser({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [profile]);
|
||||
|
||||
/* ---- Search ---- */
|
||||
const runSearch = useCallback(async () => {
|
||||
|
|
@ -725,7 +820,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 +834,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 +852,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 +865,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 +880,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 +893,7 @@ function HubBrowser({
|
|||
} catch (e) {
|
||||
showToast(`Update failed: ${e}`, "error");
|
||||
}
|
||||
}, [showToast]);
|
||||
}, [showToast, profile]);
|
||||
|
||||
const isInstalled = useCallback(
|
||||
(identifier: string) => Boolean(installed[identifier]),
|
||||
|
|
|
|||
|
|
@ -170,6 +170,11 @@ export default function SystemPage() {
|
|||
const [addingCred, setAddingCred] = useState(false);
|
||||
|
||||
const [importPath, setImportPath] = useState("");
|
||||
// Restore-from-backup is destructive (overwrites the live config) and the
|
||||
// spawned `hermes import` runs non-interactively (stdin is /dev/null), so
|
||||
// its CLI "Continue? [y/N]" prompt would auto-abort. The dashboard owns the
|
||||
// consent: confirm here, then call the endpoint with force=true.
|
||||
const [importConfirmOpen, setImportConfirmOpen] = useState(false);
|
||||
|
||||
// Create-hook modal.
|
||||
const [hookModalOpen, setHookModalOpen] = useState(false);
|
||||
|
|
@ -1181,11 +1186,24 @@ export default function SystemPage() {
|
|||
disabled={!importPath.trim()}
|
||||
onClick={() => {
|
||||
if (!importPath.trim()) return;
|
||||
runOp(() => api.runImport(importPath.trim()), "Import");
|
||||
setImportConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
open={importConfirmOpen}
|
||||
title="Restore from backup?"
|
||||
description={`This will overwrite your current Hermes configuration, skills, sessions, and data with the contents of ${importPath.trim() || "the archive"}. This cannot be undone.`}
|
||||
destructive
|
||||
confirmLabel="Restore"
|
||||
cancelLabel="Cancel"
|
||||
onCancel={() => setImportConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
setImportConfirmOpen(false);
|
||||
runOp(() => api.runImport(importPath.trim(), true), "Import");
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Webhook, Plus, Trash2, X, Copy, Check } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
Webhook,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
|
|
@ -51,6 +60,11 @@ function CopyButton({ value }: { value: string }) {
|
|||
export default function WebhooksPage() {
|
||||
const [data, setData] = useState<WebhooksResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [enabling, setEnabling] = useState(false);
|
||||
const [restartNeeded, setRestartNeeded] = useState(false);
|
||||
const [restartMessage, setRestartMessage] = useState<string | null>(null);
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const { toast, showToast } = useToast();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
|
|
@ -78,7 +92,7 @@ export default function WebhooksPage() {
|
|||
const subscriptions = data?.subscriptions ?? [];
|
||||
|
||||
const loadWebhooks = useCallback(() => {
|
||||
api
|
||||
return api
|
||||
.getWebhooks()
|
||||
.then(setData)
|
||||
.catch(() => showToast("Failed to load webhooks", "error"))
|
||||
|
|
@ -89,6 +103,78 @@ export default function WebhooksPage() {
|
|||
loadWebhooks();
|
||||
}, [loadWebhooks]);
|
||||
|
||||
const watchRestartOutcome = useCallback(async () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
try {
|
||||
const st = await api.getActionStatus("gateway-restart", 5);
|
||||
if (st.running) continue;
|
||||
if (st.exit_code !== 0 && st.exit_code !== null) {
|
||||
setRestartMessage(null);
|
||||
setRestartNeeded(true);
|
||||
setRestartError(`Gateway restart failed with exit ${st.exit_code}.`);
|
||||
showToast(
|
||||
`Gateway restart failed (exit ${st.exit_code}) — restart manually`,
|
||||
"error",
|
||||
);
|
||||
} else {
|
||||
setRestartMessage(null);
|
||||
setRestartNeeded(false);
|
||||
setRestartError(null);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
// The dashboard may briefly lose its connection while the gateway restarts.
|
||||
}
|
||||
}
|
||||
setRestartMessage(null);
|
||||
}, [showToast]);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await api.restartGateway();
|
||||
setRestartNeeded(false);
|
||||
setRestartError(null);
|
||||
setRestartMessage("Gateway restarting…");
|
||||
showToast("Gateway restarting…", "success");
|
||||
setTimeout(() => void loadWebhooks(), 4000);
|
||||
void watchRestartOutcome();
|
||||
} catch (e) {
|
||||
setRestartNeeded(true);
|
||||
setRestartError(String(e));
|
||||
showToast(`Failed to restart: ${e}`, "error");
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
}, [loadWebhooks, showToast, watchRestartOutcome]);
|
||||
|
||||
const handleEnableWebhooks = useCallback(async () => {
|
||||
setEnabling(true);
|
||||
setRestartNeeded(false);
|
||||
setRestartError(null);
|
||||
try {
|
||||
const result = await api.enableWebhooks();
|
||||
await loadWebhooks();
|
||||
if (result.restart_started) {
|
||||
setRestartMessage("Webhooks enabled; gateway restarting…");
|
||||
showToast("Webhooks enabled; gateway restarting…", "success");
|
||||
setTimeout(() => void loadWebhooks(), 4000);
|
||||
void watchRestartOutcome();
|
||||
} else {
|
||||
const detail = result.restart_error ? `: ${result.restart_error}` : ".";
|
||||
setRestartMessage(null);
|
||||
setRestartNeeded(true);
|
||||
setRestartError(`Gateway restart failed${detail}`);
|
||||
showToast(`Webhooks enabled; gateway restart failed${detail}`, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`Failed to enable webhooks: ${e}`, "error");
|
||||
} finally {
|
||||
setEnabling(false);
|
||||
}
|
||||
}, [loadWebhooks, showToast, watchRestartOutcome]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
|
|
@ -171,7 +257,7 @@ export default function WebhooksPage() {
|
|||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
disabled={!enabled}
|
||||
disabled={!enabled || enabling}
|
||||
prefix={<Plus />}
|
||||
onClick={() => {
|
||||
setCreated(null);
|
||||
|
|
@ -184,7 +270,7 @@ export default function WebhooksPage() {
|
|||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, enabled, loading]);
|
||||
}, [setEnd, enabled, enabling, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -375,17 +461,61 @@ export default function WebhooksPage() {
|
|||
)}
|
||||
|
||||
{!enabled && (
|
||||
<Card>
|
||||
<CardContent className="py-6 flex items-start gap-3 text-sm">
|
||||
<Webhook className="h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Webhook platform disabled</span>
|
||||
<span className="text-muted-foreground">
|
||||
The webhook platform must be enabled in your messaging settings
|
||||
before you can create subscriptions. Enable it, then return to
|
||||
this page.
|
||||
<Card className="border-warning/50">
|
||||
<CardContent className="flex flex-col gap-4 py-6 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Webhook className="h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Webhook receiver disabled</span>
|
||||
<span className="text-muted-foreground">
|
||||
Webhooks are their own gateway platform. Enable them here to
|
||||
accept incoming HTTP events; chat channels are only needed
|
||||
when a subscription delivers to Telegram, Discord, Slack, or
|
||||
another channel.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase shrink-0"
|
||||
onClick={handleEnableWebhooks}
|
||||
disabled={enabling}
|
||||
prefix={enabling ? <Spinner /> : <Webhook className="h-4 w-4" />}
|
||||
>
|
||||
{enabling ? "Enabling…" : "Enable webhooks"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{restartMessage && !restartNeeded && (
|
||||
<Card className="border-border">
|
||||
<CardContent className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
|
||||
<RotateCw className="h-4 w-4 shrink-0 text-warning" />
|
||||
<span>{restartMessage}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{restartNeeded && (
|
||||
<Card className="border-warning/50">
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<span>
|
||||
{restartError ??
|
||||
"Webhooks are enabled, but the gateway still needs a restart before the receiver can come online."}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase shrink-0"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
|
||||
>
|
||||
{restarting ? "Restarting…" : "Restart gateway"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
@ -400,8 +530,8 @@ export default function WebhooksPage() {
|
|||
</H2>
|
||||
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Disabled webhooks reject incoming events; the gateway hot-reloads
|
||||
changes (no restart needed).
|
||||
Subscription changes hot-reload once the webhook receiver is running.
|
||||
Disabled subscriptions reject incoming events.
|
||||
</p>
|
||||
|
||||
{subscriptions.length === 0 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue