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
|
|
@ -287,6 +287,12 @@ export interface ServerAdapterModule {
|
|||
* without knowing provider-specific credential paths or API shapes.
|
||||
*/
|
||||
getQuotaWindows?: () => Promise<ProviderQuotaResult>;
|
||||
/**
|
||||
* Optional: detect the currently configured model from local config files.
|
||||
* Returns the detected model/provider and the config source, or null if
|
||||
* the adapter does not support detection or no config is found.
|
||||
*/
|
||||
detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
"drizzle-orm": "^0.38.4",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"express": "^5.1.0",
|
||||
"hermes-paperclip-adapter": "0.1.1",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"open": "^11.0.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js";
|
||||
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js";
|
||||
export type {
|
||||
ServerAdapterModule,
|
||||
AdapterExecutionContext,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ import {
|
|||
execute as hermesExecute,
|
||||
testEnvironment as hermesTestEnvironment,
|
||||
sessionCodec as hermesSessionCodec,
|
||||
listSkills as hermesListSkills,
|
||||
syncSkills as hermesSyncSkills,
|
||||
detectModel as detectModelFromHermes,
|
||||
} from "hermes-paperclip-adapter/server";
|
||||
import {
|
||||
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
||||
|
|
@ -176,9 +179,12 @@ const hermesLocalAdapter: ServerAdapterModule = {
|
|||
execute: hermesExecute,
|
||||
testEnvironment: hermesTestEnvironment,
|
||||
sessionCodec: hermesSessionCodec,
|
||||
listSkills: hermesListSkills,
|
||||
syncSkills: hermesSyncSkills,
|
||||
models: hermesModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
||||
detectModel: () => detectModelFromHermes(),
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
|
|
@ -219,6 +225,15 @@ export function listServerAdapters(): ServerAdapterModule[] {
|
|||
return Array.from(adaptersByType.values());
|
||||
}
|
||||
|
||||
export async function detectAdapterModel(
|
||||
type: string,
|
||||
): Promise<{ model: string | null; provider: string | null; source: string | null } | null> {
|
||||
const adapter = adaptersByType.get(type);
|
||||
if (!adapter?.detectModel) return null;
|
||||
const detected = await adapter.detectModel();
|
||||
return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null;
|
||||
}
|
||||
|
||||
export function findServerAdapter(type: string): ServerAdapterModule | null {
|
||||
return adaptersByType.get(type) ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import {
|
|||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||
import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
|
||||
|
|
@ -671,6 +671,15 @@ export function agentRoutes(db: Db) {
|
|||
res.json(models);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
|
||||
const detected = await detectAdapterModel(type);
|
||||
res.json(detected ?? { model: null, provider: null, source: null });
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/adapters/:type/test-environment",
|
||||
validate(testAdapterEnvironmentSchema),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
|
|
|
|||
49
ui/src/adapters/hermes-local/config-fields.tsx
Normal file
49
ui/src/adapters/hermes-local/config-fields.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||
|
||||
export function HermesLocalConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
if (hideInstructionsFile) return null;
|
||||
return (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/hermes-local/index.ts
Normal file
12
ui/src/adapters/hermes-local/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { UIAdapterModule } from "../types";
|
||||
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
|
||||
import { HermesLocalConfigFields } from "./config-fields";
|
||||
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
|
||||
|
||||
export const hermesLocalUIAdapter: UIAdapterModule = {
|
||||
type: "hermes_local",
|
||||
label: "Hermes Agent",
|
||||
parseStdoutLine: parseHermesStdoutLine,
|
||||
ConfigFields: HermesLocalConfigFields,
|
||||
buildAdapterConfig: buildHermesConfig,
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local";
|
|||
import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||
import { hermesLocalUIAdapter } from "./hermes-local";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
import { piLocalUIAdapter } from "./pi-local";
|
||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||
|
|
@ -13,6 +14,7 @@ const uiAdapters: UIAdapterModule[] = [
|
|||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
hermesLocalUIAdapter,
|
||||
openCodeLocalUIAdapter,
|
||||
piLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ export interface AdapterModel {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface DetectedAdapterModel {
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeLoginResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
|
|
@ -159,6 +165,10 @@ export const agentsApi = {
|
|||
api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||
),
|
||||
detectModel: (companyId: string, type: string) =>
|
||||
api.get<DetectedAdapterModel>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
|
||||
),
|
||||
testEnvironment: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
43
ui/src/components/HermesIcon.tsx
Normal file
43
ui/src/components/HermesIcon.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { cn } from "../lib/utils";
|
||||
|
||||
interface HermesIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermes caduceus icon — winged staff with two intertwined serpents.
|
||||
* Replaces the generic Zap icon for the hermes_local adapter type.
|
||||
*
|
||||
* ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings.
|
||||
*/
|
||||
export function HermesIcon({ className }: HermesIconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn(className)}
|
||||
>
|
||||
{/* Central staff */}
|
||||
<line x1="12" y1="6" x2="12" y2="23" />
|
||||
{/* Left serpent curves */}
|
||||
<path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" />
|
||||
{/* Right serpent curves */}
|
||||
<path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" />
|
||||
{/* Snake heads facing outward */}
|
||||
<circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" />
|
||||
<circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" />
|
||||
{/* Wings at top of staff */}
|
||||
<path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" />
|
||||
<path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" />
|
||||
{/* Wing feather details */}
|
||||
<line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" />
|
||||
<line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" />
|
||||
{/* Staff sphere at top */}
|
||||
<circle cx="12" cy="6.5" r="1.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
|
||||
type AdvancedAdapterType =
|
||||
| "claude_local"
|
||||
|
|
@ -29,7 +30,8 @@ type AdvancedAdapterType =
|
|||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "openclaw_gateway";
|
||||
| "openclaw_gateway"
|
||||
| "hermes_local";
|
||||
|
||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||
value: AdvancedAdapterType;
|
||||
|
|
@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
|||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "hermes_local",
|
||||
label: "Hermes Agent",
|
||||
icon: HermesIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "pi_local",
|
||||
label: "Pi",
|
||||
|
|
|
|||
|
|
@ -56,12 +56,14 @@ import {
|
|||
ChevronDown,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
type AdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
| "gemini_local"
|
||||
| "hermes_local"
|
||||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
|
|
@ -208,6 +210,7 @@ export function OnboardingWizard() {
|
|||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
|
|
@ -217,6 +220,8 @@ export function OnboardingWizard() {
|
|||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
|
|
@ -843,6 +848,12 @@ export function OnboardingWizard() {
|
|||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent"
|
||||
},
|
||||
{
|
||||
value: "hermes_local" as const,
|
||||
label: "Hermes Agent",
|
||||
icon: HermesIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
|
|
@ -902,6 +913,7 @@ export function OnboardingWizard() {
|
|||
{(adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor") && (
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export const adapterLabels: Record<string, string> = {
|
|||
opencode_local: "OpenCode (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
hermes_local: "Hermes Agent",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -72,6 +72,26 @@ type TranscriptBlock =
|
|||
status: "running" | "completed" | "error";
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "tool_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
items: Array<{
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
status: "running" | "completed" | "error";
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "stderr_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
lines: Array<{ ts: string; text: string }>;
|
||||
}
|
||||
| {
|
||||
type: "stdout";
|
||||
ts: string;
|
||||
|
|
@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
|||
return grouped;
|
||||
}
|
||||
|
||||
/** Group consecutive non-command tool blocks into a single tool_group accordion. */
|
||||
function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
||||
const grouped: TranscriptBlock[] = [];
|
||||
let pending: Array<Extract<TranscriptBlock, { type: "tool_group" }>["items"][number]> = [];
|
||||
let groupTs: string | null = null;
|
||||
let groupEndTs: string | undefined;
|
||||
|
||||
const flush = () => {
|
||||
if (pending.length === 0 || !groupTs) return;
|
||||
grouped.push({
|
||||
type: "tool_group",
|
||||
ts: groupTs,
|
||||
endTs: groupEndTs,
|
||||
items: pending,
|
||||
});
|
||||
pending = [];
|
||||
groupTs = null;
|
||||
groupEndTs = undefined;
|
||||
};
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === "tool" && !isCommandTool(block.name, block.input)) {
|
||||
if (!groupTs) groupTs = block.ts;
|
||||
groupEndTs = block.endTs ?? block.ts;
|
||||
pending.push({
|
||||
ts: block.ts,
|
||||
endTs: block.endTs,
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
result: block.result,
|
||||
isError: block.isError,
|
||||
status: block.status,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
grouped.push(block);
|
||||
}
|
||||
flush();
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
|
||||
const blocks: TranscriptBlock[] = [];
|
||||
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
||||
|
|
@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
if (shouldHideNiceModeStderr(entry.text)) {
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: "event",
|
||||
ts: entry.ts,
|
||||
label: "stderr",
|
||||
tone: "error",
|
||||
text: entry.text,
|
||||
});
|
||||
// Batch consecutive stderr entries into a single group
|
||||
const prev = blocks[blocks.length - 1];
|
||||
if (prev && prev.type === "stderr_group") {
|
||||
prev.lines.push({ ts: entry.ts, text: entry.text });
|
||||
prev.endTs = entry.ts;
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "stderr_group",
|
||||
ts: entry.ts,
|
||||
endTs: entry.ts,
|
||||
lines: [{ ts: entry.ts, text: entry.text }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
}
|
||||
}
|
||||
|
||||
return groupCommandBlocks(blocks);
|
||||
return groupToolBlocks(groupCommandBlocks(blocks));
|
||||
}
|
||||
|
||||
function TranscriptMessageBlock({
|
||||
|
|
@ -805,6 +873,139 @@ function TranscriptCommandGroup({
|
|||
);
|
||||
}
|
||||
|
||||
function TranscriptToolGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "tool_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
|
||||
const hasError = block.items.some((item) => item.status === "error");
|
||||
const isRunning = Boolean(runningItem);
|
||||
const uniqueNames = [...new Set(block.items.map((item) => item.name))];
|
||||
const toolLabel =
|
||||
uniqueNames.length === 1
|
||||
? humanizeLabel(uniqueNames[0])
|
||||
: `${uniqueNames.length} tools`;
|
||||
const title = isRunning
|
||||
? `Using ${toolLabel}`
|
||||
: block.items.length === 1
|
||||
? `Used ${toolLabel}`
|
||||
: `Used ${toolLabel} (${block.items.length} calls)`;
|
||||
const subtitle = runningItem
|
||||
? summarizeToolInput(runningItem.name, runningItem.input, density)
|
||||
: null;
|
||||
const statusTone = isRunning
|
||||
? "text-cyan-700 dark:text-cyan-300"
|
||||
: "text-foreground/70";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/40 bg-muted/[0.25]">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn("flex cursor-pointer gap-2 px-3 py-2.5", subtitle ? "items-start" : "items-center")}
|
||||
onClick={() => { if (hasSelectedText()) return; setOpen((v) => !v); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<div className={cn("flex shrink-0 items-center", subtitle && "mt-0.5")}>
|
||||
{block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => {
|
||||
const isItemRunning = item.status === "running";
|
||||
const isItemError = item.status === "error";
|
||||
return (
|
||||
<span
|
||||
key={`${item.ts}-${index}`}
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm",
|
||||
index > 0 && "-ml-1.5",
|
||||
isItemRunning
|
||||
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
|
||||
: isItemError
|
||||
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
|
||||
: "border-border/70 bg-background text-foreground/55",
|
||||
isItemRunning && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn("font-semibold uppercase leading-none tracking-[0.1em]", compact ? "text-[10px]" : "text-[11px]", "text-muted-foreground/70")}>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground", subtitle && "mt-0.5")}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||||
aria-label={open ? "Collapse tool details" : "Expand tool details"}
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className={cn("space-y-2 border-t border-border/30 px-3 py-3", hasError && "rounded-b-xl")}>
|
||||
{block.items.map((item, index) => (
|
||||
<div key={`${item.ts}-${index}`} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
|
||||
item.status === "error"
|
||||
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
|
||||
: item.status === "running"
|
||||
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
|
||||
: "border-border/70 bg-background text-foreground/55",
|
||||
)}>
|
||||
<Wrench className="h-3 w-3" />
|
||||
</span>
|
||||
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground")}>
|
||||
{humanizeLabel(item.name)}
|
||||
</span>
|
||||
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]",
|
||||
item.status === "running" ? "text-cyan-700 dark:text-cyan-300"
|
||||
: item.status === "error" ? "text-red-700 dark:text-red-300"
|
||||
: "text-emerald-700 dark:text-emerald-300"
|
||||
)}>
|
||||
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Input</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/80">
|
||||
{formatToolPayload(item.input) || "<empty>"}
|
||||
</pre>
|
||||
</div>
|
||||
{item.result && (
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Result</div>
|
||||
<pre className={cn(
|
||||
"overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]",
|
||||
item.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80",
|
||||
)}>
|
||||
{formatToolPayload(item.result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptActivityRow({
|
||||
block,
|
||||
density,
|
||||
|
|
@ -883,6 +1084,43 @@ function TranscriptEventRow({
|
|||
);
|
||||
}
|
||||
|
||||
function TranscriptStderrGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "stderr_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] p-2 text-amber-700 dark:text-amber-300">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]")}>
|
||||
{block.lines.length} log {block.lines.length === 1 ? "line" : "lines"}
|
||||
</span>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
{open && (
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-amber-700/80 dark:text-amber-300/80 pl-5">
|
||||
{block.lines.map((line, i) => (
|
||||
<span key={`${line.ts}-${i}`}>
|
||||
<span className="select-none text-amber-500/50 dark:text-amber-400/40">{i > 0 ? "\n" : ""}</span>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptStdoutRow({
|
||||
block,
|
||||
density,
|
||||
|
|
@ -1003,6 +1241,8 @@ export function RunTranscriptView({
|
|||
)}
|
||||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
||||
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
|
||||
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
|
||||
{block.type === "stdout" && (
|
||||
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export const queryKeys = {
|
|||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||
adapterModels: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "adapter-models", adapterType] as const,
|
||||
detectModel: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "detect-model", adapterType] as const,
|
||||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
|
|
|
|||
|
|
@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
|||
const isLive = run.status === "running" || run.status === "queued";
|
||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const summary = run.resultJson
|
||||
const summaryRaw = run.resultJson
|
||||
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||
: run.error ?? "";
|
||||
|
||||
// Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines
|
||||
const summary = useMemo(() => {
|
||||
if (!summaryRaw) return "";
|
||||
const lines = summaryRaw
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```"));
|
||||
const excerpt: string[] = [];
|
||||
let chars = 0;
|
||||
for (const line of lines) {
|
||||
if (excerpt.length >= 3 || chars + line.length > 280) break;
|
||||
excerpt.push(line);
|
||||
chars += line.length;
|
||||
}
|
||||
return excerpt.join(" ");
|
||||
}, [summaryRaw]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
|
|
@ -2351,6 +2369,7 @@ function AgentSkillsTab({
|
|||
const queryClient = useQueryClient();
|
||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
||||
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
|
||||
const lastSavedSkillsRef = useRef<string[]>([]);
|
||||
const hasHydratedSkillSnapshotRef = useRef(false);
|
||||
const skipNextSkillAutosaveRef = useRef(true);
|
||||
|
|
@ -2680,12 +2699,19 @@ function AgentSkillsTab({
|
|||
|
||||
{unmanagedSkillRows.length > 0 && (
|
||||
<section className="border-y border-border">
|
||||
<div className="border-b border-border bg-muted/40 px-3 py-2">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 select-none"
|
||||
onClick={() => setUnmanagedOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
User-installed skills, not managed by Paperclip
|
||||
({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip
|
||||
</span>
|
||||
{unmanagedOpen ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</div>
|
||||
{unmanagedSkillRows.map(renderSkillRow)}
|
||||
{unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
|
|||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
hermes_local: "Hermes",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@ const adapterLabels: Record<string, string> = {
|
|||
pi_local: "Pi (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
hermes_local: "Hermes Agent",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
|
||||
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
|
||||
function dateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType
|
|||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"hermes_local",
|
||||
"openclaw_gateway",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ const adapterLabels: Record<string, string> = {
|
|||
gemini_local: "Gemini",
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
hermes_local: "Hermes",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue