mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
parent
24f74eb888
commit
2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
|
|
@ -1,222 +0,0 @@
|
|||
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,11 +120,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
return gw.request<{ session_id: string }>("session.create", {});
|
||||
})
|
||||
.then((created) => {
|
||||
if (cancelled || !created?.session_id) {
|
||||
|
|
@ -292,6 +288,24 @@ 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;
|
||||
|
|
@ -372,6 +386,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
|
|||
gw={gw}
|
||||
sessionId={sessionId}
|
||||
onClose={() => setModelOpen(false)}
|
||||
onSubmit={onModelSubmit}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
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,7 +4,6 @@ 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";
|
||||
|
|
@ -22,8 +21,9 @@ 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 applies the choice via
|
||||
* `config.set`, so expensive-model confirmation can happen before switch.
|
||||
* 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`.
|
||||
*
|
||||
* 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
|
||||
* `onApply`. The picker fetches options via the REST endpoint and calls
|
||||
|
|
@ -47,23 +47,6 @@ 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;
|
||||
|
|
@ -73,14 +56,10 @@ 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<ExpensiveModelConfirmResponse | void>
|
||||
| ExpensiveModelConfirmResponse
|
||||
| void;
|
||||
}): Promise<void> | void;
|
||||
|
||||
onClose(): void;
|
||||
title?: string;
|
||||
|
|
@ -111,8 +90,6 @@ 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.
|
||||
|
|
@ -202,65 +179,16 @@ export function ModelPickerDialog(props: Props) {
|
|||
|
||||
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
|
||||
|
||||
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;
|
||||
|
||||
const confirm = async () => {
|
||||
if (!canConfirm || !selectedProvider) return;
|
||||
if (standalone && onApply) {
|
||||
setApplying(true);
|
||||
try {
|
||||
const result = await onApply({
|
||||
confirmExpensiveModel,
|
||||
provider: providerSlug,
|
||||
model,
|
||||
persistGlobal: shouldPersistGlobal,
|
||||
await onApply({
|
||||
provider: selectedProvider.slug,
|
||||
model: selectedModel,
|
||||
persistGlobal,
|
||||
});
|
||||
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));
|
||||
|
|
@ -268,17 +196,14 @@ export function ModelPickerDialog(props: Props) {
|
|||
setApplying(false);
|
||||
}
|
||||
} else if (onSubmit) {
|
||||
const global = shouldPersistGlobal ? " --global" : "";
|
||||
onSubmit(`/model ${model} --provider ${providerSlug}${global}`);
|
||||
const global = persistGlobal ? " --global" : "";
|
||||
onSubmit(
|
||||
`/model ${selectedModel} --provider ${selectedProvider.slug}${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
|
||||
|
|
@ -355,12 +280,8 @@ export function ModelPickerDialog(props: Props) {
|
|||
onSelect={setSelectedModel}
|
||||
onConfirm={(m) => {
|
||||
setSelectedModel(m);
|
||||
void applySelection(false, {
|
||||
provider: selectedProvider?.slug ?? "",
|
||||
model: m,
|
||||
persistGlobal,
|
||||
message: "",
|
||||
});
|
||||
// Confirm on next tick so state settles.
|
||||
window.setTimeout(confirm, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -399,22 +320,6 @@ 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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
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,9 +20,6 @@ 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;
|
||||
|
|
@ -34,7 +31,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, profile, onClose, onChanged }: Props) {
|
||||
export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
|
||||
const { toast, showToast } = useToast();
|
||||
const [config, setConfig] = useState<ToolsetConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -63,7 +60,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
|
|||
// react-hooks/set-state-in-effect — setState only fires inside the
|
||||
// async .then/.catch/.finally callbacks.
|
||||
return api
|
||||
.getToolsetConfig(toolset.name, profile)
|
||||
.getToolsetConfig(toolset.name)
|
||||
.then((cfg) => {
|
||||
setConfig(cfg);
|
||||
setActiveProvider(cfg.active_provider);
|
||||
|
|
@ -75,7 +72,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
|
|||
})
|
||||
.catch(() => showToast("Failed to load toolset config", "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [toolset.name, profile, showToast]);
|
||||
}, [toolset.name, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
|
|
@ -124,7 +121,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
|
|||
const handleToggle = async (next: boolean) => {
|
||||
setToggling(true);
|
||||
try {
|
||||
await api.toggleToolset(toolset.name, next, profile);
|
||||
await api.toggleToolset(toolset.name, next);
|
||||
setEnabled(next);
|
||||
showToast(
|
||||
`${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`,
|
||||
|
|
@ -141,7 +138,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
|
|||
const handleSelectProvider = async (provider: ToolsetProvider) => {
|
||||
setSelecting(provider.name);
|
||||
try {
|
||||
await api.selectToolsetProvider(toolset.name, provider.name, profile);
|
||||
await api.selectToolsetProvider(toolset.name, provider.name);
|
||||
setActiveProvider(provider.name);
|
||||
showToast(`Provider set to ${provider.name}`, "success");
|
||||
onChanged();
|
||||
|
|
@ -167,7 +164,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
|
|||
}
|
||||
setSavingProvider(provider.name);
|
||||
try {
|
||||
const res = await api.saveToolsetEnv(toolset.name, env, profile);
|
||||
const res = await api.saveToolsetEnv(toolset.name, env);
|
||||
setIsSet((prev) => ({ ...prev, ...res.is_set }));
|
||||
// Clear saved drafts so the inputs reset to the "saved" placeholder.
|
||||
setDrafts((prev) => {
|
||||
|
|
@ -198,7 +195,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
|
|||
setPostSetupLog([]);
|
||||
setPostSetupKey(provider.post_setup);
|
||||
try {
|
||||
await api.runToolsetPostSetup(toolset.name, provider.post_setup, profile);
|
||||
await api.runToolsetPostSetup(toolset.name, provider.post_setup);
|
||||
// Bump the trigger so the poll effect (re)starts tailing the log.
|
||||
setPostSetupTrigger((n) => n + 1);
|
||||
} catch (e) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue