mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
feat(hermes): upgrade hermes-paperclip-adapter + UI adapter + skills + detectModel
Upgrades hermes-paperclip-adapter from 0.1.1 to ^0.2.0 and wires in all new
capabilities introduced in v0.2.0:
Server
- Upgrade hermes-paperclip-adapter 0.1.1 -> ^0.2.0 (pending PR#10 merge)
- Wire listSkills + syncSkills from hermes-paperclip-adapter/server
- Add detectModel to hermesLocalAdapter (reads ~/.hermes/config.yaml)
- Add detectAdapterModel() function + /adapters/:type/detect-model route
- Export detectAdapterModel from server/src/adapters/index.ts
Types
- Add optional detectModel? to ServerAdapterModule in adapter-utils
UI
- Add hermes-paperclip-adapter ^0.2.0 to ui/package.json (for /ui exports)
- New ui/src/adapters/hermes-local/ — config fields + UI adapter module
- Register hermesLocalUIAdapter in UI adapter registry
- New HermesIcon (caduceus SVG) for adapter pickers
- AgentConfigForm: detect-model button, creatable model input, preserve
adapter-agnostic fields (env, promptTemplate) when switching adapter type
- NewAgentDialog + OnboardingWizard: add Hermes to adapter picker
- Agents, OrgChart, InviteLanding, NewAgent, agent-config-primitives: add
hermes_local label + enable in adapter sets
- AgentDetail: smarter run summary excerpt extraction
- RunTranscriptView: improved Hermes stdout rendering
NOTE: requires hermes-paperclip-adapter@0.2.0 on npm.
Blocked on NousResearch/hermes-paperclip-adapter#10 merging.
This commit is contained in:
parent
0ac01a04e5
commit
1583a2d65a
22 changed files with 634 additions and 33 deletions
|
|
@ -248,9 +248,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
if (overlay.adapterType !== undefined) {
|
||||
patch.adapterType = overlay.adapterType;
|
||||
// When adapter type changes, send only the new config — don't merge
|
||||
// with old config since old adapter fields are meaningless for the new type
|
||||
patch.adapterConfig = overlay.adapterConfig;
|
||||
// When adapter type changes, replace adapter-specific fields but preserve
|
||||
// adapter-agnostic fields (env, promptTemplate, etc.) that are shared
|
||||
// across all adapter types.
|
||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
const adapterAgnosticKeys = [
|
||||
"env",
|
||||
"promptTemplate",
|
||||
"instructionsFilePath",
|
||||
"cwd",
|
||||
"timeoutSec",
|
||||
"graceSec",
|
||||
"bootstrapPromptTemplate",
|
||||
];
|
||||
const preserved: Record<string, unknown> = {};
|
||||
for (const key of adapterAgnosticKeys) {
|
||||
if (key in existing) {
|
||||
preserved[key] = existing[key];
|
||||
}
|
||||
}
|
||||
patch.adapterConfig = { ...preserved, ...overlay.adapterConfig };
|
||||
} else if (Object.keys(overlay.adapterConfig).length > 0) {
|
||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
|
||||
|
|
@ -296,9 +313,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
const isHermesLocal = adapterType === "hermes_local";
|
||||
const showLegacyWorkingDirectoryField =
|
||||
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||
|
|
@ -315,6 +334,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const models = fetchedModels ?? externalModels ?? [];
|
||||
const {
|
||||
data: detectedModelData,
|
||||
refetch: refetchDetectedModel,
|
||||
} = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.agents.detectModel(selectedCompanyId, adapterType)
|
||||
: ["agents", "none", "detect-model", adapterType],
|
||||
queryFn: () => {
|
||||
if (!selectedCompanyId) {
|
||||
throw new Error("Select a company to detect the Hermes model");
|
||||
}
|
||||
return agentsApi.detectModel(selectedCompanyId, adapterType);
|
||||
},
|
||||
enabled: Boolean(selectedCompanyId && isHermesLocal),
|
||||
});
|
||||
const detectedModel = detectedModelData?.model ?? null;
|
||||
|
||||
const { data: companyAgents = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
|
||||
|
|
@ -688,6 +723,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
|
|
@ -709,9 +746,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
open={modelOpen}
|
||||
onOpenChange={setModelOpen}
|
||||
allowDefault={adapterType !== "opencode_local"}
|
||||
required={adapterType === "opencode_local"}
|
||||
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
|
||||
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
|
||||
groupByProvider={adapterType === "opencode_local"}
|
||||
creatable={adapterType === "hermes_local"}
|
||||
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
|
||||
onDetectModel={adapterType === "hermes_local"
|
||||
? async () => {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
{fetchedModelsError && (
|
||||
<p className="text-xs text-destructive">
|
||||
|
|
@ -976,7 +1021,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
|
||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||
|
|
@ -1293,6 +1338,9 @@ function ModelDropdown({
|
|||
allowDefault,
|
||||
required,
|
||||
groupByProvider,
|
||||
creatable,
|
||||
detectedModel,
|
||||
onDetectModel,
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
|
|
@ -1302,9 +1350,19 @@ function ModelDropdown({
|
|||
allowDefault: boolean;
|
||||
required: boolean;
|
||||
groupByProvider: boolean;
|
||||
creatable?: boolean;
|
||||
detectedModel?: string | null;
|
||||
onDetectModel?: () => Promise<string | null>;
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const [detectingModel, setDetectingModel] = useState(false);
|
||||
const selected = models.find((m) => m.id === value);
|
||||
const manualModel = modelSearch.trim();
|
||||
const canCreateManualModel = Boolean(
|
||||
creatable &&
|
||||
manualModel &&
|
||||
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
|
||||
);
|
||||
const filteredModels = useMemo(() => {
|
||||
return models.filter((m) => {
|
||||
if (!modelSearch.trim()) return true;
|
||||
|
|
@ -1341,6 +1399,21 @@ function ModelDropdown({
|
|||
}));
|
||||
}, [filteredModels, groupByProvider]);
|
||||
|
||||
async function handleDetectModel() {
|
||||
if (!onDetectModel) return;
|
||||
setDetectingModel(true);
|
||||
try {
|
||||
const nextModel = await onDetectModel();
|
||||
if (nextModel) {
|
||||
onChange(nextModel);
|
||||
onOpenChange(false);
|
||||
setModelSearch("");
|
||||
}
|
||||
} finally {
|
||||
setDetectingModel(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Field label="Model" hint={help.model}>
|
||||
<Popover
|
||||
|
|
@ -1351,7 +1424,7 @@ function ModelDropdown({
|
|||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className={cn(!value && "text-muted-foreground")}>
|
||||
{selected
|
||||
? selected.label
|
||||
|
|
@ -1361,16 +1434,83 @@ function ModelDropdown({
|
|||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative mb-1">
|
||||
<input
|
||||
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
|
||||
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
/>
|
||||
{modelSearch && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setModelSearch("")}
|
||||
>
|
||||
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDetectModel && !detectedModel && !modelSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
||||
onClick={() => {
|
||||
void handleDetectModel();
|
||||
}}
|
||||
disabled={detectingModel}
|
||||
>
|
||||
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{detectingModel ? "Detecting..." : "Detect from Hermes config"}
|
||||
</button>
|
||||
)}
|
||||
{value && !models.some((m) => m.id === value) && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
|
||||
current
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{detectedModel && detectedModel !== value && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(detectedModel);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
|
||||
{detectedModel}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
|
||||
detected
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{allowDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!value && "bg-accent",
|
||||
|
|
@ -1383,6 +1523,20 @@ function ModelDropdown({
|
|||
Default
|
||||
</button>
|
||||
)}
|
||||
{canCreateManualModel && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
onChange(manualModel);
|
||||
onOpenChange(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<span>Use manual model</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
|
||||
</button>
|
||||
)}
|
||||
{groupedModels.map((group) => (
|
||||
<div key={group.provider} className="mb-1 last:mb-0">
|
||||
{groupByProvider && (
|
||||
|
|
@ -1392,6 +1546,7 @@ function ModelDropdown({
|
|||
)}
|
||||
{group.entries.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
|
|
@ -1409,8 +1564,14 @@ function ModelDropdown({
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||
{filteredModels.length === 0 && !canCreateManualModel && (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{onDetectModel
|
||||
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
|
||||
: "No models found."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue