Merge pull request #1955 from HenkDz/feat/hermes-adapter-upgrade
feat(hermes): upgrade hermes-paperclip-adapter + UI adapter, skills, model detection
This commit is contained in:
commit
17e5322e28
22 changed files with 638 additions and 33 deletions
|
|
@ -287,6 +287,12 @@ export interface ServerAdapterModule {
|
||||||
* without knowing provider-specific credential paths or API shapes.
|
* without knowing provider-specific credential paths or API shapes.
|
||||||
*/
|
*/
|
||||||
getQuotaWindows?: () => Promise<ProviderQuotaResult>;
|
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",
|
"drizzle-orm": "^0.38.4",
|
||||||
"embedded-postgres": "^18.1.0-beta.16",
|
"embedded-postgres": "^18.1.0-beta.16",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"hermes-paperclip-adapter": "0.1.1",
|
"hermes-paperclip-adapter": "^0.2.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"open": "^11.0.0",
|
"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 {
|
export type {
|
||||||
ServerAdapterModule,
|
ServerAdapterModule,
|
||||||
AdapterExecutionContext,
|
AdapterExecutionContext,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ import {
|
||||||
execute as hermesExecute,
|
execute as hermesExecute,
|
||||||
testEnvironment as hermesTestEnvironment,
|
testEnvironment as hermesTestEnvironment,
|
||||||
sessionCodec as hermesSessionCodec,
|
sessionCodec as hermesSessionCodec,
|
||||||
|
listSkills as hermesListSkills,
|
||||||
|
syncSkills as hermesSyncSkills,
|
||||||
|
detectModel as detectModelFromHermes,
|
||||||
} from "hermes-paperclip-adapter/server";
|
} from "hermes-paperclip-adapter/server";
|
||||||
import {
|
import {
|
||||||
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
||||||
|
|
@ -176,9 +179,12 @@ const hermesLocalAdapter: ServerAdapterModule = {
|
||||||
execute: hermesExecute,
|
execute: hermesExecute,
|
||||||
testEnvironment: hermesTestEnvironment,
|
testEnvironment: hermesTestEnvironment,
|
||||||
sessionCodec: hermesSessionCodec,
|
sessionCodec: hermesSessionCodec,
|
||||||
|
listSkills: hermesListSkills,
|
||||||
|
syncSkills: hermesSyncSkills,
|
||||||
models: hermesModels,
|
models: hermesModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
||||||
|
detectModel: () => detectModelFromHermes(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||||
|
|
@ -219,6 +225,15 @@ export function listServerAdapters(): ServerAdapterModule[] {
|
||||||
return Array.from(adaptersByType.values());
|
return Array.from(adaptersByType.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function detectAdapterModel(
|
||||||
|
type: string,
|
||||||
|
): Promise<{ model: string; provider: string; source: string } | 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 {
|
export function findServerAdapter(type: string): ServerAdapterModule | null {
|
||||||
return adaptersByType.get(type) ?? null;
|
return adaptersByType.get(type) ?? null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import {
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.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 { redactEventPayload } from "../redaction.js";
|
||||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||||
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.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);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/companies/:companyId/adapters/:type/test-environment",
|
"/companies/:companyId/adapters/:type/test-environment",
|
||||||
validate(testAdapterEnvironmentSchema),
|
validate(testAdapterEnvironmentSchema),
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
|
"hermes-paperclip-adapter": "^0.2.0",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@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 { codexLocalUIAdapter } from "./codex-local";
|
||||||
import { cursorLocalUIAdapter } from "./cursor";
|
import { cursorLocalUIAdapter } from "./cursor";
|
||||||
import { geminiLocalUIAdapter } from "./gemini-local";
|
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||||
|
import { hermesLocalUIAdapter } from "./hermes-local";
|
||||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||||
import { piLocalUIAdapter } from "./pi-local";
|
import { piLocalUIAdapter } from "./pi-local";
|
||||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||||
|
|
@ -13,6 +14,7 @@ const uiAdapters: UIAdapterModule[] = [
|
||||||
claudeLocalUIAdapter,
|
claudeLocalUIAdapter,
|
||||||
codexLocalUIAdapter,
|
codexLocalUIAdapter,
|
||||||
geminiLocalUIAdapter,
|
geminiLocalUIAdapter,
|
||||||
|
hermesLocalUIAdapter,
|
||||||
openCodeLocalUIAdapter,
|
openCodeLocalUIAdapter,
|
||||||
piLocalUIAdapter,
|
piLocalUIAdapter,
|
||||||
cursorLocalUIAdapter,
|
cursorLocalUIAdapter,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ export interface AdapterModel {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DetectedAdapterModel {
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClaudeLoginResult {
|
export interface ClaudeLoginResult {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
signal: string | null;
|
signal: string | null;
|
||||||
|
|
@ -159,6 +165,10 @@ export const agentsApi = {
|
||||||
api.get<AdapterModel[]>(
|
api.get<AdapterModel[]>(
|
||||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||||
),
|
),
|
||||||
|
detectModel: (companyId: string, type: string) =>
|
||||||
|
api.get<DetectedAdapterModel | null>(
|
||||||
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
|
||||||
|
),
|
||||||
testEnvironment: (
|
testEnvironment: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
type: string,
|
type: string,
|
||||||
|
|
|
||||||
|
|
@ -248,9 +248,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
}
|
}
|
||||||
if (overlay.adapterType !== undefined) {
|
if (overlay.adapterType !== undefined) {
|
||||||
patch.adapterType = overlay.adapterType;
|
patch.adapterType = overlay.adapterType;
|
||||||
// When adapter type changes, send only the new config — don't merge
|
// When adapter type changes, replace adapter-specific fields but preserve
|
||||||
// with old config since old adapter fields are meaningless for the new type
|
// adapter-agnostic fields (env, promptTemplate, etc.) that are shared
|
||||||
patch.adapterConfig = overlay.adapterConfig;
|
// 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) {
|
} else if (Object.keys(overlay.adapterConfig).length > 0) {
|
||||||
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||||
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
|
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
|
||||||
|
|
@ -296,9 +313,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
adapterType === "claude_local" ||
|
adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
adapterType === "gemini_local" ||
|
adapterType === "gemini_local" ||
|
||||||
|
adapterType === "hermes_local" ||
|
||||||
adapterType === "opencode_local" ||
|
adapterType === "opencode_local" ||
|
||||||
adapterType === "pi_local" ||
|
adapterType === "pi_local" ||
|
||||||
adapterType === "cursor";
|
adapterType === "cursor";
|
||||||
|
const isHermesLocal = adapterType === "hermes_local";
|
||||||
const showLegacyWorkingDirectoryField =
|
const showLegacyWorkingDirectoryField =
|
||||||
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
||||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||||
|
|
@ -315,6 +334,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
enabled: Boolean(selectedCompanyId),
|
enabled: Boolean(selectedCompanyId),
|
||||||
});
|
});
|
||||||
const models = fetchedModels ?? externalModels ?? [];
|
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({
|
const { data: companyAgents = [] } = useQuery({
|
||||||
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
|
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
|
||||||
|
|
@ -688,6 +723,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
? "codex"
|
? "codex"
|
||||||
: adapterType === "gemini_local"
|
: adapterType === "gemini_local"
|
||||||
? "gemini"
|
? "gemini"
|
||||||
|
: adapterType === "hermes_local"
|
||||||
|
? "hermes"
|
||||||
: adapterType === "pi_local"
|
: adapterType === "pi_local"
|
||||||
? "pi"
|
? "pi"
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
|
|
@ -709,9 +746,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
}
|
}
|
||||||
open={modelOpen}
|
open={modelOpen}
|
||||||
onOpenChange={setModelOpen}
|
onOpenChange={setModelOpen}
|
||||||
allowDefault={adapterType !== "opencode_local"}
|
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
|
||||||
required={adapterType === "opencode_local"}
|
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
|
||||||
groupByProvider={adapterType === "opencode_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}
|
||||||
|
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
|
||||||
/>
|
/>
|
||||||
{fetchedModelsError && (
|
{fetchedModelsError && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
|
|
@ -976,7 +1022,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
||||||
|
|
||||||
/* ---- Internal sub-components ---- */
|
/* ---- 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. */
|
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||||
|
|
@ -1293,6 +1339,10 @@ function ModelDropdown({
|
||||||
allowDefault,
|
allowDefault,
|
||||||
required,
|
required,
|
||||||
groupByProvider,
|
groupByProvider,
|
||||||
|
creatable,
|
||||||
|
detectedModel,
|
||||||
|
onDetectModel,
|
||||||
|
detectModelLabel,
|
||||||
}: {
|
}: {
|
||||||
models: AdapterModel[];
|
models: AdapterModel[];
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -1302,9 +1352,20 @@ function ModelDropdown({
|
||||||
allowDefault: boolean;
|
allowDefault: boolean;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
groupByProvider: boolean;
|
groupByProvider: boolean;
|
||||||
|
creatable?: boolean;
|
||||||
|
detectedModel?: string | null;
|
||||||
|
onDetectModel?: () => Promise<string | null>;
|
||||||
|
detectModelLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const [modelSearch, setModelSearch] = useState("");
|
const [modelSearch, setModelSearch] = useState("");
|
||||||
|
const [detectingModel, setDetectingModel] = useState(false);
|
||||||
const selected = models.find((m) => m.id === value);
|
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(() => {
|
const filteredModels = useMemo(() => {
|
||||||
return models.filter((m) => {
|
return models.filter((m) => {
|
||||||
if (!modelSearch.trim()) return true;
|
if (!modelSearch.trim()) return true;
|
||||||
|
|
@ -1341,6 +1402,21 @@ function ModelDropdown({
|
||||||
}));
|
}));
|
||||||
}, [filteredModels, groupByProvider]);
|
}, [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 (
|
return (
|
||||||
<Field label="Model" hint={help.model}>
|
<Field label="Model" hint={help.model}>
|
||||||
<Popover
|
<Popover
|
||||||
|
|
@ -1351,7 +1427,7 @@ function ModelDropdown({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<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")}>
|
<span className={cn(!value && "text-muted-foreground")}>
|
||||||
{selected
|
{selected
|
||||||
? selected.label
|
? selected.label
|
||||||
|
|
@ -1361,16 +1437,84 @@ function ModelDropdown({
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||||
<input
|
<div className="relative mb-1">
|
||||||
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"
|
<input
|
||||||
placeholder="Search models..."
|
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"
|
||||||
value={modelSearch}
|
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
|
||||||
onChange={(e) => setModelSearch(e.target.value)}
|
value={modelSearch}
|
||||||
autoFocus
|
onChange={(e) => setModelSearch(e.target.value)}
|
||||||
/>
|
autoFocus
|
||||||
|
/>
|
||||||
|
{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..." : (detectModelLabel ?? "Detect from 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">
|
<div className="max-h-[240px] overflow-y-auto">
|
||||||
{allowDefault && (
|
{allowDefault && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
!value && "bg-accent",
|
!value && "bg-accent",
|
||||||
|
|
@ -1383,6 +1527,20 @@ function ModelDropdown({
|
||||||
Default
|
Default
|
||||||
</button>
|
</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) => (
|
{groupedModels.map((group) => (
|
||||||
<div key={group.provider} className="mb-1 last:mb-0">
|
<div key={group.provider} className="mb-1 last:mb-0">
|
||||||
{groupByProvider && (
|
{groupByProvider && (
|
||||||
|
|
@ -1392,6 +1550,7 @@ function ModelDropdown({
|
||||||
)}
|
)}
|
||||||
{group.entries.map((m) => (
|
{group.entries.map((m) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
|
|
@ -1409,8 +1568,14 @@ function ModelDropdown({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredModels.length === 0 && (
|
{filteredModels.length === 0 && !canCreateManualModel && (
|
||||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
<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>
|
</div>
|
||||||
</PopoverContent>
|
</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";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||||
|
import { HermesIcon } from "./HermesIcon";
|
||||||
|
|
||||||
type AdvancedAdapterType =
|
type AdvancedAdapterType =
|
||||||
| "claude_local"
|
| "claude_local"
|
||||||
|
|
@ -29,7 +30,8 @@ type AdvancedAdapterType =
|
||||||
| "opencode_local"
|
| "opencode_local"
|
||||||
| "pi_local"
|
| "pi_local"
|
||||||
| "cursor"
|
| "cursor"
|
||||||
| "openclaw_gateway";
|
| "openclaw_gateway"
|
||||||
|
| "hermes_local";
|
||||||
|
|
||||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||||
value: AdvancedAdapterType;
|
value: AdvancedAdapterType;
|
||||||
|
|
@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||||
icon: OpenCodeLogoIcon,
|
icon: OpenCodeLogoIcon,
|
||||||
desc: "Local multi-provider agent",
|
desc: "Local multi-provider agent",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "hermes_local",
|
||||||
|
label: "Hermes Agent",
|
||||||
|
icon: HermesIcon,
|
||||||
|
desc: "Local multi-provider agent",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "pi_local",
|
value: "pi_local",
|
||||||
label: "Pi",
|
label: "Pi",
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,14 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { HermesIcon } from "./HermesIcon";
|
||||||
|
|
||||||
type Step = 1 | 2 | 3 | 4;
|
type Step = 1 | 2 | 3 | 4;
|
||||||
type AdapterType =
|
type AdapterType =
|
||||||
| "claude_local"
|
| "claude_local"
|
||||||
| "codex_local"
|
| "codex_local"
|
||||||
| "gemini_local"
|
| "gemini_local"
|
||||||
|
| "hermes_local"
|
||||||
| "opencode_local"
|
| "opencode_local"
|
||||||
| "pi_local"
|
| "pi_local"
|
||||||
| "cursor"
|
| "cursor"
|
||||||
|
|
@ -208,6 +210,7 @@ export function OnboardingWizard() {
|
||||||
adapterType === "claude_local" ||
|
adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
adapterType === "gemini_local" ||
|
adapterType === "gemini_local" ||
|
||||||
|
adapterType === "hermes_local" ||
|
||||||
adapterType === "opencode_local" ||
|
adapterType === "opencode_local" ||
|
||||||
adapterType === "pi_local" ||
|
adapterType === "pi_local" ||
|
||||||
adapterType === "cursor";
|
adapterType === "cursor";
|
||||||
|
|
@ -217,6 +220,8 @@ export function OnboardingWizard() {
|
||||||
? "codex"
|
? "codex"
|
||||||
: adapterType === "gemini_local"
|
: adapterType === "gemini_local"
|
||||||
? "gemini"
|
? "gemini"
|
||||||
|
: adapterType === "hermes_local"
|
||||||
|
? "hermes"
|
||||||
: adapterType === "pi_local"
|
: adapterType === "pi_local"
|
||||||
? "pi"
|
? "pi"
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
|
|
@ -843,6 +848,12 @@ export function OnboardingWizard() {
|
||||||
icon: MousePointer2,
|
icon: MousePointer2,
|
||||||
desc: "Local Cursor agent"
|
desc: "Local Cursor agent"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "hermes_local" as const,
|
||||||
|
label: "Hermes Agent",
|
||||||
|
icon: HermesIcon,
|
||||||
|
desc: "Local multi-provider agent"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "openclaw_gateway" as const,
|
value: "openclaw_gateway" as const,
|
||||||
label: "OpenClaw Gateway",
|
label: "OpenClaw Gateway",
|
||||||
|
|
@ -902,6 +913,7 @@ export function OnboardingWizard() {
|
||||||
{(adapterType === "claude_local" ||
|
{(adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local" ||
|
adapterType === "codex_local" ||
|
||||||
adapterType === "gemini_local" ||
|
adapterType === "gemini_local" ||
|
||||||
|
adapterType === "hermes_local" ||
|
||||||
adapterType === "opencode_local" ||
|
adapterType === "opencode_local" ||
|
||||||
adapterType === "pi_local" ||
|
adapterType === "pi_local" ||
|
||||||
adapterType === "cursor") && (
|
adapterType === "cursor") && (
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export const adapterLabels: Record<string, string> = {
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
|
hermes_local: "Hermes Agent",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,26 @@ type TranscriptBlock =
|
||||||
status: "running" | "completed" | "error";
|
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";
|
type: "stdout";
|
||||||
ts: string;
|
ts: string;
|
||||||
|
|
@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
||||||
return grouped;
|
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[] {
|
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
|
||||||
const blocks: TranscriptBlock[] = [];
|
const blocks: TranscriptBlock[] = [];
|
||||||
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
||||||
|
|
@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||||
if (shouldHideNiceModeStderr(entry.text)) {
|
if (shouldHideNiceModeStderr(entry.text)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
blocks.push({
|
// Batch consecutive stderr entries into a single group
|
||||||
type: "event",
|
const prev = blocks[blocks.length - 1];
|
||||||
ts: entry.ts,
|
if (prev && prev.type === "stderr_group") {
|
||||||
label: "stderr",
|
prev.lines.push({ ts: entry.ts, text: entry.text });
|
||||||
tone: "error",
|
prev.endTs = entry.ts;
|
||||||
text: entry.text,
|
} else {
|
||||||
});
|
blocks.push({
|
||||||
|
type: "stderr_group",
|
||||||
|
ts: entry.ts,
|
||||||
|
endTs: entry.ts,
|
||||||
|
lines: [{ ts: entry.ts, text: entry.text }],
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return groupCommandBlocks(blocks);
|
return groupToolBlocks(groupCommandBlocks(blocks));
|
||||||
}
|
}
|
||||||
|
|
||||||
function TranscriptMessageBlock({
|
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({
|
function TranscriptActivityRow({
|
||||||
block,
|
block,
|
||||||
density,
|
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({
|
function TranscriptStdoutRow({
|
||||||
block,
|
block,
|
||||||
density,
|
density,
|
||||||
|
|
@ -1003,6 +1241,8 @@ export function RunTranscriptView({
|
||||||
)}
|
)}
|
||||||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||||
{block.type === "command_group" && <TranscriptCommandGroup 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" && (
|
{block.type === "stdout" && (
|
||||||
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export const queryKeys = {
|
||||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||||
adapterModels: (companyId: string, adapterType: string) =>
|
adapterModels: (companyId: string, adapterType: string) =>
|
||||||
["agents", companyId, "adapter-models", adapterType] as const,
|
["agents", companyId, "adapter-models", adapterType] as const,
|
||||||
|
detectModel: (companyId: string, adapterType: string) =>
|
||||||
|
["agents", companyId, "detect-model", adapterType] as const,
|
||||||
},
|
},
|
||||||
issues: {
|
issues: {
|
||||||
list: (companyId: string) => ["issues", companyId] as const,
|
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 isLive = run.status === "running" || run.status === "queued";
|
||||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||||
const StatusIcon = statusInfo.icon;
|
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 ?? "")
|
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||||
: run.error ?? "";
|
: 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("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l));
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
|
|
@ -2351,6 +2369,7 @@ function AgentSkillsTab({
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||||
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
||||||
|
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
|
||||||
const lastSavedSkillsRef = useRef<string[]>([]);
|
const lastSavedSkillsRef = useRef<string[]>([]);
|
||||||
const hasHydratedSkillSnapshotRef = useRef(false);
|
const hasHydratedSkillSnapshotRef = useRef(false);
|
||||||
const skipNextSkillAutosaveRef = useRef(true);
|
const skipNextSkillAutosaveRef = useRef(true);
|
||||||
|
|
@ -2680,12 +2699,19 @@ function AgentSkillsTab({
|
||||||
|
|
||||||
{unmanagedSkillRows.length > 0 && (
|
{unmanagedSkillRows.length > 0 && (
|
||||||
<section className="border-y border-border">
|
<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">
|
<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>
|
</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>
|
</div>
|
||||||
{unmanagedSkillRows.map(renderSkillRow)}
|
{unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
|
||||||
gemini_local: "Gemini",
|
gemini_local: "Gemini",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
|
hermes_local: "Hermes",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,12 @@ const adapterLabels: Record<string, string> = {
|
||||||
pi_local: "Pi (local)",
|
pi_local: "Pi (local)",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
|
hermes_local: "Hermes Agent",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
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) {
|
function dateTime(value: string) {
|
||||||
return new Date(value).toLocaleString();
|
return new Date(value).toLocaleString();
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType
|
||||||
"opencode_local",
|
"opencode_local",
|
||||||
"pi_local",
|
"pi_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
|
"hermes_local",
|
||||||
"openclaw_gateway",
|
"openclaw_gateway",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ const adapterLabels: Record<string, string> = {
|
||||||
gemini_local: "Gemini",
|
gemini_local: "Gemini",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
|
hermes_local: "Hermes",
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue