mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Merge branch 'master' into add-gpt-5-4-xhigh-effort
This commit is contained in:
commit
19aaa54ae4
882 changed files with 316479 additions and 9403 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import type {
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -43,6 +44,8 @@ import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-field
|
|||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { ReportsToPicker } from "./ReportsToPicker";
|
||||
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
|
||||
|
||||
/* ---- Create mode values ---- */
|
||||
|
||||
|
|
@ -59,6 +62,12 @@ type AgentConfigFormProps = {
|
|||
onSaveActionChange?: (save: (() => void) | null) => void;
|
||||
onCancelActionChange?: (cancel: (() => void) | null) => void;
|
||||
hideInlineSave?: boolean;
|
||||
showAdapterTypeField?: boolean;
|
||||
showAdapterTestEnvironmentButton?: boolean;
|
||||
showCreateRunPolicySection?: boolean;
|
||||
hideInstructionsFile?: boolean;
|
||||
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
|
||||
hidePromptTemplate?: boolean;
|
||||
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
|
||||
sectionLayout?: "inline" | "cards";
|
||||
} & (
|
||||
|
|
@ -164,6 +173,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const { mode, adapterModels: externalModels } = props;
|
||||
const isCreate = mode === "create";
|
||||
const cards = props.sectionLayout === "cards";
|
||||
const showAdapterTypeField = props.showAdapterTypeField ?? true;
|
||||
const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true;
|
||||
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
|
||||
const hideInstructionsFile = props.hideInstructionsFile ?? false;
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -223,7 +236,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
|
||||
/** Build accumulated patch and send to parent */
|
||||
function handleSave() {
|
||||
const handleCancel = useCallback(() => {
|
||||
setOverlay({ ...emptyOverlay });
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (isCreate || !isDirty) return;
|
||||
const agent = props.agent;
|
||||
const patch: Record<string, unknown> = {};
|
||||
|
|
@ -233,9 +250,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 };
|
||||
|
|
@ -250,21 +284,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
|
||||
props.onSave(patch);
|
||||
}
|
||||
}, [isCreate, isDirty, overlay, props]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreate) {
|
||||
props.onDirtyChange?.(isDirty);
|
||||
props.onSaveActionChange?.(() => handleSave());
|
||||
props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay }));
|
||||
return () => {
|
||||
props.onSaveActionChange?.(null);
|
||||
props.onCancelActionChange?.(null);
|
||||
props.onDirtyChange?.(false);
|
||||
};
|
||||
props.onSaveActionChange?.(handleSave);
|
||||
props.onCancelActionChange?.(handleCancel);
|
||||
}
|
||||
return;
|
||||
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreate) return;
|
||||
return () => {
|
||||
props.onSaveActionChange?.(null);
|
||||
props.onCancelActionChange?.(null);
|
||||
props.onDirtyChange?.(false);
|
||||
};
|
||||
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
|
||||
|
||||
// ---- Resolve values ----
|
||||
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
||||
|
|
@ -277,8 +314,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const isLocal =
|
||||
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]);
|
||||
|
||||
// Fetch adapter models for the effective adapter type
|
||||
|
|
@ -293,6 +336,28 @@ 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"],
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(!isCreate && selectedCompanyId),
|
||||
});
|
||||
|
||||
/** Props passed to adapter-specific config field components */
|
||||
const adapterFieldProps = {
|
||||
|
|
@ -305,6 +370,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
|
||||
mark: mark as (group: "adapterConfig", field: string, value: unknown) => void,
|
||||
models,
|
||||
hideInstructionsFile,
|
||||
};
|
||||
|
||||
// Section toggle state — advanced always starts collapsed
|
||||
|
|
@ -369,13 +435,33 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
)
|
||||
: adapterType === "cursor"
|
||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||
const showThinkingEffort = adapterType !== "gemini_local";
|
||||
const codexSearchEnabled = adapterType === "codex_local"
|
||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||
: false;
|
||||
|
||||
const effectiveRuntimeConfig = useMemo(() => {
|
||||
if (isCreate) {
|
||||
return {
|
||||
heartbeat: {
|
||||
enabled: val!.heartbeatEnabled,
|
||||
intervalSec: val!.intervalSec,
|
||||
},
|
||||
};
|
||||
}
|
||||
const mergedHeartbeat = {
|
||||
...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object"
|
||||
? runtimeConfig.heartbeat as Record<string, unknown>
|
||||
: {}),
|
||||
...overlay.heartbeat,
|
||||
};
|
||||
return {
|
||||
...runtimeConfig,
|
||||
heartbeat: mergedHeartbeat,
|
||||
};
|
||||
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
||||
return (
|
||||
<div className={cn("relative", cards && "space-y-6")}>
|
||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||
|
|
@ -420,6 +506,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
placeholder="e.g. VP of Engineering"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Reports to" hint={help.reportsTo}>
|
||||
<ReportsToPicker
|
||||
agents={companyAgents}
|
||||
value={eff("identity", "reportsTo", props.agent.reportsTo ?? null)}
|
||||
onChange={(id) => mark("identity", "reportsTo", id)}
|
||||
excludeAgentIds={[props.agent.id]}
|
||||
chooseLabel="Choose manager…"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Capabilities" hint={help.capabilities}>
|
||||
<MarkdownEditor
|
||||
value={eff("identity", "capabilities", props.agent.capabilities ?? "")}
|
||||
|
|
@ -435,24 +530,29 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}}
|
||||
/>
|
||||
</Field>
|
||||
{isLocal && (
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"promptTemplate",
|
||||
String(config.promptTemplate ?? ""),
|
||||
)}
|
||||
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${props.agent.id}/prompt-template`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
{isLocal && !props.hidePromptTemplate && (
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"promptTemplate",
|
||||
String(config.promptTemplate ?? ""),
|
||||
)}
|
||||
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${props.agent.id}/prompt-template`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -465,65 +565,73 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
? <h3 className="text-sm font-medium">Adapter</h3>
|
||||
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => testEnvironment.mutate()}
|
||||
disabled={testEnvironment.isPending || !selectedCompanyId}
|
||||
>
|
||||
{testEnvironment.isPending ? "Testing..." : "Test environment"}
|
||||
</Button>
|
||||
{showAdapterTestEnvironmentButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => testEnvironment.mutate()}
|
||||
disabled={testEnvironment.isPending || !selectedCompanyId}
|
||||
>
|
||||
{testEnvironment.isPending ? "Testing..." : "Test environment"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
<Field label="Adapter type" hint={help.adapterType}>
|
||||
<AdapterTypeDropdown
|
||||
value={adapterType}
|
||||
onChange={(t) => {
|
||||
if (isCreate) {
|
||||
// Reset all adapter-specific fields to defaults when switching adapter type
|
||||
const { adapterType: _at, ...defaults } = defaultCreateValues;
|
||||
const nextValues: CreateConfigValues = { ...defaults, adapterType: t };
|
||||
if (t === "codex_local") {
|
||||
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
||||
nextValues.dangerouslyBypassSandbox =
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
} else if (t === "cursor") {
|
||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
} else if (t === "opencode_local") {
|
||||
nextValues.model = "";
|
||||
{showAdapterTypeField && (
|
||||
<Field label="Adapter type" hint={help.adapterType}>
|
||||
<AdapterTypeDropdown
|
||||
value={adapterType}
|
||||
onChange={(t) => {
|
||||
if (isCreate) {
|
||||
// Reset all adapter-specific fields to defaults when switching adapter type
|
||||
const { adapterType: _at, ...defaults } = defaultCreateValues;
|
||||
const nextValues: CreateConfigValues = { ...defaults, adapterType: t };
|
||||
if (t === "codex_local") {
|
||||
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
||||
nextValues.dangerouslyBypassSandbox =
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
} else if (t === "gemini_local") {
|
||||
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
} else if (t === "cursor") {
|
||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
} else if (t === "opencode_local") {
|
||||
nextValues.model = "";
|
||||
}
|
||||
set!(nextValues);
|
||||
} else {
|
||||
// Clear all adapter config and explicitly blank out model + effort/mode keys
|
||||
// so the old adapter's values don't bleed through via eff()
|
||||
setOverlay((prev) => ({
|
||||
...prev,
|
||||
adapterType: t,
|
||||
adapterConfig: {
|
||||
model:
|
||||
t === "codex_local"
|
||||
? DEFAULT_CODEX_LOCAL_MODEL
|
||||
: t === "gemini_local"
|
||||
? DEFAULT_GEMINI_LOCAL_MODEL
|
||||
: t === "cursor"
|
||||
? DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: "",
|
||||
effort: "",
|
||||
modelReasoningEffort: "",
|
||||
variant: "",
|
||||
mode: "",
|
||||
...(t === "codex_local"
|
||||
? {
|
||||
dangerouslyBypassApprovalsAndSandbox:
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
set!(nextValues);
|
||||
} else {
|
||||
// Clear all adapter config and explicitly blank out model + effort/mode keys
|
||||
// so the old adapter's values don't bleed through via eff()
|
||||
setOverlay((prev) => ({
|
||||
...prev,
|
||||
adapterType: t,
|
||||
adapterConfig: {
|
||||
model:
|
||||
t === "codex_local"
|
||||
? DEFAULT_CODEX_LOCAL_MODEL
|
||||
: t === "cursor"
|
||||
? DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: "",
|
||||
effort: "",
|
||||
modelReasoningEffort: "",
|
||||
variant: "",
|
||||
mode: "",
|
||||
...(t === "codex_local"
|
||||
? {
|
||||
dangerouslyBypassApprovalsAndSandbox:
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{testEnvironment.error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
|
|
@ -538,8 +646,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
)}
|
||||
|
||||
{/* Working directory */}
|
||||
{isLocal && (
|
||||
<Field label="Working directory" hint={help.cwd}>
|
||||
{showLegacyWorkingDirectoryField && (
|
||||
<Field label="Working directory (deprecated)" hint={help.cwd}>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<DraftInput
|
||||
|
|
@ -564,19 +672,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
|
||||
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
|
||||
{isLocal && isCreate && (
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={val!.promptTemplate}
|
||||
onChange={(v) => set!({ promptTemplate: v })}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = "agents/drafts/prompt-template";
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={val!.promptTemplate}
|
||||
onChange={(v) => set!({ promptTemplate: v })}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = "agents/drafts/prompt-template";
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Adapter-specific fields */}
|
||||
|
|
@ -610,8 +723,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
placeholder={
|
||||
adapterType === "codex_local"
|
||||
? "codex"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude"
|
||||
|
|
@ -629,9 +748,18 @@ 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}
|
||||
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
|
||||
/>
|
||||
{fetchedModelsError && (
|
||||
<p className="text-xs text-destructive">
|
||||
|
|
@ -641,51 +769,54 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</p>
|
||||
)}
|
||||
|
||||
<ThinkingEffortDropdown
|
||||
value={currentThinkingEffort}
|
||||
options={thinkingEffortOptions}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ thinkingEffort: v })
|
||||
: mark("adapterConfig", thinkingEffortKey, v || undefined)
|
||||
}
|
||||
open={thinkingEffortOpen}
|
||||
onOpenChange={setThinkingEffortOpen}
|
||||
/>
|
||||
{adapterType === "codex_local" &&
|
||||
codexSearchEnabled &&
|
||||
currentThinkingEffort === "minimal" && (
|
||||
<p className="text-xs text-amber-400">
|
||||
Codex may reject `minimal` thinking when search is enabled.
|
||||
</p>
|
||||
)}
|
||||
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
|
||||
<MarkdownEditor
|
||||
value={
|
||||
isCreate
|
||||
? val!.bootstrapPrompt
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"bootstrapPromptTemplate",
|
||||
String(config.bootstrapPromptTemplate ?? ""),
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ bootstrapPrompt: v })
|
||||
: mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
|
||||
}
|
||||
placeholder="Optional initial setup prompt for the first run"
|
||||
contentClassName="min-h-[44px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = isCreate
|
||||
? "agents/drafts/bootstrap-prompt"
|
||||
: `agents/${props.agent.id}/bootstrap-prompt`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
{showThinkingEffort && (
|
||||
<>
|
||||
<ThinkingEffortDropdown
|
||||
value={currentThinkingEffort}
|
||||
options={thinkingEffortOptions}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ thinkingEffort: v })
|
||||
: mark("adapterConfig", thinkingEffortKey, v || undefined)
|
||||
}
|
||||
open={thinkingEffortOpen}
|
||||
onOpenChange={setThinkingEffortOpen}
|
||||
/>
|
||||
{adapterType === "codex_local" &&
|
||||
codexSearchEnabled &&
|
||||
currentThinkingEffort === "minimal" && (
|
||||
<p className="text-xs text-amber-400">
|
||||
Codex may reject `minimal` thinking when search is enabled.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
|
||||
<>
|
||||
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
|
||||
<MarkdownEditor
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"bootstrapPromptTemplate",
|
||||
String(config.bootstrapPromptTemplate ?? ""),
|
||||
)}
|
||||
onChange={(v) =>
|
||||
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
|
||||
}
|
||||
placeholder="Optional initial setup prompt for the first run"
|
||||
contentClassName="min-h-[44px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${props.agent.id}/bootstrap-prompt`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent's prompt template or instructions file instead.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{adapterType === "claude_local" && (
|
||||
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||
)}
|
||||
|
|
@ -763,7 +894,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
)}
|
||||
|
||||
{/* ---- Run Policy ---- */}
|
||||
{isCreate ? (
|
||||
{isCreate && showCreateRunPolicySection ? (
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
||||
|
|
@ -784,7 +915,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : !isCreate ? (
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
||||
|
|
@ -850,7 +981,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
|
@ -893,7 +1024,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_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 }[] = [
|
||||
|
|
@ -1210,6 +1341,10 @@ function ModelDropdown({
|
|||
allowDefault,
|
||||
required,
|
||||
groupByProvider,
|
||||
creatable,
|
||||
detectedModel,
|
||||
onDetectModel,
|
||||
detectModelLabel,
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
|
|
@ -1219,9 +1354,20 @@ function ModelDropdown({
|
|||
allowDefault: boolean;
|
||||
required: boolean;
|
||||
groupByProvider: boolean;
|
||||
creatable?: boolean;
|
||||
detectedModel?: string | null;
|
||||
onDetectModel?: () => Promise<string | null>;
|
||||
detectModelLabel?: string;
|
||||
}) {
|
||||
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;
|
||||
|
|
@ -1258,6 +1404,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
|
||||
|
|
@ -1268,7 +1429,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
|
||||
|
|
@ -1278,16 +1439,84 @@ 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)}
|
||||
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">
|
||||
{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",
|
||||
|
|
@ -1300,6 +1529,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 && (
|
||||
|
|
@ -1309,6 +1552,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",
|
||||
|
|
@ -1326,8 +1570,14 @@ function ModelDropdown({
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||
{filteredModels.length === 0 && !canCreateManualModel && (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{onDetectModel
|
||||
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
|
||||
: "No models found."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue