617 lines
19 KiB
TypeScript
617 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Label } from "@kandev/ui/label";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Switch } from "@kandev/ui/switch";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
|
|
import { ModelCombobox } from "@/components/settings/model-combobox";
|
|
import { ModeCombobox } from "@/components/settings/mode-combobox";
|
|
import { ModelFallbackFields } from "@/components/agent/cli-profile-fallback-fields";
|
|
import { CLIFlagsField } from "@/components/settings/cli-flags-field";
|
|
import {
|
|
createAgentAction,
|
|
createAgentProfileAction,
|
|
updateAgentProfileAction,
|
|
} from "@/app/actions/agents";
|
|
import { isHandledApiError } from "@/lib/api/client";
|
|
import type { Agent, AgentProfile, AvailableAgent, CLIFlag } from "@/lib/types/http";
|
|
import { seedDefaultCLIFlags } from "@/lib/cli-flags";
|
|
import {
|
|
buildDefaultPermissions,
|
|
PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
|
|
} from "@/lib/agent-permissions";
|
|
import { t } from "@/lib/i18n";
|
|
|
|
export type CliProfileEditorMode = "create" | "edit";
|
|
|
|
export type CliProfileEditorProps = {
|
|
/** "create" mounts the inline create flow; "edit" patches an existing profile. */
|
|
mode: CliProfileEditorMode;
|
|
/** When `mode === "edit"`, the existing profile to patch. */
|
|
profile?: AgentProfile;
|
|
/** Default profile name suggestion in create mode. */
|
|
defaultProfileName?: string;
|
|
/** Called once a profile is successfully saved. Receives the saved profile. */
|
|
onSaved: (profile: AgentProfile) => void;
|
|
/** Called on cancel; only used by callers that wrap us in a dialog. */
|
|
onCancel?: () => void;
|
|
/**
|
|
* Show the advanced (permissions / passthrough) toggles. Defaults to
|
|
* collapsed; the wizard hides them entirely, the configuration tab
|
|
* exposes them inline.
|
|
*/
|
|
showAdvanced?: boolean;
|
|
/** Whether the editor exposes CLI passthrough. Office agents should keep ACP enabled. */
|
|
allowCliPassthrough?: boolean;
|
|
};
|
|
|
|
type FormState = {
|
|
agentName: string;
|
|
profileName: string;
|
|
model: string;
|
|
fallbackModel: string;
|
|
autoFallback: boolean;
|
|
mode: string;
|
|
cliFlags: CLIFlag[];
|
|
cliPassthrough: boolean;
|
|
allowIndexing: boolean;
|
|
autoApprove: boolean;
|
|
};
|
|
|
|
function pickDefaultAgent(available: AvailableAgent[]): AvailableAgent | undefined {
|
|
return available.find((a) => a.available) ?? available[0];
|
|
}
|
|
|
|
function fromExistingProfile(profile: AgentProfile): FormState {
|
|
return {
|
|
agentName: profile.agentId ?? "",
|
|
profileName: profile.name,
|
|
model: profile.model ?? "",
|
|
fallbackModel: profile.fallbackModel ?? "",
|
|
autoFallback: profile.autoFallback ?? false,
|
|
mode: profile.mode ?? "",
|
|
cliFlags: profile.cliFlags ?? [],
|
|
cliPassthrough: profile.cliPassthrough ?? false,
|
|
allowIndexing: profile.allowIndexing ?? false,
|
|
autoApprove: profile.autoApprove ?? false,
|
|
};
|
|
}
|
|
|
|
function fromDefaultAgent(
|
|
defaultName: string,
|
|
defaultAgent: AvailableAgent | undefined,
|
|
): FormState {
|
|
const cfg = defaultAgent?.model_config;
|
|
const permissionSettings = defaultAgent?.permission_settings ?? {};
|
|
const defaults = buildDefaultPermissions(permissionSettings);
|
|
return {
|
|
agentName: defaultAgent?.name ?? "",
|
|
profileName: defaultName,
|
|
model: cfg?.default_model ?? "",
|
|
fallbackModel: "",
|
|
autoFallback: false,
|
|
mode: cfg?.current_mode_id ?? "",
|
|
cliFlags: seedDefaultCLIFlags(permissionSettings),
|
|
cliPassthrough: false,
|
|
...defaults,
|
|
};
|
|
}
|
|
|
|
function initialState(
|
|
mode: CliProfileEditorMode,
|
|
profile: AgentProfile | undefined,
|
|
defaultName: string,
|
|
defaultAgent: AvailableAgent | undefined,
|
|
): FormState {
|
|
if (mode === "edit" && profile) return fromExistingProfile(profile);
|
|
return fromDefaultAgent(defaultName, defaultAgent);
|
|
}
|
|
|
|
function ProfileNameField({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div>
|
|
<Label htmlFor="cli-profile-name">{t("common:profileName")}</Label>
|
|
<Input
|
|
id="cli-profile-name"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={t("common:default")}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CliProfileEditor({
|
|
mode,
|
|
profile,
|
|
defaultProfileName = "default",
|
|
onSaved,
|
|
onCancel,
|
|
showAdvanced = false,
|
|
allowCliPassthrough = true,
|
|
}: CliProfileEditorProps) {
|
|
const availableAgents = useAvailableAgents();
|
|
const settingsAgents = useAppStore((s) => s.settingsAgents.items);
|
|
const installed = useMemo(
|
|
() => availableAgents.items.filter((a) => a.available),
|
|
[availableAgents.items],
|
|
);
|
|
const [form, setForm] = useState<FormState>(() =>
|
|
initialState(mode, profile, defaultProfileName, pickDefaultAgent(installed)),
|
|
);
|
|
const [saving, setSaving] = useState(false);
|
|
const [advancedOpen, setAdvancedOpen] = useState(showAdvanced);
|
|
|
|
const currentAgent = installed.find((a) => a.name === form.agentName);
|
|
const modelConfig = currentAgent?.model_config;
|
|
const permissionSettings = currentAgent?.permission_settings;
|
|
|
|
const patch = useCallback((p: Partial<FormState>) => {
|
|
setForm((prev) => ({ ...prev, ...p }));
|
|
}, []);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!form.agentName || !form.profileName.trim()) return;
|
|
setSaving(true);
|
|
try {
|
|
const saved =
|
|
mode === "edit" && profile
|
|
? await saveExistingProfile(profile.id, form)
|
|
: await saveNewProfile(form, settingsAgents);
|
|
onSaved(saved);
|
|
} catch (error) {
|
|
if (isHandledApiError(error)) return;
|
|
throw error;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [form, mode, profile, settingsAgents, onSaved]);
|
|
|
|
const canSave = form.agentName !== "" && form.profileName.trim() !== "";
|
|
|
|
return (
|
|
<div className="space-y-4" data-testid="cli-profile-editor">
|
|
{mode === "create" && (
|
|
<CliClientPicker
|
|
installed={installed}
|
|
value={form.agentName}
|
|
onChange={(name) =>
|
|
patch({
|
|
agentName: name,
|
|
model: installed.find((a) => a.name === name)?.model_config.default_model ?? "",
|
|
// A fallback model belongs to the previous client's model
|
|
// vocabulary; carrying it over would save a false gone-model
|
|
// configuration for the new client.
|
|
fallbackModel: "",
|
|
mode: installed.find((a) => a.name === name)?.model_config.current_mode_id ?? "",
|
|
cliFlags: seedDefaultCLIFlags(
|
|
installed.find((a) => a.name === name)?.permission_settings ?? {},
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<ProfileNameField
|
|
value={form.profileName}
|
|
onChange={(profileName) => patch({ profileName })}
|
|
/>
|
|
|
|
<ModelModeFieldsBinding form={form} patch={patch} modelConfig={modelConfig ?? null} />
|
|
|
|
<AdvancedToggles
|
|
open={advancedOpen}
|
|
onToggle={() => setAdvancedOpen((o) => !o)}
|
|
cliPassthrough={form.cliPassthrough}
|
|
allowIndexing={form.allowIndexing}
|
|
autoApprove={form.autoApprove}
|
|
cliFlags={form.cliFlags}
|
|
permissionSettings={permissionSettings ?? {}}
|
|
allowCliPassthrough={allowCliPassthrough}
|
|
showAllowIndexing={Boolean(permissionSettings?.allow_indexing?.supported)}
|
|
onCliPassthroughChange={(v) => patch({ cliPassthrough: v })}
|
|
onAllowIndexingChange={(v) => patch({ allowIndexing: v })}
|
|
onAutoApproveChange={(v) => patch({ autoApprove: v })}
|
|
onCliFlagsChange={(v) => patch({ cliFlags: v })}
|
|
/>
|
|
|
|
<EditorFooter
|
|
mode={mode}
|
|
saving={saving}
|
|
canSave={canSave}
|
|
onSave={handleSave}
|
|
onCancel={onCancel}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buttonLabel(mode: CliProfileEditorMode, saving: boolean): string {
|
|
if (saving) return mode === "edit" ? t("agents:savingProfile") : t("agents:creatingProfile");
|
|
return mode === "edit" ? t("agents:saveProfile") : t("agents:createProfile");
|
|
}
|
|
|
|
function EditorFooter({
|
|
mode,
|
|
saving,
|
|
canSave,
|
|
onSave,
|
|
onCancel,
|
|
}: {
|
|
mode: CliProfileEditorMode;
|
|
saving: boolean;
|
|
canSave: boolean;
|
|
onSave: () => void;
|
|
onCancel?: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex items-center justify-end gap-2 pt-2">
|
|
{onCancel && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={onCancel}
|
|
disabled={saving}
|
|
className="cursor-pointer"
|
|
>
|
|
{t("common:cancel")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
onClick={onSave}
|
|
disabled={!canSave || saving}
|
|
className="cursor-pointer"
|
|
>
|
|
{buttonLabel(mode, saving)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CliClientPicker({
|
|
installed,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
installed: AvailableAgent[];
|
|
value: string;
|
|
onChange: (name: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div>
|
|
<Label>{t("common:cliClient")}</Label>
|
|
<Select value={value} onValueChange={onChange} disabled={installed.length === 0}>
|
|
<SelectTrigger className="mt-1 cursor-pointer">
|
|
<SelectValue placeholder={t("common:pickACliClient")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{installed.map((agent) => (
|
|
<SelectItem key={agent.name} value={agent.name} className="cursor-pointer">
|
|
{agent.display_name || agent.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{installed.length === 0 && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("common:noCliClientsInstalledYetInstall")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ModelModeFieldsBinding forwards the editor's form state to ModelModeFields.
|
|
// Extracted so CliProfileEditor stays under the linter's line budget.
|
|
function ModelModeFieldsBinding({
|
|
form,
|
|
patch,
|
|
modelConfig,
|
|
}: {
|
|
form: FormState;
|
|
patch: (p: Partial<FormState>) => void;
|
|
modelConfig: NonNullable<AvailableAgent["model_config"]> | null;
|
|
}) {
|
|
return (
|
|
<ModelModeFields
|
|
modelConfig={modelConfig}
|
|
model={form.model}
|
|
fallbackModel={form.fallbackModel}
|
|
autoFallback={form.autoFallback}
|
|
mode={form.mode}
|
|
onModelChange={(v) => patch({ model: v })}
|
|
onFallbackModelChange={(v) => patch({ fallbackModel: v })}
|
|
onAutoFallbackChange={(v) => patch({ autoFallback: v })}
|
|
onModeChange={(v) => patch({ mode: v })}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type ModelModeFieldsProps = {
|
|
modelConfig: NonNullable<AvailableAgent["model_config"]> | null;
|
|
model: string;
|
|
fallbackModel: string;
|
|
autoFallback: boolean;
|
|
mode: string;
|
|
onModelChange: (v: string) => void;
|
|
onFallbackModelChange: (v: string) => void;
|
|
onAutoFallbackChange: (v: boolean) => void;
|
|
onModeChange: (v: string) => void;
|
|
};
|
|
|
|
function ModelModeFields({
|
|
modelConfig,
|
|
model,
|
|
fallbackModel,
|
|
autoFallback,
|
|
mode,
|
|
onModelChange,
|
|
onFallbackModelChange,
|
|
onAutoFallbackChange,
|
|
onModeChange,
|
|
}: ModelModeFieldsProps) {
|
|
const { t } = useTranslation();
|
|
if (!modelConfig) {
|
|
return <p className="text-xs text-muted-foreground">{t("common:pickACliClientToLoad")}</p>;
|
|
}
|
|
const availableModels = modelConfig.available_models ?? [];
|
|
const startModelGone = Boolean(model && !availableModels.some((m) => m.id === model));
|
|
const fallbackModelGone = Boolean(
|
|
fallbackModel && !availableModels.some((m) => m.id === fallbackModel),
|
|
);
|
|
return (
|
|
<div className="grid grid-cols-1 gap-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>{t("common:model")}</Label>
|
|
<ModelCombobox
|
|
value={model}
|
|
onChange={onModelChange}
|
|
models={
|
|
startModelGone
|
|
? [
|
|
...availableModels,
|
|
{
|
|
id: model,
|
|
name: `${model} (${t("settings:startModelUnavailable")})`,
|
|
disabled: true,
|
|
},
|
|
]
|
|
: availableModels
|
|
}
|
|
currentModelId={modelConfig.current_model_id}
|
|
/>
|
|
</div>
|
|
{(modelConfig.available_modes ?? []).length > 0 && (
|
|
<div>
|
|
<Label>{t("common:mode")}</Label>
|
|
<ModeCombobox
|
|
value={mode}
|
|
onChange={onModeChange}
|
|
modes={modelConfig.available_modes ?? []}
|
|
currentModeId={modelConfig.current_mode_id}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<ModelFallbackFields
|
|
availableModels={availableModels}
|
|
fallbackModel={fallbackModel}
|
|
fallbackModelGone={fallbackModelGone}
|
|
autoFallback={autoFallback}
|
|
currentModelId={modelConfig.current_model_id}
|
|
onFallbackModelChange={onFallbackModelChange}
|
|
onAutoFallbackChange={onAutoFallbackChange}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type AdvancedTogglesProps = {
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
cliPassthrough: boolean;
|
|
allowIndexing: boolean;
|
|
autoApprove: boolean;
|
|
cliFlags: CLIFlag[];
|
|
permissionSettings: AvailableAgent["permission_settings"];
|
|
allowCliPassthrough: boolean;
|
|
showAllowIndexing: boolean;
|
|
onCliPassthroughChange: (v: boolean) => void;
|
|
onAllowIndexingChange: (v: boolean) => void;
|
|
onAutoApproveChange: (v: boolean) => void;
|
|
onCliFlagsChange: (v: CLIFlag[]) => void;
|
|
};
|
|
|
|
function AdvancedToggles({
|
|
open,
|
|
onToggle,
|
|
cliPassthrough,
|
|
allowIndexing,
|
|
autoApprove,
|
|
cliFlags,
|
|
permissionSettings,
|
|
allowCliPassthrough,
|
|
showAllowIndexing,
|
|
onCliPassthroughChange,
|
|
onAllowIndexingChange,
|
|
onAutoApproveChange,
|
|
onCliFlagsChange,
|
|
}: AdvancedTogglesProps) {
|
|
const { t } = useTranslation();
|
|
const autoSetting = permissionSettings?.auto_approve;
|
|
const showAgentctlAutoApprove = Boolean(
|
|
autoSetting?.supported && autoSetting.apply_method === PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
|
|
);
|
|
|
|
return (
|
|
<div className="border-t pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer"
|
|
>
|
|
{open ? t("common:hideAdvancedOptions") : t("common:showAdvancedOptions")}
|
|
</button>
|
|
{open && (
|
|
<div className="mt-3 space-y-3">
|
|
{allowCliPassthrough && (
|
|
<ToggleRow
|
|
id="cli-passthrough"
|
|
label={t("common:cliPassthrough")}
|
|
description={t("common:forwardStdinStdoutStraightToThe")}
|
|
checked={cliPassthrough}
|
|
onChange={onCliPassthroughChange}
|
|
/>
|
|
)}
|
|
{showAgentctlAutoApprove && autoSetting && (
|
|
<AgentctlAutoApproveRow
|
|
id="cli-auto-approve"
|
|
setting={autoSetting}
|
|
checked={autoApprove}
|
|
onChange={onAutoApproveChange}
|
|
/>
|
|
)}
|
|
{showAllowIndexing && (
|
|
<ToggleRow
|
|
id="allow-indexing"
|
|
label={t("common:allowIndexing")}
|
|
description={t("common:permitTheCliToUploadCode")}
|
|
checked={allowIndexing}
|
|
onChange={onAllowIndexingChange}
|
|
/>
|
|
)}
|
|
<CLIFlagsField
|
|
flags={cliFlags}
|
|
permissionSettings={permissionSettings ?? {}}
|
|
onChange={onCliFlagsChange}
|
|
variant="compact"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({
|
|
id,
|
|
label,
|
|
description,
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor={id} className="text-sm">
|
|
{label}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">{description}</p>
|
|
</div>
|
|
<Switch id={id} checked={checked} onCheckedChange={onChange} className="cursor-pointer" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AgentctlAutoApproveRow({
|
|
id,
|
|
setting,
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
id: string;
|
|
setting: NonNullable<AvailableAgent["permission_settings"]>["auto_approve"];
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex items-start justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor={id} className="text-sm text-destructive">
|
|
{setting?.label ?? t("common:autoApproveAllPermissions")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{setting?.description ?? t("common:kandevAllowsEveryAgentPermissionRequest")}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id={id}
|
|
checked={checked}
|
|
onCheckedChange={onChange}
|
|
className="cursor-pointer data-[state=checked]:bg-destructive"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
async function saveExistingProfile(id: string, form: FormState): Promise<AgentProfile> {
|
|
return updateAgentProfileAction(id, {
|
|
name: form.profileName.trim(),
|
|
model: form.model,
|
|
fallback_model: form.fallbackModel ?? "",
|
|
auto_fallback: form.autoFallback,
|
|
mode: form.mode || undefined,
|
|
allow_indexing: form.allowIndexing,
|
|
auto_approve: form.autoApprove,
|
|
cli_flags: form.cliFlags,
|
|
cli_passthrough: form.cliPassthrough,
|
|
});
|
|
}
|
|
|
|
async function saveNewProfile(form: FormState, settingsAgents: Agent[]): Promise<AgentProfile> {
|
|
const existingAgent = settingsAgents.find((a) => a.name === form.agentName);
|
|
const profilePayload = {
|
|
name: form.profileName.trim(),
|
|
model: form.model,
|
|
fallback_model: form.fallbackModel ?? "",
|
|
auto_fallback: form.autoFallback,
|
|
mode: form.mode || undefined,
|
|
allow_indexing: form.allowIndexing,
|
|
auto_approve: form.autoApprove,
|
|
cli_passthrough: form.cliPassthrough,
|
|
cli_flags: form.cliFlags,
|
|
};
|
|
if (existingAgent) {
|
|
return createAgentProfileAction(existingAgent.id, profilePayload);
|
|
}
|
|
// Otherwise create the kanban Agent row + the first profile in one POST.
|
|
const created = await createAgentAction({
|
|
name: form.agentName,
|
|
profiles: [profilePayload],
|
|
});
|
|
const newProfile = created.profiles.find((p) => p.name === profilePayload.name);
|
|
if (!newProfile) {
|
|
throw new Error(
|
|
`Created agent ${form.agentName} but the response did not include the new profile.`,
|
|
);
|
|
}
|
|
return {
|
|
...newProfile,
|
|
agentId: newProfile.agentId || created.id,
|
|
agentDisplayName: newProfile.agentDisplayName || created.name,
|
|
};
|
|
}
|