fix(plugins): re-export probe symbols + declare plugin deps in dashboard
- Hermes / OpenClaw plugin index.ts now re-export `probeHermesBinary` / `probeOpenClawBinary` and their status types so the dashboard's `runtime-provider-probes.ts` façade can import them via the public package entry instead of deep paths. - Dashboard `package.json` adds `@fusion-plugin-examples/hermes-runtime`, `…/openclaw-runtime`, `…/paperclip-runtime` as workspace deps so pnpm symlinks them into `packages/dashboard/node_modules/`. Without these, the new probe imports failed with "Cannot find module" during `pnpm typecheck`. This clears 6 of the 9 outstanding typecheck errors. The remaining 3 are in the in-flight Hermes plugin rewrite (runtime-adapter still imports from a deleted `./pi-module.js`; the new `index.ts` calls a factory with the wrong arg type) and should be resolved by the same change set that landed the rewrite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1205,6 +1205,197 @@ export function fetchClaudeCliStatus(): Promise<ClaudeCliStatus> {
|
||||
return api<ClaudeCliStatus>("/providers/claude-cli/status");
|
||||
}
|
||||
|
||||
// --- Runtime Provider Status Types ---
|
||||
|
||||
export interface RuntimeBinaryStatus {
|
||||
available: boolean;
|
||||
binaryPath?: string;
|
||||
version?: string;
|
||||
reason?: string;
|
||||
probeDurationMs: number;
|
||||
}
|
||||
|
||||
export interface PaperclipConnectionStatus {
|
||||
available: boolean;
|
||||
apiUrl: string;
|
||||
identity?: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
role?: string;
|
||||
companyId: string;
|
||||
companyName?: string;
|
||||
};
|
||||
reason?: string;
|
||||
probeDurationMs: number;
|
||||
}
|
||||
|
||||
export interface HermesProviderStatus {
|
||||
binary: RuntimeBinaryStatus;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export interface OpenClawProviderStatus {
|
||||
binary: RuntimeBinaryStatus;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export interface PaperclipProviderStatus {
|
||||
connection: PaperclipConnectionStatus;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
/** Probe the local Hermes binary. */
|
||||
export async function fetchHermesStatus(opts?: {
|
||||
binaryPath?: string;
|
||||
}): Promise<HermesProviderStatus> {
|
||||
const qs = opts?.binaryPath
|
||||
? `?binaryPath=${encodeURIComponent(opts.binaryPath)}`
|
||||
: "";
|
||||
return api<HermesProviderStatus>(`/providers/hermes/status${qs}`);
|
||||
}
|
||||
|
||||
export interface HermesProfileSummary {
|
||||
name: string;
|
||||
model?: string;
|
||||
gateway?: string;
|
||||
alias?: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/** List Hermes profiles from `hermes profile list`. Returns empty array on error. */
|
||||
export async function fetchHermesProfiles(opts?: {
|
||||
binaryPath?: string;
|
||||
}): Promise<HermesProfileSummary[]> {
|
||||
const qs = opts?.binaryPath ? `?binaryPath=${encodeURIComponent(opts.binaryPath)}` : "";
|
||||
const r = await api<{ profiles: HermesProfileSummary[]; error?: string }>(
|
||||
`/providers/hermes/profiles${qs}`,
|
||||
);
|
||||
return r.profiles ?? [];
|
||||
}
|
||||
|
||||
/** Probe the local OpenClaw binary. */
|
||||
export async function fetchOpenClawStatus(opts?: {
|
||||
binaryPath?: string;
|
||||
}): Promise<OpenClawProviderStatus> {
|
||||
const qs = opts?.binaryPath
|
||||
? `?binaryPath=${encodeURIComponent(opts.binaryPath)}`
|
||||
: "";
|
||||
return api<OpenClawProviderStatus>(`/providers/openclaw/status${qs}`);
|
||||
}
|
||||
|
||||
/** Probe the Paperclip API connection. */
|
||||
export async function fetchPaperclipStatus(opts: {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
}): Promise<PaperclipProviderStatus> {
|
||||
const params = new URLSearchParams({ apiUrl: opts.apiUrl });
|
||||
if (opts.apiKey) params.set("apiKey", opts.apiKey);
|
||||
return api<PaperclipProviderStatus>(
|
||||
`/providers/paperclip/status?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export interface PaperclipCompanySummary {
|
||||
id: string;
|
||||
name: string;
|
||||
urlKey?: string;
|
||||
}
|
||||
|
||||
export interface PaperclipAgentSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
companyId: string;
|
||||
status?: string;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
export interface PaperclipCliDiscoverySuccess {
|
||||
ok: true;
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
configPath: string;
|
||||
deploymentMode?: string;
|
||||
}
|
||||
|
||||
export interface PaperclipCliDiscoveryFailure {
|
||||
ok: false;
|
||||
reason: string;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export type PaperclipCliDiscoveryResult =
|
||||
| PaperclipCliDiscoverySuccess
|
||||
| PaperclipCliDiscoveryFailure;
|
||||
|
||||
/** List Paperclip companies visible to the bearer. Empty array on failure. */
|
||||
export async function fetchPaperclipCompanies(opts: {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
}): Promise<PaperclipCompanySummary[]> {
|
||||
const params = new URLSearchParams({ apiUrl: opts.apiUrl });
|
||||
if (opts.apiKey) params.set("apiKey", opts.apiKey);
|
||||
const r = await api<{ companies: PaperclipCompanySummary[] }>(
|
||||
`/providers/paperclip/companies?${params.toString()}`,
|
||||
);
|
||||
return r.companies ?? [];
|
||||
}
|
||||
|
||||
/** List agents in a Paperclip company. Empty array on failure. */
|
||||
export async function fetchPaperclipAgents(opts: {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
companyId: string;
|
||||
}): Promise<PaperclipAgentSummary[]> {
|
||||
const params = new URLSearchParams({
|
||||
apiUrl: opts.apiUrl,
|
||||
companyId: opts.companyId,
|
||||
});
|
||||
if (opts.apiKey) params.set("apiKey", opts.apiKey);
|
||||
const r = await api<{ agents: PaperclipAgentSummary[] }>(
|
||||
`/providers/paperclip/agents?${params.toString()}`,
|
||||
);
|
||||
return r.agents ?? [];
|
||||
}
|
||||
|
||||
export interface PaperclipMintKeyRequest {
|
||||
cliBinaryPath?: string;
|
||||
agentRef: string;
|
||||
/** Required by paperclipai agent local-cli (`-C/--company-id`). */
|
||||
companyId: string;
|
||||
keyName?: string;
|
||||
configPath?: string;
|
||||
dataDir?: string;
|
||||
}
|
||||
export type PaperclipMintKeyResult =
|
||||
| { ok: true; key: { apiKey: string; apiBase?: string; agentId?: string; companyId?: string } }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Mints a Paperclip agent API key via the local `paperclipai` CLI.
|
||||
* Always resolves (never rejects); on failure the result has `ok: false`.
|
||||
*/
|
||||
export async function mintPaperclipApiKey(
|
||||
body: PaperclipMintKeyRequest,
|
||||
): Promise<PaperclipMintKeyResult> {
|
||||
return api<PaperclipMintKeyResult>(`/providers/paperclip/cli-mint-key`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the local paperclipai config to discover apiUrl + deploymentMode. */
|
||||
export async function fetchPaperclipCliDiscovery(opts: {
|
||||
cliConfigPath?: string;
|
||||
} = {}): Promise<PaperclipCliDiscoveryResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
|
||||
const qs = params.toString();
|
||||
return api<PaperclipCliDiscoveryResult>(
|
||||
`/providers/paperclip/cli-discovery${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Enable or disable the Claude CLI provider. Refuses enable if binary is missing. */
|
||||
export function setClaudeCliEnabled(
|
||||
enabled: boolean,
|
||||
|
||||
@@ -433,7 +433,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
{/* State-dependent action buttons */}
|
||||
{agent.state === "idle" && (
|
||||
<>
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<Play size={14} />
|
||||
Start
|
||||
</button>
|
||||
@@ -450,7 +450,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "paused" && (
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<Play size={14} />
|
||||
Resume
|
||||
</button>
|
||||
@@ -469,7 +469,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
)}
|
||||
{agent.state === "error" && (
|
||||
<>
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<Play size={14} />
|
||||
Retry
|
||||
</button>
|
||||
@@ -481,7 +481,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
<button className="btn btn--primary btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<Play size={14} />
|
||||
Start
|
||||
</button>
|
||||
@@ -1193,7 +1193,7 @@ function RunsTab({
|
||||
{canRunHeartbeat && (
|
||||
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border-color)" }}>
|
||||
<button
|
||||
className="btn btn--sm btn--primary"
|
||||
className="btn btn--sm btn-task-create"
|
||||
onClick={() => void handleRunHeartbeat()}
|
||||
aria-label={`Run now for ${agentName ?? agentId}`}
|
||||
>
|
||||
@@ -1468,7 +1468,7 @@ function RunsTab({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn--sm btn--primary"
|
||||
className="btn btn--sm btn-task-create"
|
||||
onClick={() => void handleRunHeartbeat()}
|
||||
aria-label={`Run now for ${agentName ?? agentId}`}
|
||||
>
|
||||
@@ -1783,7 +1783,7 @@ function SoulTab({
|
||||
{!showPreview && (
|
||||
<div className="config-actions">
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create"
|
||||
disabled={!hasChanges || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
@@ -2104,7 +2104,7 @@ function MemoryTab({
|
||||
<div className="config-actions">
|
||||
{!showPreview && (
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create"
|
||||
disabled={!hasInlineChanges || isSaving || isReadOnly}
|
||||
onClick={() => void handleSaveInlineMemory()}
|
||||
>
|
||||
@@ -2362,7 +2362,7 @@ function InstructionsTab({
|
||||
{!showPreview && (
|
||||
<div className="config-actions">
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create"
|
||||
disabled={!hasInstructionsChanges || isSaving}
|
||||
onClick={() => void handleSaveInstructions()}
|
||||
>
|
||||
@@ -2431,7 +2431,7 @@ function InstructionsTab({
|
||||
|
||||
<div className="config-actions">
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create"
|
||||
disabled={!fileContentDirty || isSavingFile}
|
||||
onClick={() => void handleSaveFile()}
|
||||
>
|
||||
@@ -3578,7 +3578,7 @@ function ConfigTab({
|
||||
|
||||
<div className="config-actions">
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create"
|
||||
disabled={!hasChanges || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function AgentEmptyState({
|
||||
<p className="agent-empty-state__title">{title}</p>
|
||||
<p className="agent-empty-state__description text-secondary">{description}</p>
|
||||
{onCtaClick ? (
|
||||
<button type="button" className="btn btn--primary" onClick={onCtaClick}>
|
||||
<button type="button" className="btn btn-task-create btn-sm" onClick={onCtaClick}>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -305,7 +305,7 @@ export function AgentGenerationModal({
|
||||
</button>
|
||||
{view.type === "input" && (
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create"
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={!canGenerate}
|
||||
>
|
||||
@@ -320,7 +320,7 @@ export function AgentGenerationModal({
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
<button className="btn btn--primary" onClick={handleUseSpec}>
|
||||
<button className="btn btn-task-create" onClick={handleUseSpec}>
|
||||
Use This
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -908,7 +908,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
</button>
|
||||
{step === "input" && (
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create"
|
||||
onClick={() => void handleParse()}
|
||||
disabled={
|
||||
isParsing || (
|
||||
@@ -932,7 +932,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={isImporting || (selectedAgentCount === 0 && selectedSkillCount === 0)}
|
||||
>
|
||||
|
||||
@@ -289,7 +289,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create btn-sm"
|
||||
onClick={() => setIsCreating(!isCreating)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
@@ -320,7 +320,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn--primary" onClick={() => void handleCreate()}>
|
||||
<button className="btn btn-task-create btn-sm" onClick={() => void handleCreate()}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
@@ -650,7 +650,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm btn--primary"
|
||||
className="btn btn--sm btn-task-create"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Start"
|
||||
|
||||
@@ -429,7 +429,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create"
|
||||
disabled={newScore === 0 || isSubmittingRating}
|
||||
>
|
||||
{isSubmittingRating ? "Submitting..." : "Submit Rating"}
|
||||
|
||||
@@ -973,7 +973,7 @@
|
||||
|
||||
/* New Agent: icon-only on mobile, sized to match view-toggle buttons.
|
||||
Text is visually hidden via font-size: 0 (the SVG icon keeps its size). */
|
||||
.agents-view-primary-actions .btn--primary {
|
||||
.agents-view-primary-actions .btn-task-create {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
|
||||
@@ -777,7 +777,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
className="btn btn-task-create btn-sm"
|
||||
onClick={() => {
|
||||
setIsCreating(true);
|
||||
setIsControlsPanelOpen(false);
|
||||
|
||||
381
packages/dashboard/app/components/HermesRuntimeCard.tsx
Normal file
381
packages/dashboard/app/components/HermesRuntimeCard.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchHermesProfiles,
|
||||
fetchHermesStatus,
|
||||
fetchPluginSettings,
|
||||
updatePluginSettings,
|
||||
type HermesProfileSummary,
|
||||
type HermesProviderStatus,
|
||||
} from "../api";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { RuntimeCardShell } from "./RuntimeCardShell";
|
||||
|
||||
const PLUGIN_ID = "fusion-plugin-hermes-runtime";
|
||||
const HERMES_LEARN_MORE = "https://github.com/NousResearch/hermes-agent";
|
||||
|
||||
const HERMES_PROVIDER_OPTIONS = [
|
||||
"auto",
|
||||
"anthropic",
|
||||
"openrouter",
|
||||
"gemini",
|
||||
"openai-codex",
|
||||
"copilot",
|
||||
"copilot-acp",
|
||||
"huggingface",
|
||||
"zai",
|
||||
"kimi-coding",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"kilocode",
|
||||
"xiaomi",
|
||||
"nous",
|
||||
] as const;
|
||||
|
||||
interface HermesSettings {
|
||||
binaryPath: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
maxTurns: number;
|
||||
yolo: boolean;
|
||||
cliTimeoutMs: number;
|
||||
/** Hermes profile name; empty = "Auto / use Hermes default". */
|
||||
profile: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: HermesSettings = {
|
||||
binaryPath: "",
|
||||
model: "",
|
||||
provider: "auto",
|
||||
maxTurns: 12,
|
||||
yolo: false,
|
||||
cliTimeoutMs: 300_000,
|
||||
profile: "",
|
||||
};
|
||||
|
||||
function settingsFromRecord(raw: Record<string, unknown>): HermesSettings {
|
||||
return {
|
||||
binaryPath: typeof raw.binaryPath === "string" ? raw.binaryPath : DEFAULT_SETTINGS.binaryPath,
|
||||
model: typeof raw.model === "string" ? raw.model : DEFAULT_SETTINGS.model,
|
||||
provider: typeof raw.provider === "string" ? raw.provider : DEFAULT_SETTINGS.provider,
|
||||
maxTurns: typeof raw.maxTurns === "number" ? raw.maxTurns : DEFAULT_SETTINGS.maxTurns,
|
||||
yolo: typeof raw.yolo === "boolean" ? raw.yolo : DEFAULT_SETTINGS.yolo,
|
||||
cliTimeoutMs:
|
||||
typeof raw.cliTimeoutMs === "number" ? raw.cliTimeoutMs : DEFAULT_SETTINGS.cliTimeoutMs,
|
||||
profile: typeof raw.profile === "string" ? raw.profile : DEFAULT_SETTINGS.profile,
|
||||
};
|
||||
}
|
||||
|
||||
export function HermesRuntimeCard() {
|
||||
const [settings, setSettings] = useState<HermesSettings>(DEFAULT_SETTINGS);
|
||||
const [status, setStatus] = useState<HermesProviderStatus | null>(null);
|
||||
const [profiles, setProfiles] = useState<HermesProfileSummary[]>([]);
|
||||
const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null);
|
||||
const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setBusy("loading");
|
||||
fetchPluginSettings(PLUGIN_ID)
|
||||
.then((raw) => {
|
||||
if (mountedRef.current) setSettings(settingsFromRecord(raw));
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHermesProfiles(settings.binaryPath ? { binaryPath: settings.binaryPath } : {})
|
||||
.then((p) => { if (mountedRef.current) setProfiles(p); })
|
||||
.catch(() => undefined);
|
||||
}, [settings.binaryPath]);
|
||||
|
||||
const probe = useCallback(async (): Promise<HermesProviderStatus | null> => {
|
||||
try {
|
||||
const next = await fetchHermesStatus(
|
||||
settings.binaryPath ? { binaryPath: settings.binaryPath } : {},
|
||||
);
|
||||
if (mountedRef.current) setStatus(next);
|
||||
return next;
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [settings.binaryPath]);
|
||||
|
||||
useEffect(() => {
|
||||
void probe();
|
||||
}, [probe]);
|
||||
|
||||
const buildPayload = useCallback(
|
||||
(): Record<string, unknown> => ({
|
||||
binaryPath: settings.binaryPath,
|
||||
model: settings.model,
|
||||
provider: settings.provider,
|
||||
maxTurns: settings.maxTurns,
|
||||
yolo: settings.yolo,
|
||||
cliTimeoutMs: settings.cliTimeoutMs,
|
||||
profile: settings.profile,
|
||||
}),
|
||||
[settings],
|
||||
);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setBusy("testing");
|
||||
setToast(null);
|
||||
const next = await probe();
|
||||
if (!mountedRef.current) return;
|
||||
setBusy(null);
|
||||
if (!next) {
|
||||
setToast({ kind: "err", message: "Test failed — see status above." });
|
||||
} else if (next.binary.available) {
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `✓ hermes detected${next.binary.version ? ` (${next.binary.version})` : ""}${next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : ""}.`,
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ ${next.binary.reason ?? "hermes not found"}`,
|
||||
});
|
||||
}
|
||||
}, [probe]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setBusy("saving");
|
||||
setToast(null);
|
||||
try {
|
||||
await updatePluginSettings(PLUGIN_ID, buildPayload());
|
||||
if (mountedRef.current) setToast({ kind: "ok", message: "Settings saved." });
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload]);
|
||||
|
||||
const handleSaveAndTest = useCallback(async () => {
|
||||
setBusy("save-test");
|
||||
setToast(null);
|
||||
try {
|
||||
await updatePluginSettings(PLUGIN_ID, buildPayload());
|
||||
const next = await probe();
|
||||
if (!mountedRef.current) return;
|
||||
if (!next) {
|
||||
setToast({ kind: "err", message: "Saved, but probe failed." });
|
||||
} else if (next.binary.available) {
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `Saved · ✓ hermes detected${next.binary.version ? ` (${next.binary.version})` : ""}.`,
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `Saved · ✗ ${next.binary.reason ?? "hermes not found"}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload, probe]);
|
||||
|
||||
const binary = status?.binary;
|
||||
const statusKind = status === null
|
||||
? "loading"
|
||||
: binary?.available
|
||||
? "ok"
|
||||
: "err";
|
||||
const statusText =
|
||||
status === null
|
||||
? "Probing local hermes binary…"
|
||||
: binary?.available
|
||||
? `✓ Detected${binary.version ? ` ${binary.version}` : ""}${binary.binaryPath ? ` · ${binary.binaryPath}` : ""}`
|
||||
: `✗ ${binary?.reason ?? "not detected on PATH"}`;
|
||||
|
||||
return (
|
||||
<RuntimeCardShell
|
||||
testId="hermes-runtime-card"
|
||||
logo={<ProviderIcon provider="hermes" size="lg" />}
|
||||
name="Hermes"
|
||||
subname="by Nous Research"
|
||||
learnMoreHref={HERMES_LEARN_MORE}
|
||||
statusKind={statusKind}
|
||||
statusText={statusText}
|
||||
description={
|
||||
<>
|
||||
Drives the local <code>hermes</code> CLI as a subprocess. Each Fusion
|
||||
prompt is sent as <code>hermes chat -q …</code>; subsequent prompts
|
||||
resume the same hermes session via <code>--resume</code>. Provider,
|
||||
model, and skills are configured inside hermes itself; this card
|
||||
only chooses overrides.
|
||||
</>
|
||||
}
|
||||
busy={busy}
|
||||
toast={toast}
|
||||
onTest={() => void handleTest()}
|
||||
onSave={() => void handleSave()}
|
||||
onSaveAndTest={() => void handleSaveAndTest()}
|
||||
belowForm={
|
||||
binary?.available === false ? (
|
||||
<div className="onboarding-helper-text">
|
||||
<p>
|
||||
<code>hermes</code> not detected. Install the upstream agent:
|
||||
</p>
|
||||
<pre>
|
||||
<code>pipx install hermes-agent</code>
|
||||
</pre>
|
||||
<p>
|
||||
<a href={HERMES_LEARN_MORE} target="_blank" rel="noreferrer">
|
||||
Hermes on GitHub
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-profile">Profile (optional)</label>
|
||||
<select
|
||||
id="hermes-profile"
|
||||
value={settings.profile}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, profile: e.target.value }))}
|
||||
>
|
||||
<option value="">Auto / use Hermes default</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
{p.model ? ` — ${p.model}` : ""}
|
||||
{p.isDefault ? " (default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
Select a Hermes profile to use. Activates the profile by setting{" "}
|
||||
<code>HERMES_HOME</code> to the profile directory when invoking{" "}
|
||||
<code>hermes chat</code>.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-binary">Binary path</label>
|
||||
<input
|
||||
id="hermes-binary"
|
||||
type="text"
|
||||
placeholder="hermes (defaults to PATH)"
|
||||
value={settings.binaryPath}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, binaryPath: e.target.value }))}
|
||||
/>
|
||||
<small>
|
||||
Leave blank to resolve <code>hermes</code> from your PATH.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-model">Model override</label>
|
||||
<input
|
||||
id="hermes-model"
|
||||
type="text"
|
||||
placeholder="e.g. claude-sonnet-4-5, MiniMax-M2.7"
|
||||
value={settings.model}
|
||||
disabled={!!settings.profile}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))}
|
||||
/>
|
||||
{settings.profile ? (
|
||||
<small>Controlled by profile: <strong>{settings.profile}</strong></small>
|
||||
) : (
|
||||
<small>Optional — overrides Hermes's configured default model.</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-provider">Provider</label>
|
||||
<select
|
||||
id="hermes-provider"
|
||||
value={settings.provider}
|
||||
disabled={!!settings.profile}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, provider: e.target.value }))}
|
||||
>
|
||||
{HERMES_PROVIDER_OPTIONS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{settings.profile ? (
|
||||
<small>Controlled by profile: <strong>{settings.profile}</strong></small>
|
||||
) : (
|
||||
<small>Inference provider Hermes routes calls through (default: <code>auto</code>)).</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-maxTurns">Max turns</label>
|
||||
<input
|
||||
id="hermes-maxTurns"
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={settings.maxTurns}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
maxTurns: Number(e.target.value) || DEFAULT_SETTINGS.maxTurns,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<small>Cap per Hermes turn. Hermes's own default is 90; we cap lower.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label
|
||||
htmlFor="hermes-yolo"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-xs)" }}
|
||||
>
|
||||
<input
|
||||
id="hermes-yolo"
|
||||
type="checkbox"
|
||||
checked={settings.yolo}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, yolo: e.target.checked }))}
|
||||
/>
|
||||
Auto-approve dangerous tool calls (<code>--yolo</code>)
|
||||
</label>
|
||||
<small>Required for non-interactive sessions that trigger shell-style tools.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-timeoutMs">CLI hard-kill timeout (ms)</label>
|
||||
<input
|
||||
id="hermes-timeoutMs"
|
||||
type="number"
|
||||
min={1000}
|
||||
step={1000}
|
||||
value={settings.cliTimeoutMs}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
cliTimeoutMs: Number(e.target.value) || DEFAULT_SETTINGS.cliTimeoutMs,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<small>Fusion-side hard cap. Default 5 min.</small>
|
||||
</div>
|
||||
</RuntimeCardShell>
|
||||
);
|
||||
}
|
||||
362
packages/dashboard/app/components/OpenClawRuntimeCard.tsx
Normal file
362
packages/dashboard/app/components/OpenClawRuntimeCard.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchOpenClawStatus,
|
||||
fetchPluginSettings,
|
||||
updatePluginSettings,
|
||||
type OpenClawProviderStatus,
|
||||
} from "../api";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { RuntimeCardShell } from "./RuntimeCardShell";
|
||||
|
||||
const PLUGIN_ID = "fusion-plugin-openclaw-runtime";
|
||||
const OPENCLAW_LEARN_MORE = "https://docs.openclaw.ai/";
|
||||
const OPENCLAW_GITHUB = "https://github.com/openclaw/openclaw";
|
||||
|
||||
type ThinkingLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh"
|
||||
| "adaptive"
|
||||
| "max";
|
||||
|
||||
const THINKING_LEVEL_OPTIONS: ThinkingLevel[] = [
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"adaptive",
|
||||
"max",
|
||||
];
|
||||
|
||||
interface OpenClawSettings {
|
||||
binaryPath: string;
|
||||
agentId: string;
|
||||
model: string;
|
||||
thinking: ThinkingLevel;
|
||||
useGateway: boolean;
|
||||
cliTimeoutSec: number;
|
||||
cliTimeoutMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: OpenClawSettings = {
|
||||
binaryPath: "",
|
||||
agentId: "main",
|
||||
model: "",
|
||||
thinking: "off",
|
||||
useGateway: false,
|
||||
cliTimeoutSec: 0,
|
||||
cliTimeoutMs: 300_000,
|
||||
};
|
||||
|
||||
function settingsFromRecord(raw: Record<string, unknown>): OpenClawSettings {
|
||||
return {
|
||||
binaryPath:
|
||||
typeof raw.binaryPath === "string" ? raw.binaryPath : DEFAULT_SETTINGS.binaryPath,
|
||||
agentId:
|
||||
typeof raw.agentId === "string" ? raw.agentId : DEFAULT_SETTINGS.agentId,
|
||||
model: typeof raw.model === "string" ? raw.model : DEFAULT_SETTINGS.model,
|
||||
thinking: (THINKING_LEVEL_OPTIONS as string[]).includes(raw.thinking as string)
|
||||
? (raw.thinking as ThinkingLevel)
|
||||
: DEFAULT_SETTINGS.thinking,
|
||||
useGateway:
|
||||
typeof raw.useGateway === "boolean" ? raw.useGateway : DEFAULT_SETTINGS.useGateway,
|
||||
cliTimeoutSec:
|
||||
typeof raw.cliTimeoutSec === "number"
|
||||
? raw.cliTimeoutSec
|
||||
: DEFAULT_SETTINGS.cliTimeoutSec,
|
||||
cliTimeoutMs:
|
||||
typeof raw.cliTimeoutMs === "number"
|
||||
? raw.cliTimeoutMs
|
||||
: DEFAULT_SETTINGS.cliTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function OpenClawRuntimeCard() {
|
||||
const [settings, setSettings] = useState<OpenClawSettings>(DEFAULT_SETTINGS);
|
||||
const [status, setStatus] = useState<OpenClawProviderStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null);
|
||||
const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load saved settings on mount.
|
||||
useEffect(() => {
|
||||
setBusy("loading");
|
||||
fetchPluginSettings(PLUGIN_ID)
|
||||
.then((raw) => {
|
||||
if (mountedRef.current) setSettings(settingsFromRecord(raw));
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const probe = useCallback(async (): Promise<OpenClawProviderStatus | null> => {
|
||||
try {
|
||||
const next = await fetchOpenClawStatus(
|
||||
settings.binaryPath ? { binaryPath: settings.binaryPath } : undefined,
|
||||
);
|
||||
if (mountedRef.current) setStatus(next);
|
||||
return next;
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
return null;
|
||||
}
|
||||
}, [settings.binaryPath]);
|
||||
|
||||
// Initial probe.
|
||||
useEffect(() => {
|
||||
void probe();
|
||||
}, [probe]);
|
||||
|
||||
const buildPayload = useCallback(
|
||||
(): Record<string, unknown> => ({
|
||||
binaryPath: settings.binaryPath,
|
||||
agentId: settings.agentId,
|
||||
model: settings.model,
|
||||
thinking: settings.thinking,
|
||||
useGateway: settings.useGateway,
|
||||
cliTimeoutSec: settings.cliTimeoutSec,
|
||||
cliTimeoutMs: settings.cliTimeoutMs,
|
||||
}),
|
||||
[settings],
|
||||
);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setBusy("testing");
|
||||
setToast(null);
|
||||
const next = await probe();
|
||||
if (!mountedRef.current) return;
|
||||
setBusy(null);
|
||||
if (!next) {
|
||||
setToast({ kind: "err", message: "Test failed — see status above." });
|
||||
} else if (next.binary.available) {
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `✓ openclaw detected${next.binary.version ? ` (${next.binary.version})` : ""}${next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : ""}.`,
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ ${next.binary.reason ?? "openclaw not found"}`,
|
||||
});
|
||||
}
|
||||
}, [probe]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setBusy("saving");
|
||||
setToast(null);
|
||||
try {
|
||||
await updatePluginSettings(PLUGIN_ID, buildPayload());
|
||||
if (mountedRef.current) setToast({ kind: "ok", message: "Settings saved." });
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload]);
|
||||
|
||||
const handleSaveAndTest = useCallback(async () => {
|
||||
setBusy("save-test");
|
||||
setToast(null);
|
||||
try {
|
||||
await updatePluginSettings(PLUGIN_ID, buildPayload());
|
||||
const next = await probe();
|
||||
if (!mountedRef.current) return;
|
||||
if (!next) {
|
||||
setToast({ kind: "err", message: "Saved, but probe failed." });
|
||||
} else if (next.binary.available) {
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `Saved · ✓ openclaw detected${next.binary.version ? ` (${next.binary.version})` : ""}${next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : ""}.`,
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `Saved · ✗ ${next.binary.reason ?? "openclaw not found"}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload, probe]);
|
||||
|
||||
const binary = status?.binary;
|
||||
const statusKind =
|
||||
status === null ? "loading" : binary?.available ? "ok" : "err";
|
||||
const statusText =
|
||||
status === null
|
||||
? "Probing local openclaw binary…"
|
||||
: binary?.available
|
||||
? `✓ Detected${binary.version ? ` ${binary.version}` : ""}${binary.binaryPath ? ` · ${binary.binaryPath}` : ""}`
|
||||
: `✗ ${binary?.reason ?? "not detected on PATH"}`;
|
||||
|
||||
return (
|
||||
<RuntimeCardShell
|
||||
testId="openclaw-runtime-card"
|
||||
logo={<ProviderIcon provider="openclaw" size="lg" />}
|
||||
name="OpenClaw"
|
||||
learnMoreHref={OPENCLAW_LEARN_MORE}
|
||||
statusKind={statusKind}
|
||||
statusText={statusText}
|
||||
description={
|
||||
<>
|
||||
Drives the local <code>openclaw</code> CLI as a subprocess. Each
|
||||
Fusion prompt is dispatched via{" "}
|
||||
<code>openclaw agent --local --json</code>; the agent definition,
|
||||
model, and thinking level are resolved by OpenClaw. This card only
|
||||
sets overrides and the binary path.
|
||||
</>
|
||||
}
|
||||
busy={busy}
|
||||
toast={toast}
|
||||
onTest={() => void handleTest()}
|
||||
onSave={() => void handleSave()}
|
||||
onSaveAndTest={() => void handleSaveAndTest()}
|
||||
belowForm={
|
||||
binary?.available === false ? (
|
||||
<div className="onboarding-helper-text">
|
||||
<p>
|
||||
<code>openclaw</code> not detected. Install the upstream agent:
|
||||
</p>
|
||||
<pre>
|
||||
<code>npm install -g openclaw</code>
|
||||
</pre>
|
||||
<p>
|
||||
<a href={OPENCLAW_GITHUB} target="_blank" rel="noreferrer">
|
||||
openclaw on GitHub
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-binaryPath">Binary path</label>
|
||||
<input
|
||||
id="openclaw-binaryPath"
|
||||
type="text"
|
||||
placeholder="openclaw (defaults to PATH)"
|
||||
value={settings.binaryPath}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, binaryPath: e.target.value }))}
|
||||
/>
|
||||
<small>Leave blank to resolve <code>openclaw</code> from your PATH.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-agentId">Agent ID</label>
|
||||
<input
|
||||
id="openclaw-agentId"
|
||||
type="text"
|
||||
placeholder="main"
|
||||
value={settings.agentId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, agentId: e.target.value }))}
|
||||
/>
|
||||
<small>OpenClaw agent definition to run (default: <code>main</code>).</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-model">Model override</label>
|
||||
<input
|
||||
id="openclaw-model"
|
||||
type="text"
|
||||
placeholder="e.g. anthropic/claude-haiku-4-5, minimax/MiniMax-M2.7"
|
||||
value={settings.model}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))}
|
||||
/>
|
||||
<small>Optional — overrides the OpenClaw default model.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-thinking">Thinking level</label>
|
||||
<select
|
||||
id="openclaw-thinking"
|
||||
value={settings.thinking}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({ ...s, thinking: e.target.value as ThinkingLevel }))
|
||||
}
|
||||
>
|
||||
{THINKING_LEVEL_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>Controls how much extended thinking the model uses.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label
|
||||
htmlFor="openclaw-useGateway"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-xs)" }}
|
||||
>
|
||||
<input
|
||||
id="openclaw-useGateway"
|
||||
type="checkbox"
|
||||
checked={settings.useGateway}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, useGateway: e.target.checked }))}
|
||||
/>
|
||||
Route through OpenClaw gateway (otherwise embedded)
|
||||
</label>
|
||||
<small>When enabled, calls pass through the OpenClaw gateway service.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-cliTimeoutSec">OpenClaw timeout (sec)</label>
|
||||
<input
|
||||
id="openclaw-cliTimeoutSec"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={settings.cliTimeoutSec}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
cliTimeoutSec: parseInt(e.target.value, 10) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<small>OpenClaw-side run timeout in seconds (0 = no timeout).</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-cliTimeoutMs">CLI subprocess timeout (ms)</label>
|
||||
<input
|
||||
id="openclaw-cliTimeoutMs"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
value={settings.cliTimeoutMs}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
cliTimeoutMs:
|
||||
parseInt(e.target.value, 10) || DEFAULT_SETTINGS.cliTimeoutMs,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
Fusion-side hard cap before the CLI subprocess is killed (default:{" "}
|
||||
{DEFAULT_SETTINGS.cliTimeoutMs / 1000}s).
|
||||
</small>
|
||||
</div>
|
||||
</RuntimeCardShell>
|
||||
);
|
||||
}
|
||||
731
packages/dashboard/app/components/PaperclipRuntimeCard.tsx
Normal file
731
packages/dashboard/app/components/PaperclipRuntimeCard.tsx
Normal file
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* Paperclip runtime settings card.
|
||||
*
|
||||
* Two transport modes:
|
||||
* - "api": user supplies `apiUrl` + `apiKey` directly.
|
||||
* - "cli": auto-derives `apiUrl` (and, for local-trusted, `apiKey`) from the
|
||||
* local `paperclipai` instance config (default
|
||||
* `~/.paperclip/instances/default/config.json`).
|
||||
*
|
||||
* After a connection is established the card auto-loads the company list (or
|
||||
* the single company the agent key is scoped to) and the agent list inside
|
||||
* the chosen company so the user picks a real agent rather than typing IDs.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
fetchPaperclipAgents,
|
||||
fetchPaperclipCliDiscovery,
|
||||
fetchPaperclipCompanies,
|
||||
fetchPaperclipStatus,
|
||||
fetchPluginSettings,
|
||||
mintPaperclipApiKey,
|
||||
updatePluginSettings,
|
||||
type PaperclipAgentSummary,
|
||||
type PaperclipCliDiscoveryResult,
|
||||
type PaperclipCompanySummary,
|
||||
type PaperclipProviderStatus,
|
||||
} from "../api";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { RuntimeCardShell } from "./RuntimeCardShell";
|
||||
|
||||
const PLUGIN_ID = "fusion-plugin-paperclip-runtime";
|
||||
const PAPERCLIP_LEARN_MORE = "https://paperclip.ing/";
|
||||
const PAPERCLIP_GITHUB = "https://github.com/paperclipai/paperclip";
|
||||
|
||||
type PaperclipMode = "issue-per-prompt" | "rolling-issue" | "wakeup-only";
|
||||
type Transport = "api" | "cli";
|
||||
|
||||
const MODE_OPTIONS: { value: PaperclipMode; label: string; help: string }[] = [
|
||||
{
|
||||
value: "rolling-issue",
|
||||
label: "Rolling issue (default)",
|
||||
help: "One Paperclip issue per Fusion session; subsequent prompts are added as comments. Closest to a chat experience.",
|
||||
},
|
||||
{
|
||||
value: "issue-per-prompt",
|
||||
label: "Issue per prompt",
|
||||
help: "Each prompt creates a new top-level Paperclip issue. Maximally explicit; tends to clutter the board.",
|
||||
},
|
||||
{
|
||||
value: "wakeup-only",
|
||||
label: "Wakeup only (advanced)",
|
||||
help: "No issue side-effects; the prompt is delivered via the wakeup payload only. Requires the agent's prompt template to know how to handle a payload-driven wake.",
|
||||
},
|
||||
];
|
||||
|
||||
interface PaperclipSettings {
|
||||
transport: Transport;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
cliBinaryPath: string;
|
||||
cliConfigPath: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
mode: PaperclipMode;
|
||||
parentIssueId: string;
|
||||
projectId: string;
|
||||
goalId: string;
|
||||
runTimeoutMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PaperclipSettings = {
|
||||
transport: "api",
|
||||
apiUrl: "http://localhost:3100",
|
||||
apiKey: "",
|
||||
cliBinaryPath: "paperclipai",
|
||||
cliConfigPath: "",
|
||||
agentId: "",
|
||||
companyId: "",
|
||||
mode: "rolling-issue",
|
||||
parentIssueId: "",
|
||||
projectId: "",
|
||||
goalId: "",
|
||||
runTimeoutMs: 600_000,
|
||||
};
|
||||
|
||||
const VALID_MODES = new Set<PaperclipMode>([
|
||||
"issue-per-prompt",
|
||||
"rolling-issue",
|
||||
"wakeup-only",
|
||||
]);
|
||||
|
||||
function settingsFromRecord(raw: Record<string, unknown>): PaperclipSettings {
|
||||
const str = (k: string, fallback: string): string =>
|
||||
typeof raw[k] === "string" ? (raw[k] as string) : fallback;
|
||||
const num = (k: string, fallback: number): number =>
|
||||
typeof raw[k] === "number" ? (raw[k] as number) : fallback;
|
||||
const transport: Transport = raw.transport === "cli" ? "cli" : "api";
|
||||
const mode: PaperclipMode = VALID_MODES.has(raw.mode as PaperclipMode)
|
||||
? (raw.mode as PaperclipMode)
|
||||
: DEFAULT_SETTINGS.mode;
|
||||
return {
|
||||
transport,
|
||||
apiUrl: str("apiUrl", DEFAULT_SETTINGS.apiUrl),
|
||||
apiKey: str("apiKey", DEFAULT_SETTINGS.apiKey),
|
||||
cliBinaryPath: str("cliBinaryPath", DEFAULT_SETTINGS.cliBinaryPath),
|
||||
cliConfigPath: str("cliConfigPath", DEFAULT_SETTINGS.cliConfigPath),
|
||||
agentId: str("agentId", DEFAULT_SETTINGS.agentId),
|
||||
companyId: str("companyId", DEFAULT_SETTINGS.companyId),
|
||||
mode,
|
||||
parentIssueId: str("parentIssueId", DEFAULT_SETTINGS.parentIssueId),
|
||||
projectId: str("projectId", DEFAULT_SETTINGS.projectId),
|
||||
goalId: str("goalId", DEFAULT_SETTINGS.goalId),
|
||||
runTimeoutMs: num("runTimeoutMs", DEFAULT_SETTINGS.runTimeoutMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function PaperclipRuntimeCard() {
|
||||
const [settings, setSettings] = useState<PaperclipSettings>(DEFAULT_SETTINGS);
|
||||
const [status, setStatus] = useState<PaperclipProviderStatus | null>(null);
|
||||
const [cliDiscovery, setCliDiscovery] =
|
||||
useState<PaperclipCliDiscoveryResult | null>(null);
|
||||
const [companies, setCompanies] = useState<PaperclipCompanySummary[]>([]);
|
||||
const [agents, setAgents] = useState<PaperclipAgentSummary[]>([]);
|
||||
const [busy, setBusy] = useState<
|
||||
"loading" | "saving" | "testing" | "save-test" | null
|
||||
>(null);
|
||||
const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null);
|
||||
const [apiKeyDirty, setApiKeyDirty] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The "effective" apiUrl/apiKey used for status + dropdowns.
|
||||
// In CLI mode we prefer whatever cliDiscovery returned, falling back to the
|
||||
// typed apiUrl if discovery hasn't completed yet.
|
||||
const effectiveAuth = useMemo<{ apiUrl: string; apiKey?: string }>(() => {
|
||||
if (settings.transport === "cli" && cliDiscovery && cliDiscovery.ok) {
|
||||
return {
|
||||
apiUrl: cliDiscovery.apiUrl,
|
||||
apiKey: settings.apiKey || cliDiscovery.apiKey || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
apiUrl: settings.apiUrl,
|
||||
apiKey: settings.apiKey || undefined,
|
||||
};
|
||||
}, [settings.transport, settings.apiUrl, settings.apiKey, cliDiscovery]);
|
||||
|
||||
// Load saved settings on mount.
|
||||
useEffect(() => {
|
||||
setBusy("loading");
|
||||
fetchPluginSettings(PLUGIN_ID)
|
||||
.then((raw) => {
|
||||
if (mountedRef.current) setSettings(settingsFromRecord(raw));
|
||||
})
|
||||
.catch(() => {
|
||||
// Defaults remain.
|
||||
})
|
||||
.finally(() => {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// CLI discovery whenever transport=cli (and cliConfigPath changes).
|
||||
useEffect(() => {
|
||||
if (settings.transport !== "cli") {
|
||||
setCliDiscovery(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetchPaperclipCliDiscovery({
|
||||
cliConfigPath: settings.cliConfigPath || undefined,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled && mountedRef.current) setCliDiscovery(r);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && mountedRef.current)
|
||||
setCliDiscovery({ ok: false, reason: "Discovery request failed" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [settings.transport, settings.cliConfigPath]);
|
||||
|
||||
// Probe + load companies/agents whenever effective auth changes.
|
||||
const probe = useCallback(async (): Promise<PaperclipProviderStatus | null> => {
|
||||
if (!effectiveAuth.apiUrl) return null;
|
||||
try {
|
||||
const next = await fetchPaperclipStatus(effectiveAuth);
|
||||
if (mountedRef.current) setStatus(next);
|
||||
// Then load companies + agents for the picker.
|
||||
const cs = await fetchPaperclipCompanies(effectiveAuth);
|
||||
if (!mountedRef.current) return next;
|
||||
setCompanies(cs);
|
||||
// Auto-pick a company: keep current selection if it exists in cs;
|
||||
// otherwise default to identity's company; otherwise the first one.
|
||||
let picked = settings.companyId;
|
||||
if (!cs.some((c) => c.id === picked)) {
|
||||
picked =
|
||||
next.connection.identity?.companyId ?? (cs[0]?.id ?? "");
|
||||
if (picked && picked !== settings.companyId) {
|
||||
setSettings((s) => ({ ...s, companyId: picked }));
|
||||
}
|
||||
}
|
||||
if (picked) {
|
||||
const ag = await fetchPaperclipAgents({
|
||||
...effectiveAuth,
|
||||
companyId: picked,
|
||||
});
|
||||
if (mountedRef.current) {
|
||||
setAgents(ag);
|
||||
// Auto-pick agent the same way.
|
||||
if (!ag.some((a) => a.id === settings.agentId)) {
|
||||
const nextAgent =
|
||||
next.connection.identity?.agentId ?? (ag[0]?.id ?? "");
|
||||
if (nextAgent && nextAgent !== settings.agentId) {
|
||||
setSettings((s) => ({ ...s, agentId: nextAgent }));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mountedRef.current) setAgents([]);
|
||||
}
|
||||
return next;
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
return null;
|
||||
}
|
||||
// Deliberately keyed only on the URL/key — companyId/agentId changes are
|
||||
// handled by a separate effect below.
|
||||
}, [effectiveAuth.apiUrl, effectiveAuth.apiKey, settings.companyId, settings.agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void probe();
|
||||
}, [probe]);
|
||||
|
||||
// Reload agent list when the user manually changes companyId.
|
||||
useEffect(() => {
|
||||
if (!effectiveAuth.apiUrl || !settings.companyId) {
|
||||
setAgents([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetchPaperclipAgents({
|
||||
...effectiveAuth,
|
||||
companyId: settings.companyId,
|
||||
})
|
||||
.then((ag) => {
|
||||
if (!cancelled && mountedRef.current) setAgents(ag);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && mountedRef.current) setAgents([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveAuth.apiUrl, effectiveAuth.apiKey, settings.companyId]);
|
||||
|
||||
const buildPayload = useCallback((): Record<string, unknown> => {
|
||||
const payload: Record<string, unknown> = {
|
||||
transport: settings.transport,
|
||||
mode: settings.mode,
|
||||
parentIssueId: settings.parentIssueId,
|
||||
projectId: settings.projectId,
|
||||
goalId: settings.goalId,
|
||||
runTimeoutMs: settings.runTimeoutMs,
|
||||
agentId: settings.agentId,
|
||||
companyId: settings.companyId,
|
||||
};
|
||||
if (settings.transport === "api") {
|
||||
payload.apiUrl = settings.apiUrl;
|
||||
} else {
|
||||
payload.cliBinaryPath = settings.cliBinaryPath;
|
||||
if (settings.cliConfigPath) payload.cliConfigPath = settings.cliConfigPath;
|
||||
}
|
||||
if (apiKeyDirty) payload.apiKey = settings.apiKey;
|
||||
return payload;
|
||||
}, [settings, apiKeyDirty]);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setBusy("testing");
|
||||
setToast(null);
|
||||
const next = await probe();
|
||||
if (!mountedRef.current) return;
|
||||
setBusy(null);
|
||||
if (!next) {
|
||||
if (settings.transport === "cli" && cliDiscovery && !cliDiscovery.ok) {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ CLI discovery failed: ${cliDiscovery.reason}`,
|
||||
});
|
||||
} else {
|
||||
setToast({ kind: "err", message: "Test failed — see status above." });
|
||||
}
|
||||
} else if (next.connection.available) {
|
||||
const id = next.connection.identity;
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: id
|
||||
? `✓ Connected as ${id.agentName}${id.companyName ? ` at ${id.companyName}` : ""}.`
|
||||
: "✓ Connected.",
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ ${next.connection.reason ?? "Paperclip server unreachable"}`,
|
||||
});
|
||||
}
|
||||
}, [probe, settings.transport, cliDiscovery]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setBusy("saving");
|
||||
setToast(null);
|
||||
try {
|
||||
await updatePluginSettings(PLUGIN_ID, buildPayload());
|
||||
if (mountedRef.current) {
|
||||
setToast({ kind: "ok", message: "Settings saved." });
|
||||
setApiKeyDirty(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload]);
|
||||
|
||||
const handleSaveAndTest = useCallback(async () => {
|
||||
setBusy("save-test");
|
||||
setToast(null);
|
||||
try {
|
||||
await updatePluginSettings(PLUGIN_ID, buildPayload());
|
||||
if (mountedRef.current) setApiKeyDirty(false);
|
||||
const next = await probe();
|
||||
if (!mountedRef.current) return;
|
||||
if (!next) {
|
||||
if (settings.transport === "cli" && cliDiscovery && !cliDiscovery.ok) {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `Saved · ✗ CLI discovery failed: ${cliDiscovery.reason}`,
|
||||
});
|
||||
} else {
|
||||
setToast({ kind: "err", message: "Saved, but probe failed." });
|
||||
}
|
||||
} else if (next.connection.available) {
|
||||
const id = next.connection.identity;
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: id
|
||||
? `Saved · ✓ Connected as ${id.agentName}${id.companyName ? ` at ${id.companyName}` : ""}.`
|
||||
: "Saved · ✓ Connected.",
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `Saved · ✗ ${next.connection.reason ?? "Paperclip server unreachable"}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload, probe, settings.transport, cliDiscovery]);
|
||||
|
||||
const connected = status?.connection.available ?? null;
|
||||
const identity = status?.connection.identity;
|
||||
const cliOk = cliDiscovery?.ok === true;
|
||||
|
||||
const statusKind =
|
||||
status === null
|
||||
? "loading"
|
||||
: connected
|
||||
? "ok"
|
||||
: "err";
|
||||
|
||||
const statusText =
|
||||
status === null
|
||||
? settings.transport === "cli" && cliDiscovery && !cliOk
|
||||
? `✗ CLI discovery failed: ${cliDiscovery.reason}`
|
||||
: "Probing Paperclip server…"
|
||||
: connected
|
||||
? identity
|
||||
? `✓ Connected as ${identity.agentName}${identity.role ? ` (${identity.role})` : ""}${identity.companyName ? ` at ${identity.companyName}` : ""}`
|
||||
: "✓ Connected"
|
||||
: `✗ ${status.connection.reason ?? "Unreachable"}`;
|
||||
|
||||
const tabs = (
|
||||
<div
|
||||
className="runtime-card__tabs"
|
||||
role="tablist"
|
||||
aria-label="Paperclip connection mode"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={settings.transport === "api"}
|
||||
className="runtime-card__tab"
|
||||
onClick={() => setSettings((s) => ({ ...s, transport: "api" }))}
|
||||
>
|
||||
API (URL + token)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={settings.transport === "cli"}
|
||||
className="runtime-card__tab"
|
||||
onClick={() => setSettings((s) => ({ ...s, transport: "cli" }))}
|
||||
>
|
||||
Local CLI (auto-derive)
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<RuntimeCardShell
|
||||
testId="paperclip-runtime-card"
|
||||
logo={<ProviderIcon provider="paperclip" size="lg" />}
|
||||
name="Paperclip"
|
||||
learnMoreHref={PAPERCLIP_LEARN_MORE}
|
||||
statusKind={statusKind}
|
||||
statusText={statusText}
|
||||
description={
|
||||
<>
|
||||
Drive a Paperclip agent ("employee") in a Paperclip company. Each
|
||||
prompt dispatches a task-shaped request; governance, budgets, and
|
||||
approvals are enforced by Paperclip. Expect seconds-to-minutes
|
||||
latency per turn.
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
busy={busy}
|
||||
toast={toast}
|
||||
onTest={() => void handleTest()}
|
||||
onSave={() => void handleSave()}
|
||||
onSaveAndTest={() => void handleSaveAndTest()}
|
||||
belowForm={
|
||||
connected === false ? (
|
||||
<div className="onboarding-helper-text">
|
||||
<p>Make sure a Paperclip server is running. To install Paperclip:</p>
|
||||
<pre>
|
||||
<code>npm install -g paperclipai</code>
|
||||
</pre>
|
||||
<p>
|
||||
<a href={PAPERCLIP_LEARN_MORE} target="_blank" rel="noreferrer">
|
||||
Paperclip docs
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a href={PAPERCLIP_GITHUB} target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{/* CLI discovery banner */}
|
||||
{settings.transport === "cli" && cliDiscovery && (
|
||||
<div className="settings-muted" style={{ marginBottom: "var(--space-sm)" }}>
|
||||
{cliOk ? (
|
||||
<small>
|
||||
CLI config: <code>{(cliDiscovery as { configPath: string }).configPath}</code>{" "}
|
||||
· resolved <code>{cliDiscovery.apiUrl}</code>
|
||||
{(cliDiscovery as { deploymentMode?: string }).deploymentMode
|
||||
? ` · ${(cliDiscovery as { deploymentMode?: string }).deploymentMode}`
|
||||
: ""}
|
||||
</small>
|
||||
) : (
|
||||
<small>CLI discovery failed: {cliDiscovery.reason}</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API mode fields */}
|
||||
{settings.transport === "api" && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-apiUrl">API URL</label>
|
||||
<input
|
||||
id="paperclip-apiUrl"
|
||||
type="text"
|
||||
placeholder="http://localhost:3100"
|
||||
value={settings.apiUrl}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, apiUrl: e.target.value }))}
|
||||
/>
|
||||
<small>Base URL of the Paperclip server.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-apiKey">API key</label>
|
||||
<input
|
||||
id="paperclip-apiKey"
|
||||
type="password"
|
||||
placeholder={apiKeyDirty ? "" : "•••••••• (leave blank to keep existing)"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => {
|
||||
setSettings((s) => ({ ...s, apiKey: e.target.value }));
|
||||
setApiKeyDirty(true);
|
||||
}}
|
||||
/>
|
||||
<small>Agent API key. Local-trusted deployments may leave this blank.</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* CLI mode fields */}
|
||||
{settings.transport === "cli" && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-cliBinaryPath">paperclipai binary</label>
|
||||
<input
|
||||
id="paperclip-cliBinaryPath"
|
||||
type="text"
|
||||
placeholder="paperclipai"
|
||||
value={settings.cliBinaryPath}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({ ...s, cliBinaryPath: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
Optional — informational; the adapter currently reads the instance
|
||||
config file directly.
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-cliConfigPath">Instance config path</label>
|
||||
<input
|
||||
id="paperclip-cliConfigPath"
|
||||
type="text"
|
||||
placeholder="~/.paperclip/instances/default/config.json"
|
||||
value={settings.cliConfigPath}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({ ...s, cliConfigPath: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
Override the path to <code>config.json</code>. Leave blank for the
|
||||
default.
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-cli-apikey">API key (override, optional)</label>
|
||||
<input
|
||||
id="paperclip-cli-apikey"
|
||||
type="password"
|
||||
placeholder={
|
||||
apiKeyDirty ? "" : "Optional — only required for non-local-trusted modes"
|
||||
}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => {
|
||||
setSettings((s) => ({ ...s, apiKey: e.target.value }));
|
||||
setApiKeyDirty(true);
|
||||
}}
|
||||
/>
|
||||
<small>Local-trusted deployments do not require a key.</small>
|
||||
{/* Mint button: show when CLI mode, connection attempted but unavailable, and agent selected */}
|
||||
{status !== null && connected === false && settings.agentId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
style={{ marginTop: "0.4rem" }}
|
||||
disabled={busy !== null}
|
||||
onClick={async () => {
|
||||
setBusy("testing");
|
||||
setToast(null);
|
||||
const result = await mintPaperclipApiKey({
|
||||
cliBinaryPath: settings.cliBinaryPath || undefined,
|
||||
agentRef: settings.agentId,
|
||||
companyId: settings.companyId || undefined,
|
||||
keyName: "fusion-runtime",
|
||||
configPath: settings.cliConfigPath || undefined,
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
setBusy(null);
|
||||
if (result.ok) {
|
||||
setSettings((s) => ({ ...s, apiKey: result.key.apiKey }));
|
||||
setApiKeyDirty(true);
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `✓ API key minted via paperclipai (key 'fusion-runtime' installed for agent ${settings.agentId}). Click Save to persist.`,
|
||||
});
|
||||
void probe();
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ Mint failed: ${result.reason}. Run \`paperclipai onboard\` first if your CLI isn't authenticated.`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
✨ Mint API key via paperclipai
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Company picker */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-companyId">Company</label>
|
||||
<select
|
||||
id="paperclip-companyId"
|
||||
value={settings.companyId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, companyId: e.target.value }))}
|
||||
disabled={companies.length === 0}
|
||||
>
|
||||
{companies.length === 0 ? (
|
||||
<option value="">
|
||||
{connected ? "No companies discovered" : "Connect to populate"}
|
||||
</option>
|
||||
) : (
|
||||
companies.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.id})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<small>Select a Paperclip company.</small>
|
||||
</div>
|
||||
|
||||
{/* Agent picker */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-agentId">Agent</label>
|
||||
<select
|
||||
id="paperclip-agentId"
|
||||
value={settings.agentId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, agentId: e.target.value }))}
|
||||
disabled={agents.length === 0}
|
||||
>
|
||||
{agents.length === 0 ? (
|
||||
<option value="">
|
||||
{settings.companyId ? "No agents discovered" : "Pick a company first"}
|
||||
</option>
|
||||
) : (
|
||||
agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{a.role ? ` (${a.role})` : ""}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<small>Pick the Paperclip agent this Fusion runtime will proxy.</small>
|
||||
</div>
|
||||
|
||||
{/* Conversation mode */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-mode">Conversation mode</label>
|
||||
<select
|
||||
id="paperclip-mode"
|
||||
value={settings.mode}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({ ...s, mode: e.target.value as PaperclipMode }))
|
||||
}
|
||||
>
|
||||
{MODE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>{MODE_OPTIONS.find((o) => o.value === settings.mode)?.help}</small>
|
||||
</div>
|
||||
|
||||
{/* Optional scoping */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-projectId">Project ID (optional)</label>
|
||||
<input
|
||||
id="paperclip-projectId"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
value={settings.projectId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, projectId: e.target.value }))}
|
||||
/>
|
||||
<small>Pin work to a specific Paperclip project.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-parentIssueId">Parent issue ID (optional)</label>
|
||||
<input
|
||||
id="paperclip-parentIssueId"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
value={settings.parentIssueId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, parentIssueId: e.target.value }))}
|
||||
/>
|
||||
<small>Scope work under an existing parent issue.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-goalId">Goal ID (optional)</label>
|
||||
<input
|
||||
id="paperclip-goalId"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
value={settings.goalId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, goalId: e.target.value }))}
|
||||
/>
|
||||
<small>Associate work with a Paperclip goal.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-runTimeoutMs">Run timeout (ms)</label>
|
||||
<input
|
||||
id="paperclip-runTimeoutMs"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
value={settings.runTimeoutMs}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
runTimeoutMs: parseInt(e.target.value, 10) || DEFAULT_SETTINGS.runTimeoutMs,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<small>Local cap before Fusion gives up on a Paperclip run.</small>
|
||||
</div>
|
||||
</RuntimeCardShell>
|
||||
);
|
||||
}
|
||||
@@ -488,6 +488,96 @@ function GitHubIcon({ size, color, label = "GitHub" }: { size: number; color: st
|
||||
);
|
||||
}
|
||||
|
||||
// Hermes — caduceus (winged staff) mark, single-color so it adapts to theme.
|
||||
function HermesIcon({ size, color, label = "Hermes" }: { size: number; color: string; label?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label={label}>
|
||||
<rect x="30" y="10" width="4" height="46" rx="2" fill={color} />
|
||||
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill={color} opacity="0.9" />
|
||||
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill={color} opacity="0.7" />
|
||||
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill={color} opacity="0.9" />
|
||||
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill={color} opacity="0.7" />
|
||||
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" opacity="0.8" />
|
||||
<circle cx="32" cy="10" r="4" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// OpenClaw — pixel-art lobster (verbatim 16×16 source SVG, recolor disabled
|
||||
// so the iconic palette survives).
|
||||
function OpenClawIcon({ size, label = "OpenClaw" }: { size: number; color: string; label?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" role="img" aria-label={label}>
|
||||
<rect width="16" height="16" fill="none" />
|
||||
<g fill="#3a0a0d">
|
||||
<rect x="1" y="5" width="1" height="3" />
|
||||
<rect x="2" y="4" width="1" height="1" />
|
||||
<rect x="2" y="8" width="1" height="1" />
|
||||
<rect x="3" y="3" width="1" height="1" />
|
||||
<rect x="3" y="9" width="1" height="1" />
|
||||
<rect x="4" y="2" width="1" height="1" />
|
||||
<rect x="4" y="10" width="1" height="1" />
|
||||
<rect x="5" y="2" width="6" height="1" />
|
||||
<rect x="11" y="2" width="1" height="1" />
|
||||
<rect x="12" y="3" width="1" height="1" />
|
||||
<rect x="12" y="9" width="1" height="1" />
|
||||
<rect x="13" y="4" width="1" height="1" />
|
||||
<rect x="13" y="8" width="1" height="1" />
|
||||
<rect x="14" y="5" width="1" height="3" />
|
||||
<rect x="5" y="11" width="6" height="1" />
|
||||
<rect x="4" y="12" width="1" height="1" />
|
||||
<rect x="11" y="12" width="1" height="1" />
|
||||
<rect x="3" y="13" width="1" height="1" />
|
||||
<rect x="12" y="13" width="1" height="1" />
|
||||
<rect x="5" y="14" width="6" height="1" />
|
||||
</g>
|
||||
<g fill="#ff4f40">
|
||||
<rect x="5" y="3" width="6" height="1" />
|
||||
<rect x="4" y="4" width="8" height="1" />
|
||||
<rect x="3" y="5" width="10" height="1" />
|
||||
<rect x="3" y="6" width="10" height="1" />
|
||||
<rect x="3" y="7" width="10" height="1" />
|
||||
<rect x="4" y="8" width="8" height="1" />
|
||||
<rect x="5" y="9" width="6" height="1" />
|
||||
<rect x="5" y="12" width="6" height="1" />
|
||||
<rect x="6" y="13" width="4" height="1" />
|
||||
</g>
|
||||
<g fill="#ff775f">
|
||||
<rect x="1" y="6" width="2" height="1" />
|
||||
<rect x="2" y="5" width="1" height="1" />
|
||||
<rect x="2" y="7" width="1" height="1" />
|
||||
<rect x="13" y="6" width="2" height="1" />
|
||||
<rect x="13" y="5" width="1" height="1" />
|
||||
<rect x="13" y="7" width="1" height="1" />
|
||||
</g>
|
||||
<g fill="#081016">
|
||||
<rect x="6" y="5" width="1" height="1" />
|
||||
<rect x="9" y="5" width="1" height="1" />
|
||||
</g>
|
||||
<g fill="#f5fbff">
|
||||
<rect x="6" y="4" width="1" height="1" />
|
||||
<rect x="9" y="4" width="1" height="1" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Paperclip — official paperclip outline, theme-color aware.
|
||||
function PaperclipIcon({ size, color, label = "Paperclip" }: { size: number; color: string; label?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label={label}>
|
||||
<path
|
||||
d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Anthropic "A" mark composited with a small terminal "> _" badge in the
|
||||
// bottom-right, visually signalling "Anthropic, but via the local CLI".
|
||||
function ClaudeCliIcon({ size, color, label = "Anthropic — via Claude CLI" }: { size: number; color: string; label?: string }) {
|
||||
@@ -600,6 +690,16 @@ const providerConfig: Record<
|
||||
|
||||
vercel: { component: VercelIcon, color: "var(--provider-vercel)" },
|
||||
"vercel-ai-gateway": { component: VercelIcon, color: "var(--provider-vercel)", label: "Vercel AI Gateway" },
|
||||
|
||||
// Runtime-plugin marks (Hermes / OpenClaw / Paperclip).
|
||||
hermes: { component: HermesIcon, color: "var(--provider-hermes)", label: "Hermes" },
|
||||
"hermes-agent": { component: HermesIcon, color: "var(--provider-hermes)", label: "Hermes" },
|
||||
hermesagent: { component: HermesIcon, color: "var(--provider-hermes)", label: "Hermes" },
|
||||
openclaw: { component: OpenClawIcon, color: "var(--provider-openclaw)", label: "OpenClaw" },
|
||||
"open-claw": { component: OpenClawIcon, color: "var(--provider-openclaw)", label: "OpenClaw" },
|
||||
paperclip: { component: PaperclipIcon, color: "var(--provider-paperclip)", label: "Paperclip" },
|
||||
paperclipai: { component: PaperclipIcon, color: "var(--provider-paperclip)", label: "Paperclip" },
|
||||
"paperclip-ai": { component: PaperclipIcon, color: "var(--provider-paperclip)", label: "Paperclip" },
|
||||
};
|
||||
|
||||
export function ProviderIcon({ provider, size = "sm" }: ProviderIconProps) {
|
||||
|
||||
162
packages/dashboard/app/components/RuntimeCardShell.tsx
Normal file
162
packages/dashboard/app/components/RuntimeCardShell.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Shared visual shell for the three runtime-provider settings cards
|
||||
* (Hermes, OpenClaw, Paperclip).
|
||||
*
|
||||
* Renders a consistent header (logo + name + status badge + Learn more link),
|
||||
* a description block, a slot for connection-mode tabs, the form contents the
|
||||
* caller passes as children, and a footer with Test/Save/Save & Test buttons
|
||||
* plus a status toast that surfaces probe + save outcomes.
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export type RuntimeStatusKind = "neutral" | "ok" | "err" | "loading";
|
||||
|
||||
export interface RuntimeCardShellProps {
|
||||
/** Name shown next to the logo (e.g. "Hermes"). */
|
||||
name: string;
|
||||
/** Optional sub-line under the name (e.g. "by Nous Research"). */
|
||||
subname?: string;
|
||||
/** The provider logo. Use a `<ProviderIcon>` or an inline `<img>` for brand SVGs. */
|
||||
logo: ReactNode;
|
||||
/** "Learn more" target — official documentation/site. */
|
||||
learnMoreHref: string;
|
||||
/** Status kind drives the badge color. */
|
||||
statusKind: RuntimeStatusKind;
|
||||
/** Status text (e.g. "✓ Detected v0.8.0"). */
|
||||
statusText: string;
|
||||
/** Description shown under the header. */
|
||||
description: ReactNode;
|
||||
/** Optional extra header chrome (e.g. tabs). Rendered above the form. */
|
||||
tabs?: ReactNode;
|
||||
/** The form fields. */
|
||||
children: ReactNode;
|
||||
/** Disabled state for the action row. */
|
||||
busy: "loading" | "saving" | "testing" | "save-test" | null;
|
||||
/** Disable Test if some required input is missing. */
|
||||
canTest?: boolean;
|
||||
/** Toast under the action row — shown when not null. */
|
||||
toast?: { kind: "ok" | "err"; message: string } | null;
|
||||
onTest: () => void;
|
||||
onSave: () => void;
|
||||
onSaveAndTest: () => void;
|
||||
/** Optional extra content right above the footer (e.g. an install hint). */
|
||||
belowForm?: ReactNode;
|
||||
/** A test-id forwarded to the root element. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function RuntimeCardShell(props: RuntimeCardShellProps) {
|
||||
const {
|
||||
name,
|
||||
subname,
|
||||
logo,
|
||||
learnMoreHref,
|
||||
statusKind,
|
||||
statusText,
|
||||
description,
|
||||
tabs,
|
||||
children,
|
||||
busy,
|
||||
canTest = true,
|
||||
toast,
|
||||
onTest,
|
||||
onSave,
|
||||
onSaveAndTest,
|
||||
belowForm,
|
||||
testId,
|
||||
} = props;
|
||||
|
||||
const statusClass =
|
||||
statusKind === "ok"
|
||||
? "runtime-card__status runtime-card__status--ok"
|
||||
: statusKind === "err"
|
||||
? "runtime-card__status runtime-card__status--err"
|
||||
: "runtime-card__status runtime-card__status--neutral";
|
||||
|
||||
return (
|
||||
<div className="runtime-card" data-testid={testId} aria-live="polite">
|
||||
<header className="runtime-card__header">
|
||||
<span className="runtime-card__logo">{logo}</span>
|
||||
<div className="runtime-card__title">
|
||||
<h3 className="runtime-card__name">{name}</h3>
|
||||
{subname && (
|
||||
<small className="runtime-card__cobrand">{subname}</small>
|
||||
)}
|
||||
<small className={statusClass}>
|
||||
{statusKind === "loading" && <Loader2 size={12} className="animate-spin" />}
|
||||
{statusText}
|
||||
</small>
|
||||
</div>
|
||||
<a
|
||||
className="runtime-card__learn-more btn btn-sm btn-ghost"
|
||||
href={learnMoreHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<p className="runtime-card__description">{description}</p>
|
||||
|
||||
{tabs}
|
||||
|
||||
<div className="runtime-card__form">{children}</div>
|
||||
|
||||
{belowForm}
|
||||
|
||||
<footer className="runtime-card__footer">
|
||||
{toast && (
|
||||
<span
|
||||
className={
|
||||
toast.kind === "ok"
|
||||
? "runtime-card__toast runtime-card__toast--ok"
|
||||
: "runtime-card__toast runtime-card__toast--err"
|
||||
}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={onTest}
|
||||
disabled={busy !== null || !canTest}
|
||||
>
|
||||
{busy === "testing" ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" /> Testing…
|
||||
</>
|
||||
) : (
|
||||
"Test"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={onSave}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "saving" ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={onSaveAndTest}
|
||||
disabled={busy !== null || !canTest}
|
||||
>
|
||||
{busy === "save-test" ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
"Save & Test"
|
||||
)}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ default: m.PluginManager })));
|
||||
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
import { HermesRuntimeCard } from "./HermesRuntimeCard";
|
||||
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
|
||||
import { PaperclipRuntimeCard } from "./PaperclipRuntimeCard";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { AgentPromptsManager } from "./AgentPromptsManager";
|
||||
import { LoginInstructions } from "./LoginInstructions";
|
||||
@@ -161,6 +164,12 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
{ id: "global-models", label: "Models", scope: "global" },
|
||||
{ id: "updates", label: "Updates", scope: "global" },
|
||||
|
||||
// Runtimes group (plugin runtimes with their own settings)
|
||||
{ id: "__runtimes_header", label: "Runtimes", scope: undefined, isGroupHeader: true },
|
||||
{ id: "hermes-runtime", label: "Hermes", scope: "global" },
|
||||
{ id: "openclaw-runtime", label: "OpenClaw", scope: "global" },
|
||||
{ id: "paperclip-runtime", label: "Paperclip", scope: "global" },
|
||||
|
||||
// Project group (specific to this project)
|
||||
{ id: "__project_header", label: "Project", scope: undefined, isGroupHeader: true },
|
||||
{ id: "general", label: "General", scope: "project" },
|
||||
@@ -4276,6 +4285,27 @@ export function SettingsModal({
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "hermes-runtime":
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">Hermes Runtime</h4>
|
||||
<HermesRuntimeCard />
|
||||
</>
|
||||
);
|
||||
case "openclaw-runtime":
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">OpenClaw Runtime</h4>
|
||||
<OpenClawRuntimeCard />
|
||||
</>
|
||||
);
|
||||
case "paperclip-runtime":
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">Paperclip Runtime</h4>
|
||||
<PaperclipRuntimeCard />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4320,7 +4350,7 @@ export function SettingsModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-header-actions">
|
||||
{form.showGitHubStarButton !== false && !starClicked && (
|
||||
{form.showGitHubStarButton !== false && (
|
||||
<a
|
||||
href="https://github.com/Runfusion/Fusion"
|
||||
target="_blank"
|
||||
@@ -4329,9 +4359,11 @@ export function SettingsModal({
|
||||
aria-label="Star Fusion on GitHub"
|
||||
title="Star Fusion on GitHub"
|
||||
onClick={markStarClicked}
|
||||
data-clicked={starClicked ? "true" : "false"}
|
||||
>
|
||||
<span className="settings-github-star-btn__action">
|
||||
<Star size={13} aria-hidden="true" />
|
||||
<ProviderIcon provider="github" size="sm" />
|
||||
<Star size={11} aria-hidden="true" />
|
||||
Star
|
||||
</span>
|
||||
{gitHubStarCount !== null && (
|
||||
|
||||
1
packages/dashboard/app/public/brands/hermes-logo.svg
Normal file
1
packages/dashboard/app/public/brands/hermes-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -218,7 +218,176 @@ html {
|
||||
--provider-cerebras: #f1592a;
|
||||
--provider-groq: #f55036;
|
||||
--provider-vercel: var(--text);
|
||||
/* Runtime-plugin marks. */
|
||||
--provider-hermes: #d4961c;
|
||||
--provider-openclaw: #ff4f40;
|
||||
--provider-paperclip: var(--text);
|
||||
--provider-icon-contrast: var(--bg);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Runtime provider settings card — unified layout used by Hermes / OpenClaw /
|
||||
* Paperclip cards. Header (large logo + name + status), description, form,
|
||||
* footer action row.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.runtime-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.runtime-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.runtime-card__logo {
|
||||
flex: 0 0 auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--provider-icon-color, currentColor) 8%, transparent);
|
||||
}
|
||||
|
||||
.runtime-card__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runtime-card__name {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.runtime-card__status {
|
||||
font-size: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.runtime-card__status--ok {
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.runtime-card__status--err {
|
||||
color: var(--accent-red, #ef4444);
|
||||
}
|
||||
|
||||
.runtime-card__status--neutral {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.runtime-card__learn-more {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.runtime-card__description {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runtime-card__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.runtime-card__form .form-group {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.runtime-card__tabs {
|
||||
display: flex;
|
||||
gap: var(--space-xxs, 2px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.runtime-card__tab {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.runtime-card__tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.runtime-card__tab[aria-selected="true"] {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent, #4f46e5);
|
||||
}
|
||||
|
||||
.runtime-card__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.runtime-card__toast {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.85rem;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.runtime-card__toast--ok {
|
||||
background: color-mix(in srgb, var(--accent-green, #22c55e) 12%, transparent);
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.runtime-card__toast--err {
|
||||
background: color-mix(in srgb, var(--accent-red, #ef4444) 12%, transparent);
|
||||
color: var(--accent-red, #ef4444);
|
||||
}
|
||||
|
||||
.runtime-card__cobrand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-xxs, 2px);
|
||||
}
|
||||
|
||||
.runtime-card__cobrand-mark {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: currentColor;
|
||||
color: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
/* Task-creation CTA tokens */
|
||||
--cta-bg: #238636;
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
||||
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
|
||||
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/engine": "workspace:*",
|
||||
"@mariozechner/pi-coding-agent": "^0.70.0",
|
||||
|
||||
1
packages/dashboard/public/brands/hermes-logo.svg
Normal file
1
packages/dashboard/public/brands/hermes-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -60,7 +60,7 @@ export async function probeClaudeCli(
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
const child = spawn("claude", ["--version"], {
|
||||
const child = spawn(binaryPath ?? "claude", ["--version"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import { registerProxyRoutes } from "./routes/register-proxy-routes.js";
|
||||
import { registerModelRoutes } from "./routes/register-model-routes.js";
|
||||
import { registerUsageRoutes } from "./routes/register-usage-routes.js";
|
||||
import { registerAuthRoutes } from "./routes/register-auth-routes.js";
|
||||
import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js";
|
||||
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
|
||||
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
|
||||
import { runGitCommand } from "./routes/resolve-diff-base.js";
|
||||
@@ -1407,6 +1408,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// ---------- Auth routes ----------
|
||||
registerAuthRoutes(routeContext);
|
||||
|
||||
// ---------- Runtime-plugin probe routes (Hermes / OpenClaw / Paperclip) ----------
|
||||
registerRuntimeProviderRoutes(routeContext);
|
||||
|
||||
/**
|
||||
* POST /api/ai/refine-text
|
||||
* AI-powered text refinement for task descriptions.
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { ApiError, badRequest } from "../api-error.js";
|
||||
import {
|
||||
discoverPaperclipCli,
|
||||
listHermesProviderProfiles,
|
||||
listPaperclipCompanies,
|
||||
listPaperclipCompanyAgents,
|
||||
mintPaperclipKeyViaCli,
|
||||
probeHermesProvider,
|
||||
probeOpenClawProvider,
|
||||
probePaperclipProvider,
|
||||
} from "../runtime-provider-probes.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
/**
|
||||
* Registers three read-only status probes for the runtime provider plugins:
|
||||
*
|
||||
* GET /providers/hermes/status
|
||||
* GET /providers/openclaw/status
|
||||
* GET /providers/paperclip/status
|
||||
*
|
||||
* These routes mirror the existing GET /providers/claude-cli/status pattern
|
||||
* in register-auth-routes.ts. They intentionally do NOT require authentication
|
||||
* because they are introspection endpoints consumed by the provider-card UI.
|
||||
*/
|
||||
export const registerRuntimeProviderRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, rethrowAsApiError } = ctx;
|
||||
|
||||
/**
|
||||
* GET /providers/hermes/status
|
||||
*
|
||||
* Query params:
|
||||
* binaryPath? — override the hermes binary path (default: "hermes")
|
||||
*
|
||||
* Response: { binary: HermesBinaryStatus, ready: boolean }
|
||||
* ready === binary.available
|
||||
*/
|
||||
router.get("/providers/hermes/status", async (req, res) => {
|
||||
try {
|
||||
const binaryPath =
|
||||
typeof req.query.binaryPath === "string" ? req.query.binaryPath : undefined;
|
||||
const binary = await probeHermesProvider({ binaryPath });
|
||||
res.json({ binary, ready: binary.available });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /providers/hermes/profiles
|
||||
*
|
||||
* Query params:
|
||||
* binaryPath? — override the hermes binary path (default: "hermes")
|
||||
*
|
||||
* Response: { profiles: HermesProfileSummary[] }
|
||||
* On any error: { profiles: [], error: "<reason>" } with HTTP 200.
|
||||
*/
|
||||
router.get("/providers/hermes/profiles", async (req, res) => {
|
||||
const binaryPath =
|
||||
typeof req.query.binaryPath === "string" ? req.query.binaryPath : undefined;
|
||||
try {
|
||||
const profiles = await listHermesProviderProfiles({ binaryPath });
|
||||
res.json({ profiles });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
res.json({ profiles: [], error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /providers/openclaw/status
|
||||
*
|
||||
* Query params:
|
||||
* binaryPath? — override the openclaw binary path (default: "openclaw")
|
||||
*
|
||||
* Response: { binary: OpenClawBinaryStatus, ready: boolean }
|
||||
* ready === binary.available
|
||||
*/
|
||||
router.get("/providers/openclaw/status", async (req, res) => {
|
||||
try {
|
||||
const binaryPath =
|
||||
typeof req.query.binaryPath === "string" ? req.query.binaryPath : undefined;
|
||||
const binary = await probeOpenClawProvider({ binaryPath });
|
||||
res.json({ binary, ready: binary.available });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /providers/paperclip/status
|
||||
*
|
||||
* Query params:
|
||||
* apiUrl — REQUIRED. The Paperclip server base URL (must start with http:// or https://).
|
||||
* apiKey? — Optional API key forwarded to the probe.
|
||||
*
|
||||
* Response: { connection: PaperclipConnectionStatus, ready: boolean }
|
||||
* ready === connection.available
|
||||
*
|
||||
* Returns 400 when apiUrl is missing or does not start with http:// or https://.
|
||||
*/
|
||||
router.get("/providers/paperclip/status", async (req, res) => {
|
||||
try {
|
||||
const rawApiUrl = req.query.apiUrl;
|
||||
if (typeof rawApiUrl !== "string" || rawApiUrl.trim() === "") {
|
||||
throw badRequest("Missing required query parameter: apiUrl");
|
||||
}
|
||||
const apiUrl = rawApiUrl.trim();
|
||||
if (!apiUrl.startsWith("http://") && !apiUrl.startsWith("https://")) {
|
||||
throw badRequest("Invalid apiUrl: must start with http:// or https://");
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
typeof req.query.apiKey === "string" ? req.query.apiKey : undefined;
|
||||
|
||||
const connection = await probePaperclipProvider({ apiUrl, apiKey });
|
||||
res.json({ connection, ready: connection.available });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /providers/paperclip/companies
|
||||
*
|
||||
* Query: apiUrl (required), apiKey?
|
||||
* Returns: { companies: PaperclipCompanySummary[] } — never throws on
|
||||
* upstream failure; an empty list signals "no companies visible".
|
||||
*/
|
||||
router.get("/providers/paperclip/companies", async (req, res) => {
|
||||
try {
|
||||
const apiUrl = readApiUrl(req.query.apiUrl);
|
||||
const apiKey =
|
||||
typeof req.query.apiKey === "string" ? req.query.apiKey : undefined;
|
||||
const companies = await listPaperclipCompanies({ apiUrl, apiKey });
|
||||
res.json({ companies });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /providers/paperclip/agents
|
||||
*
|
||||
* Query: apiUrl (required), companyId (required), apiKey?
|
||||
* Returns: { agents: PaperclipAgentSummary[] } — empty list on upstream error.
|
||||
*/
|
||||
router.get("/providers/paperclip/agents", async (req, res) => {
|
||||
try {
|
||||
const apiUrl = readApiUrl(req.query.apiUrl);
|
||||
const apiKey =
|
||||
typeof req.query.apiKey === "string" ? req.query.apiKey : undefined;
|
||||
const companyId =
|
||||
typeof req.query.companyId === "string"
|
||||
? req.query.companyId.trim()
|
||||
: "";
|
||||
if (!companyId) {
|
||||
throw badRequest("Missing required query parameter: companyId");
|
||||
}
|
||||
const agents = await listPaperclipCompanyAgents({ apiUrl, apiKey, companyId });
|
||||
res.json({ agents });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /providers/paperclip/cli-discovery
|
||||
*
|
||||
* Query: cliConfigPath?
|
||||
* Returns the result of reading the local paperclipai config (apiUrl,
|
||||
* deploymentMode, etc.). Never throws — failures are reported as
|
||||
* `{ ok: false, reason }` so the card can render a clear error state.
|
||||
*/
|
||||
router.get("/providers/paperclip/cli-discovery", async (req, res) => {
|
||||
try {
|
||||
const cliConfigPath =
|
||||
typeof req.query.cliConfigPath === "string"
|
||||
? req.query.cliConfigPath
|
||||
: undefined;
|
||||
const result = await discoverPaperclipCli({ cliConfigPath });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /providers/paperclip/cli-mint-key
|
||||
*
|
||||
* Body: { agentRef (required), cliBinaryPath?, companyId?, keyName?, configPath?, dataDir? }
|
||||
*
|
||||
* Spawns `paperclipai agent local-cli <agentRef> --json --no-install-skills` to
|
||||
* mint a fresh agent API key. Always responds HTTP 200; failures are reported as
|
||||
* `{ ok: false, reason }` so the card can render an inline error.
|
||||
*
|
||||
* Returns 400 if `agentRef` is missing or empty.
|
||||
*/
|
||||
router.post("/providers/paperclip/cli-mint-key", async (req, res) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const agentRef =
|
||||
typeof body.agentRef === "string" ? body.agentRef.trim() : "";
|
||||
if (!agentRef) {
|
||||
throw badRequest("Missing required body field: agentRef");
|
||||
}
|
||||
const cliBinaryPath =
|
||||
typeof body.cliBinaryPath === "string" && body.cliBinaryPath.trim()
|
||||
? body.cliBinaryPath.trim()
|
||||
: undefined;
|
||||
const companyId =
|
||||
typeof body.companyId === "string" ? body.companyId.trim() : "";
|
||||
if (!companyId) {
|
||||
throw badRequest(
|
||||
"Missing required body field: companyId (paperclipai agent local-cli requires -C/--company-id)",
|
||||
);
|
||||
}
|
||||
const keyName =
|
||||
typeof body.keyName === "string" && body.keyName.trim()
|
||||
? body.keyName.trim()
|
||||
: undefined;
|
||||
const configPath =
|
||||
typeof body.configPath === "string" && body.configPath.trim()
|
||||
? body.configPath.trim()
|
||||
: undefined;
|
||||
const dataDir =
|
||||
typeof body.dataDir === "string" && body.dataDir.trim()
|
||||
? body.dataDir.trim()
|
||||
: undefined;
|
||||
|
||||
const result = await mintPaperclipKeyViaCli({
|
||||
agentRef,
|
||||
cliBinaryPath,
|
||||
companyId,
|
||||
keyName,
|
||||
configPath,
|
||||
dataDir,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the apiUrl query param (must be present + http(s)).
|
||||
* Throws an ApiError(400) on failure.
|
||||
*/
|
||||
function readApiUrl(raw: unknown): string {
|
||||
if (typeof raw !== "string" || raw.trim() === "") {
|
||||
throw badRequest("Missing required query parameter: apiUrl");
|
||||
}
|
||||
const apiUrl = raw.trim();
|
||||
if (!apiUrl.startsWith("http://") && !apiUrl.startsWith("https://")) {
|
||||
throw badRequest("Invalid apiUrl: must start with http:// or https://");
|
||||
}
|
||||
return apiUrl;
|
||||
}
|
||||
158
packages/dashboard/src/runtime-provider-probes.ts
Normal file
158
packages/dashboard/src/runtime-provider-probes.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Thin façade over the three runtime-provider plugin probe functions.
|
||||
*
|
||||
* Each exported function delegates directly to the corresponding plugin's
|
||||
* probe. The indirection exists so that:
|
||||
* 1. Route handlers import from one stable internal module rather than
|
||||
* reaching into plugin packages directly.
|
||||
* 2. Tests can spy/mock at this module boundary without touching the
|
||||
* plugin packages.
|
||||
* 3. If a plugin package is somehow not installed, the error surfaces
|
||||
* as an import-time TypeError with a clear message rather than a
|
||||
* cryptic missing-module crash inside a route handler.
|
||||
*/
|
||||
|
||||
import {
|
||||
listHermesProfiles,
|
||||
probeHermesBinary,
|
||||
type HermesBinaryStatus,
|
||||
type HermesProfileSummary,
|
||||
} from "@fusion-plugin-examples/hermes-runtime";
|
||||
|
||||
import {
|
||||
probeOpenClawBinary,
|
||||
type OpenClawBinaryStatus,
|
||||
} from "@fusion-plugin-examples/openclaw-runtime";
|
||||
|
||||
import {
|
||||
agentsMe,
|
||||
discoverPaperclipCliConfig,
|
||||
listCompanies,
|
||||
listCompanyAgents,
|
||||
mintAgentApiKeyViaCli,
|
||||
probePaperclipConnection,
|
||||
type MintCliKeyOptions,
|
||||
type MintedApiKey,
|
||||
type PaperclipAgentSummary,
|
||||
type PaperclipCliDiscoveryResult,
|
||||
type PaperclipCompanySummary,
|
||||
type PaperclipConnectionStatus,
|
||||
} from "@fusion-plugin-examples/paperclip-runtime";
|
||||
|
||||
export type {
|
||||
HermesBinaryStatus,
|
||||
HermesProfileSummary,
|
||||
MintCliKeyOptions,
|
||||
MintedApiKey,
|
||||
OpenClawBinaryStatus,
|
||||
PaperclipAgentSummary,
|
||||
PaperclipCliDiscoveryResult,
|
||||
PaperclipCompanySummary,
|
||||
PaperclipConnectionStatus,
|
||||
};
|
||||
export { mintAgentApiKeyViaCli };
|
||||
|
||||
/**
|
||||
* Probe the local Hermes binary.
|
||||
*
|
||||
* Never throws — failures are reported as `available: false` with a reason
|
||||
* field so HTTP handlers can render the provider card without try/catch.
|
||||
*/
|
||||
export async function probeHermesProvider(opts?: {
|
||||
binaryPath?: string;
|
||||
}): Promise<HermesBinaryStatus> {
|
||||
return probeHermesBinary(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* List Hermes profiles via `hermes profile list`.
|
||||
*
|
||||
* Delegates directly to the plugin's listHermesProfiles.
|
||||
* Callers are expected to handle errors; this function does not swallow them.
|
||||
*/
|
||||
export async function listHermesProviderProfiles(opts?: {
|
||||
binaryPath?: string;
|
||||
}): Promise<HermesProfileSummary[]> {
|
||||
return listHermesProfiles(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the local OpenClaw binary.
|
||||
*
|
||||
* Never throws — failures are reported as `available: false` with a reason.
|
||||
*/
|
||||
export async function probeOpenClawProvider(opts?: {
|
||||
binaryPath?: string;
|
||||
}): Promise<OpenClawBinaryStatus> {
|
||||
return probeOpenClawBinary(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a Paperclip server by its API URL.
|
||||
*
|
||||
* Never throws — failures are reported as `available: false` with a reason.
|
||||
*/
|
||||
export async function probePaperclipProvider(opts: {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
}): Promise<PaperclipConnectionStatus> {
|
||||
return probePaperclipConnection(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* List companies visible to the bearer. Falls back to the single company
|
||||
* derived from `/api/agents/me` when `/api/companies` returns 403 (typical
|
||||
* for agent-key-scoped requests in `authenticated` deployment mode).
|
||||
*
|
||||
* Always returns at least an empty array; never throws — failures degrade
|
||||
* to an empty list so the UI can render a "no companies discovered" state.
|
||||
*/
|
||||
export async function listPaperclipCompanies(opts: {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
}): Promise<PaperclipCompanySummary[]> {
|
||||
// Prefer the broad listing if it works (board cookie / multi-company access).
|
||||
try {
|
||||
const cs = await listCompanies(opts.apiUrl, opts.apiKey);
|
||||
if (cs.length > 0) return cs;
|
||||
} catch {
|
||||
// Fall through to /agents/me below.
|
||||
}
|
||||
// Fall back: agent keys can see their own company via /agents/me.
|
||||
try {
|
||||
const me = await agentsMe(opts.apiUrl, opts.apiKey);
|
||||
return [{ id: me.companyId, name: me.companyName ?? me.companyId }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPaperclipCompanyAgents(opts: {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
companyId: string;
|
||||
}): Promise<PaperclipAgentSummary[]> {
|
||||
return listCompanyAgents(opts.apiUrl, opts.apiKey, opts.companyId);
|
||||
}
|
||||
|
||||
export async function discoverPaperclipCli(opts: {
|
||||
cliConfigPath?: string;
|
||||
}): Promise<PaperclipCliDiscoveryResult> {
|
||||
return discoverPaperclipCliConfig({ configPath: opts.cliConfigPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin façade over `mintAgentApiKeyViaCli` that never throws.
|
||||
* Returns `{ ok: true, key }` on success or `{ ok: false, reason }` on failure,
|
||||
* so HTTP handlers and tests can destructure without try/catch.
|
||||
*/
|
||||
export async function mintPaperclipKeyViaCli(
|
||||
opts: MintCliKeyOptions,
|
||||
): Promise<{ ok: true; key: MintedApiKey } | { ok: false; reason: string }> {
|
||||
try {
|
||||
const key = await mintAgentApiKeyViaCli(opts);
|
||||
return { ok: true, key };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user