refactor(ui): Phase 3 — Zod schemas + React Hook Form for 6 form dialogs

Add zod@4.3.6, react-hook-form@7.72.1, @hookform/resolvers@5.2.2.

Create src/schemas/ with 6 validation schemas:
- agent.schema.ts, provider.schema.ts, cron.schema.ts
- channel.schema.ts, credential.schema.ts, heartbeat.schema.ts

Refactor 6 form dialogs from manual useState chains to RHF + Zod:
- agent-create-dialog: 12 → 2 useState
- provider-form-dialog: 8 → 0 useState
- cron-form-dialog: 7 → 0 useState
- channel-instance-form-dialog: 11 → 6 useState
- cli-credential-form-dialog: 18 → 9 useState
- heartbeat-config-dialog: 19 → 4 useState

Total: 75 useState removed, replaced with type-safe form state.
Async side-effects kept as hooks. No behavior changes.
This commit is contained in:
viettranx committed 2026-04-03 22:35:06 +07:00
1 parent 1187146626
commit 7a6a944622
14 files changed
+767 -429

No files matched your search

+3
View File
@@ -13,6 +13,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-table": "^8.21.3",
@@ -30,6 +31,7 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-force-graph-2d": "^1.29.1",
"react-hook-form": "^7.72.1",
"react-i18next": "^17.0.1",
"react-markdown": "^10.1.0",
"react-router": "^7.13.2",
@@ -37,6 +39,7 @@
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
+34
View File
@@ -17,6 +17,9 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.72.1(react@19.2.4))
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.2.2)
@@ -68,6 +71,9 @@ importers:
react-force-graph-2d:
specifier: ^1.29.1
version: 1.29.1(react@19.2.4)
react-hook-form:
specifier: ^7.72.1
version: 7.72.1(react@19.2.4)
react-i18next:
specifier: ^17.0.1
version: 17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2)
@@ -89,6 +95,9 @@ importers:
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
zod:
specifier: ^4.3.6
version: 4.3.6
zustand:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
@@ -176,6 +185,11 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
peerDependencies:
react-hook-form: ^7.55.0
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1880,6 +1894,12 @@ packages:
peerDependencies:
react: '*'
react-hook-form@7.72.1:
resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.0.1:
resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==}
peerDependencies:
@@ -2197,6 +2217,9 @@ packages:
utf-8-validate:
optional: true
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
@@ -2295,6 +2318,11 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
'@hookform/resolvers@5.2.2(react-hook-form@7.72.1(react@19.2.4))':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.72.1(react@19.2.4)
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -4202,6 +4230,10 @@ snapshots:
react: 19.2.4
react-kapsule: 2.5.7(react@19.2.4)
react-hook-form@7.72.1(react@19.2.4):
dependencies:
react: 19.2.4
react-i18next@17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2):
dependencies:
'@babel/runtime': 7.29.2
@@ -4549,6 +4581,8 @@ snapshots:
ws@8.20.0: {}
zod@4.3.6: {}
zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.4)
+125 -80
View File
@@ -1,4 +1,6 @@
import { useState, useMemo, useEffect } from "react";
import { useMemo, useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { ChevronRight } from "lucide-react";
import {
@@ -22,11 +24,13 @@ import {
} from "@/components/ui/select";
import { Combobox } from "@/components/ui/combobox";
import type { AgentData } from "@/types/agent";
import { slugify, isValidSlug } from "@/lib/slug";
import { slugify } from "@/lib/slug";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import { useAgentPresets } from "./agent-presets";
import { agentCreateSchema, type AgentCreateFormData } from "@/schemas/agent.schema";
import { useState } from "react";
interface AgentCreateDialogProps {
open: boolean;
@@ -38,21 +42,32 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
const { t } = useTranslation("agents");
const agentPresets = useAgentPresets();
const { providers } = useProviders();
const [emoji, setEmoji] = useState("");
const [agentKey, setAgentKey] = useState("");
const [keyTouched, setKeyTouched] = useState(false);
const [displayName, setDisplayName] = useState("");
const [provider, setProvider] = useState("");
const [model, setModel] = useState("");
const [agentType, setAgentType] = useState<"open" | "predefined">("predefined");
const [description, setDescription] = useState("");
const [selfEvolve, setSelfEvolve] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [submitError, setSubmitError] = useState("");
const form = useForm<AgentCreateFormData>({
resolver: zodResolver(agentCreateSchema),
mode: "onChange",
defaultValues: {
emoji: "",
displayName: "",
agentKey: "",
provider: "",
model: "",
agentType: "predefined",
description: "",
selfEvolve: false,
},
});
const { register, control, handleSubmit, watch, setValue, reset, formState: { errors } } = form;
const provider = watch("provider");
const model = watch("model");
const agentType = watch("agentType");
const enabledProviders = providers.filter((p) => p.enabled);
// Look up provider ID from selected provider name for model fetching
const selectedProvider = useMemo(
() => enabledProviders.find((p) => p.name === provider),
[enabledProviders, provider],
@@ -66,6 +81,15 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
resetVerify();
}, [provider, model, resetVerify]);
// Reset form when dialog closes
useEffect(() => {
if (!open) {
reset();
setSubmitError("");
resetVerify();
}
}, [open, reset, resetVerify]);
const handleVerify = async () => {
if (!selectedProviderId || !model.trim()) return;
await verify(selectedProviderId, model.trim());
@@ -74,49 +98,46 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
const handleVerifyAndCreate = async () => {
if (!selectedProviderId || !model.trim()) return;
const res = await verify(selectedProviderId, model.trim());
if (res?.valid) await handleCreate();
if (res?.valid) await handleSubmitForm(form.getValues());
};
const handleCreate = async () => {
if (!agentKey.trim()) return;
const handleSubmitForm = async (data: AgentCreateFormData) => {
setLoading(true);
setError("");
setSubmitError("");
try {
const otherConfig: Record<string, unknown> = {};
if (emoji.trim()) otherConfig.emoji = emoji.trim();
if (description.trim()) otherConfig.description = description.trim();
if (selfEvolve) otherConfig.self_evolve = true;
if (data.emoji?.trim()) otherConfig.emoji = data.emoji.trim();
if (data.description?.trim()) otherConfig.description = data.description.trim();
if (data.selfEvolve) otherConfig.self_evolve = true;
await onCreate({
agent_key: agentKey.trim(),
display_name: displayName.trim() || undefined,
provider: provider.trim(),
model: model.trim(),
agent_type: agentType,
agent_key: data.agentKey,
display_name: data.displayName || undefined,
provider: data.provider,
model: data.model,
agent_type: data.agentType,
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
});
onOpenChange(false);
setEmoji("");
setAgentKey("");
setKeyTouched(false);
setDisplayName("");
setProvider("");
setModel("");
setAgentType("predefined");
setDescription("");
setSelfEvolve(false);
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : t("create.failedToCreate"));
setSubmitError(err instanceof Error ? err.message : t("create.failedToCreate"));
} finally {
setLoading(false);
}
};
const handleProviderChange = (value: string) => {
setProvider(value);
setModel("");
setValue("provider", value, { shouldValidate: true });
setValue("model", "", { shouldValidate: false });
};
const displayName = watch("displayName");
const agentKey = watch("agentKey");
// Derived submit button state
const canCreate = !!agentKey && !!displayName && !!provider && !!model &&
!errors.agentKey && !errors.displayName &&
(agentType !== "predefined" || !!watch("description")?.trim());
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col">
@@ -130,8 +151,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
<div className="flex gap-2">
<Input
id="emoji"
value={emoji}
onChange={(e) => setEmoji(e.target.value)}
{...register("emoji")}
placeholder="🤖"
className="w-14 shrink-0 text-center text-lg"
maxLength={2}
@@ -139,11 +159,12 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
/>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
onBlur={() => {
if (!keyTouched && displayName.trim()) {
setAgentKey(slugify(displayName.trim()));
{...register("displayName")}
onBlur={(e) => {
register("displayName").onBlur(e);
const name = e.target.value.trim();
if (name && !form.getFieldState("agentKey").isDirty) {
setValue("agentKey", slugify(name), { shouldValidate: true });
}
}}
placeholder={t("create.displayNamePlaceholder")}
@@ -154,37 +175,44 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
<Label htmlFor="agentKey">{t("create.agentKey")}</Label>
<Input
id="agentKey"
value={agentKey}
onChange={(e) => {
setKeyTouched(true);
setAgentKey(e.target.value);
{...register("agentKey")}
onBlur={(e) => {
setValue("agentKey", slugify(e.target.value), { shouldValidate: true });
}}
onBlur={() => setAgentKey(slugify(agentKey))}
placeholder={t("create.agentKeyPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("create.agentKeyHint")}</p>
{errors.agentKey ? (
<p className="text-xs text-destructive">{errors.agentKey.message}</p>
) : (
<p className="text-xs text-muted-foreground">{t("create.agentKeyHint")}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("create.provider")}</Label>
{enabledProviders.length > 0 ? (
<Select value={provider} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder={t("create.selectProvider")} />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Controller
control={control}
name="provider"
render={({ field }) => (
<Select value={field.value} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder={t("create.selectProvider")} />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
) : (
<Input
value={provider}
onChange={(e) => setProvider(e.target.value)}
{...register("provider")}
placeholder="openrouter"
/>
)}
@@ -193,11 +221,17 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
<Label>{t("create.model")}</Label>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={setModel}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? t("create.loadingModels") : t("create.enterOrSelectModel")}
<Controller
control={control}
name="model"
render={({ field }) => (
<Combobox
value={field.value}
onChange={(v) => setValue("model", v, { shouldValidate: true })}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? t("create.loadingModels") : t("create.enterOrSelectModel")}
/>
)}
/>
</div>
<Button
@@ -229,7 +263,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
<button
key={preset.label}
type="button"
onClick={() => setDescription(preset.prompt)}
onClick={() => setValue("description", preset.prompt, { shouldValidate: true })}
className="rounded-full border px-2.5 py-0.5 text-xs transition-colors hover:bg-accent"
>
{preset.label}
@@ -237,8 +271,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
))}
</div>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
{...register("description")}
placeholder={t("create.descriptionPlaceholder")}
className="min-h-[120px]"
/>
@@ -250,7 +283,13 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
<Label htmlFor="create-self-evolve" className="text-sm font-normal">{t("create.selfEvolution")}</Label>
<p className="text-xs text-muted-foreground">{t("create.selfEvolutionHint")}</p>
</div>
<Switch id="create-self-evolve" checked={selfEvolve} onCheckedChange={setSelfEvolve} />
<Controller
control={control}
name="selfEvolve"
render={({ field }) => (
<Switch id="create-self-evolve" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
</div>
</div>
) : (
@@ -263,7 +302,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setAgentType("predefined")}
onClick={() => setValue("agentType", "predefined")}
>
{t("create.switchToPredefined")}
</Button>
@@ -273,15 +312,15 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
{/* Collapsible toggle for Open agent type */}
<button
type="button"
onClick={() => setAgentType(agentType === "open" ? "predefined" : "open")}
onClick={() => setValue("agentType", agentType === "open" ? "predefined" : "open")}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={`h-3 w-3 transition-transform ${agentType === "open" ? "rotate-90" : ""}`} />
{t("create.useOpenAgent")}
</button>
{error && (
<p className="text-sm text-destructive">{error}</p>
{submitError && (
<p className="text-sm text-destructive">{submitError}</p>
)}
</div>
<DialogFooter>
@@ -291,11 +330,17 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
{loading ? (
<Button disabled>{t("create.creating")}</Button>
) : !verifyResult?.valid && selectedProviderId && model.trim() ? (
<Button onClick={handleVerifyAndCreate} disabled={verifying || !displayName.trim() || !agentKey.trim() || !isValidSlug(agentKey) || (agentType === "predefined" && !description.trim())}>
<Button
onClick={handleVerifyAndCreate}
disabled={verifying || !canCreate}
>
{verifying ? t("create.checking") : t("create.checkAndCreate")}
</Button>
) : (
<Button onClick={handleCreate} disabled={!displayName.trim() || !agentKey.trim() || !isValidSlug(agentKey) || !provider.trim() || !model.trim() || !verifyResult?.valid || (agentType === "predefined" && !description.trim())}>
<Button
onClick={handleSubmit(handleSubmitForm)}
disabled={!canCreate || !verifyResult?.valid || loading}
>
{t("create.create")}
</Button>
)}
@@ -1,4 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Play, Loader2, Heart, Clock, FileText, Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
@@ -20,6 +22,7 @@ import type { HeartbeatConfig, DeliveryTarget } from "@/pages/agents/hooks/use-a
import { HeartbeatScheduleSection } from "./heartbeat-schedule-section";
import { HeartbeatAdvancedPanel } from "./heartbeat-advanced-panel";
import { HeartbeatDeliverySection } from "./heartbeat-delivery-section";
import { heartbeatConfigSchema, type HeartbeatConfigFormData } from "@/schemas/heartbeat.schema";
interface HeartbeatConfigDialogProps {
open: boolean;
@@ -48,20 +51,7 @@ export function HeartbeatConfigDialog({
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const defaultTz = userTz && userTz !== "auto" ? userTz : browserTz;
const [enabled, setEnabled] = useState(false);
const [intervalMin, setIntervalMin] = useState(30);
const [ackMaxChars, setAckMaxChars] = useState(300);
const [maxRetries, setMaxRetries] = useState(2);
const [isolatedSession, setIsolatedSession] = useState(false);
const [lightContext, setLightContext] = useState(false);
const [activeHoursStart, setActiveHoursStart] = useState("");
const [activeHoursEnd, setActiveHoursEnd] = useState("");
const [timezone, setTimezone] = useState("");
const [channel, setChannel] = useState("");
const [chatId, setChatId] = useState("");
const [hbProvider, setHbProvider] = useState("");
const [hbModel, setHbModel] = useState("");
const [checklist, setChecklistState] = useState("");
// Non-form state: checklist tracking, targets, test spinner
const [originalChecklist, setOriginalChecklist] = useState("");
const [checklistLoading, setChecklistLoading] = useState(false);
const [targets, setTargets] = useState<DeliveryTarget[]>([]);
@@ -74,37 +64,88 @@ export function HeartbeatConfigDialog({
return map;
}, [providers]);
const form = useForm<HeartbeatConfigFormData>({
resolver: zodResolver(heartbeatConfigSchema),
mode: "onChange",
defaultValues: {
enabled: false,
intervalMin: 30,
ackMaxChars: 300,
maxRetries: 2,
isolatedSession: false,
lightContext: false,
activeHoursStart: "",
activeHoursEnd: "",
timezone: "",
channel: "",
chatId: "",
hbProvider: "",
hbModel: "",
checklist: "",
},
});
const { control, register, watch, setValue } = form;
const enabled = watch("enabled");
const hbProvider = watch("hbProvider") ?? "";
const hbModel = watch("hbModel") ?? "";
const channel = watch("channel") ?? "";
const chatId = watch("chatId") ?? "";
const activeHoursStart = watch("activeHoursStart") ?? "";
const activeHoursEnd = watch("activeHoursEnd") ?? "";
const timezone = watch("timezone") ?? "";
const ackMaxChars = watch("ackMaxChars");
const maxRetries = watch("maxRetries");
const isolatedSession = watch("isolatedSession");
const lightContext = watch("lightContext");
const loadChecklist = useCallback(async () => {
setChecklistLoading(true);
try {
const content = await getChecklist();
setChecklistState(content);
setValue("checklist", content);
setOriginalChecklist(content);
} catch { /* ignore */ } finally {
setChecklistLoading(false);
}
}, [getChecklist]);
}, [getChecklist, setValue]);
useEffect(() => {
if (!open) return;
if (config) {
setEnabled(config.enabled);
setIntervalMin(Math.round(config.intervalSec / 60));
setAckMaxChars(config.ackMaxChars);
setMaxRetries(config.maxRetries);
setIsolatedSession(config.isolatedSession);
setLightContext(config.lightContext);
setActiveHoursStart(config.activeHoursStart ?? "");
setActiveHoursEnd(config.activeHoursEnd ?? "");
setTimezone(config.timezone || defaultTz);
setChannel(config.channel ?? "");
setChatId(config.chatId ?? "");
setHbProvider(config.providerId ? (providerNameById[config.providerId] ?? "") : "");
setHbModel(config.model ?? "");
form.reset({
enabled: config.enabled,
intervalMin: Math.round(config.intervalSec / 60),
ackMaxChars: config.ackMaxChars,
maxRetries: config.maxRetries,
isolatedSession: config.isolatedSession,
lightContext: config.lightContext,
activeHoursStart: config.activeHoursStart ?? "",
activeHoursEnd: config.activeHoursEnd ?? "",
timezone: config.timezone || defaultTz,
channel: config.channel ?? "",
chatId: config.chatId ?? "",
hbProvider: config.providerId ? (providerNameById[config.providerId] ?? "") : "",
hbModel: config.model ?? "",
checklist: "",
});
} else {
setTimezone(defaultTz);
setHbProvider("");
setHbModel("");
form.reset({
enabled: false,
intervalMin: 30,
ackMaxChars: 300,
maxRetries: 2,
isolatedSession: false,
lightContext: false,
activeHoursStart: "",
activeHoursEnd: "",
timezone: defaultTz,
channel: "",
chatId: "",
hbProvider: "",
hbModel: "",
checklist: "",
});
}
loadChecklist();
fetchTargets().then(setTargets).catch(() => {});
@@ -116,38 +157,38 @@ export function HeartbeatConfigDialog({
try { await test(); } finally { setTestRunning(false); }
};
const handleSave = async () => {
if (timezone && !isValidIanaTimezone(timezone)) {
const handleSave = form.handleSubmit(async (values) => {
if (values.timezone && !isValidIanaTimezone(values.timezone)) {
toast.error(t("heartbeat.invalidTimezone", "Invalid timezone"));
return;
}
try {
const clampedMin = Math.max(5, intervalMin);
const clampedMin = Math.max(5, values.intervalMin);
await update({
enabled,
enabled: values.enabled,
intervalSec: clampedMin * 60,
ackMaxChars,
maxRetries,
isolatedSession,
lightContext,
activeHoursStart: activeHoursStart || undefined,
activeHoursEnd: activeHoursEnd || undefined,
timezone: timezone || undefined,
channel: channel || undefined,
chatId: chatId || undefined,
model: hbModel || undefined,
providerName: hbProvider || undefined,
ackMaxChars: values.ackMaxChars,
maxRetries: values.maxRetries,
isolatedSession: values.isolatedSession,
lightContext: values.lightContext,
activeHoursStart: values.activeHoursStart || undefined,
activeHoursEnd: values.activeHoursEnd || undefined,
timezone: values.timezone || undefined,
channel: values.channel || undefined,
chatId: values.chatId || undefined,
model: values.hbModel || undefined,
providerName: values.hbProvider || undefined,
});
if (checklist !== originalChecklist) {
await setChecklist(checklist);
setOriginalChecklist(checklist);
if (values.checklist !== originalChecklist) {
await setChecklist(values.checklist ?? "");
setOriginalChecklist(values.checklist ?? "");
}
await refresh();
onOpenChange(false);
} catch {
// toast shown by hook — keep dialog open
}
};
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -167,7 +208,13 @@ export function HeartbeatConfigDialog({
{/* Enable + Interval */}
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="flex items-center gap-3 min-w-0">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<div className="min-w-0">
<span className="text-sm font-medium">{t("heartbeat.enabled")}</span>
<p className="text-xs text-muted-foreground">{t("heartbeat.enabledHint")}</p>
@@ -178,8 +225,10 @@ export function HeartbeatConfigDialog({
<Input
type="number"
min={5}
value={intervalMin}
onChange={(e) => setIntervalMin(Math.max(5, Number(e.target.value) || 5))}
{...register("intervalMin", {
valueAsNumber: true,
onChange: (e) => setValue("intervalMin", Math.max(5, Number(e.target.value) || 5)),
})}
className="w-[4.5rem] text-center text-base md:text-sm"
/>
<span className="text-xs text-muted-foreground">min</span>
@@ -197,9 +246,9 @@ export function HeartbeatConfigDialog({
<p className="text-xs text-muted-foreground">{t("heartbeat.modelHint")}</p>
<ProviderModelSelect
provider={hbProvider}
onProviderChange={setHbProvider}
onProviderChange={(v) => setValue("hbProvider", v)}
model={hbModel}
onModelChange={setHbModel}
onModelChange={(v) => setValue("hbModel", v)}
allowEmpty
showVerify={!!(hbProvider && hbModel)}
providerPlaceholder={agentProvider ? `(${agentProvider})` : "(agent default)"}
@@ -209,23 +258,23 @@ export function HeartbeatConfigDialog({
<HeartbeatDeliverySection
channelNames={channelNames}
channel={channel} setChannel={setChannel}
chatId={chatId} setChatId={setChatId}
channel={channel} setChannel={(v) => setValue("channel", v)}
chatId={chatId} setChatId={(v) => setValue("chatId", v)}
targets={targets}
/>
<HeartbeatScheduleSection
activeHoursStart={activeHoursStart} setActiveHoursStart={setActiveHoursStart}
activeHoursEnd={activeHoursEnd} setActiveHoursEnd={setActiveHoursEnd}
timezone={timezone} setTimezone={setTimezone}
activeHoursStart={activeHoursStart} setActiveHoursStart={(v) => setValue("activeHoursStart", v)}
activeHoursEnd={activeHoursEnd} setActiveHoursEnd={(v) => setValue("activeHoursEnd", v)}
timezone={timezone} setTimezone={(v) => setValue("timezone", v)}
defaultTz={defaultTz}
/>
<HeartbeatAdvancedPanel
ackMaxChars={ackMaxChars} setAckMaxChars={setAckMaxChars}
maxRetries={maxRetries} setMaxRetries={setMaxRetries}
isolatedSession={isolatedSession} setIsolatedSession={setIsolatedSession}
lightContext={lightContext} setLightContext={setLightContext}
ackMaxChars={ackMaxChars} setAckMaxChars={(v) => setValue("ackMaxChars", v)}
maxRetries={maxRetries} setMaxRetries={(v) => setValue("maxRetries", v)}
isolatedSession={isolatedSession} setIsolatedSession={(v) => setValue("isolatedSession", v)}
lightContext={lightContext} setLightContext={(v) => setValue("lightContext", v)}
/>
{/* Checklist */}
@@ -244,8 +293,7 @@ export function HeartbeatConfigDialog({
</div>
) : (
<Textarea
value={checklist}
onChange={(e) => setChecklistState(e.target.value)}
{...register("checklist")}
placeholder={t("heartbeat.checklistPlaceholder")}
rows={8}
className="text-base md:text-sm font-mono resize-y min-h-[120px] sm:min-h-[200px]"
@@ -1,4 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import {
Dialog,
@@ -20,13 +22,14 @@ import {
} from "@/components/ui/select";
import type { ChannelInstanceData, ChannelInstanceInput } from "./hooks/use-channel-instances";
import type { AgentData } from "@/types/agent";
import { slugify, isValidSlug } from "@/lib/slug";
import { slugify } from "@/lib/slug";
import { credentialsSchema, configSchema, wizardConfig, type FieldDef } from "./channel-schemas";
import { ChannelFields } from "./channel-fields";
import { ChannelScopesInfo } from "./channel-scopes-info";
import { wizardAuthSteps, wizardConfigSteps, wizardEditConfigs } from "./channel-wizard-registry";
import { TelegramGroupOverrides } from "./telegram-group-overrides";
import { CHANNEL_TYPES } from "@/constants/channels";
import { channelInstanceSchema, type ChannelInstanceFormData } from "@/schemas/channel.schema";
type WizardStep = "form" | "auth" | "config";
@@ -49,21 +52,28 @@ export function ChannelInstanceFormDialog({
}: ChannelInstanceFormDialogProps) {
const { t } = useTranslation("channels");
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
const [channelType, setChannelType] = useState("telegram");
const [agentId, setAgentId] = useState("");
// Non-form state (dynamic maps + wizard flow)
const [credsValues, setCredsValues] = useState<Record<string, unknown>>({});
const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
const [enabled, setEnabled] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Wizard state (activated for channels with wizardConfig on create only)
const [step, setStep] = useState<WizardStep>("form");
const [createdInstanceId, setCreatedInstanceId] = useState<string | null>(null);
const [authCompleted, setAuthCompleted] = useState(false);
const form = useForm<ChannelInstanceFormData>({
resolver: zodResolver(channelInstanceSchema),
mode: "onChange",
defaultValues: {
name: "",
displayName: "",
channelType: "telegram",
agentId: "",
enabled: true,
},
});
const channelType = form.watch("channelType");
const wizard = wizardConfig[channelType];
const hasWizard = !instance && !!wizard;
const channelLabel = CHANNEL_TYPES.find((ct) => ct.value === channelType)?.label ?? channelType;
@@ -81,11 +91,15 @@ export function ChannelInstanceFormDialog({
useEffect(() => {
if (open) {
setName(instance?.name ?? "");
setDisplayName(instance?.display_name ?? "");
setChannelType(instance?.channel_type ?? "telegram");
setAgentId(instance?.agent_id ?? (agents[0]?.id ?? ""));
form.reset({
name: instance?.name ?? "",
displayName: instance?.display_name ?? "",
channelType: instance?.channel_type ?? "telegram",
agentId: instance?.agent_id ?? (agents[0]?.id ?? ""),
enabled: instance?.enabled ?? true,
});
setCredsValues({});
// Merge schema defaults into config so select fields persist their defaults.
const ct = instance?.channel_type ?? "telegram";
const schema = configSchema[ct] ?? [];
@@ -103,13 +117,12 @@ export function ChannelInstanceFormDialog({
else if (merged[key] === undefined || merged[key] === null) merged[key] = "inherit";
}
setConfigValues(merged);
setEnabled(instance?.enabled ?? true);
setError("");
setStep("form");
setCreatedInstanceId(null);
setAuthCompleted(false);
}
}, [open, instance, agents]);
}, [open, instance, agents, form]);
// Auto-advance from auth to next step on completion
useEffect(() => {
@@ -144,16 +157,9 @@ export function ChannelInstanceFormDialog({
}
};
const handleSubmit = async () => {
if (!name.trim()) { setError(t("form.errors.keyRequired")); return; }
if (!isValidSlug(name.trim())) {
setError(t("form.errors.keySlug"));
return;
}
if (!agentId) { setError(t("form.errors.agentRequired")); return; }
const handleSubmit = form.handleSubmit(async (values) => {
if (!instance) {
const schema = credentialsSchema[channelType] ?? [];
const schema = credentialsSchema[values.channelType] ?? [];
const missing = schema.filter((f) => f.required && !credsValues[f.key]);
if (missing.length > 0) {
setError(t("form.errors.requiredFields", { fields: missing.map((f) => f.label).join(", ") }));
@@ -164,7 +170,7 @@ export function ChannelInstanceFormDialog({
const cleanConfig = Object.fromEntries(
Object.entries(configValues).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
coerceBoolSelects(cleanConfig, configSchema[channelType] ?? []);
coerceBoolSelects(cleanConfig, configSchema[values.channelType] ?? []);
const cleanCreds = Object.fromEntries(
Object.entries(credsValues).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
@@ -173,12 +179,12 @@ export function ChannelInstanceFormDialog({
setError("");
try {
const data: ChannelInstanceInput = {
name: name.trim(),
display_name: displayName.trim() || undefined,
channel_type: channelType,
agent_id: agentId,
name: values.name,
display_name: values.displayName?.trim() || undefined,
channel_type: values.channelType,
agent_id: values.agentId,
config: Object.keys(cleanConfig).length > 0 ? cleanConfig : undefined,
enabled,
enabled: values.enabled,
};
if (Object.keys(cleanCreds).length > 0) data.credentials = cleanCreds;
@@ -201,7 +207,7 @@ export function ChannelInstanceFormDialog({
} finally {
setLoading(false);
}
};
});
const handleConfigDone = async () => {
if (!createdInstanceId || !onUpdate) { onOpenChange(false); return; }
@@ -246,6 +252,8 @@ export function ChannelInstanceFormDialog({
? t("form.authenticate", { label: channelLabel })
: t("form.configure", { label: channelLabel });
const { register, control, formState: { errors } } = form;
return (
<Dialog open={open} onOpenChange={(v) => { if (!loading && canClose) onOpenChange(v); }}>
<DialogContent className="max-h-[85vh] flex flex-col">
@@ -264,37 +272,64 @@ export function ChannelInstanceFormDialog({
<div className="grid gap-4 py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="grid gap-1.5">
<Label htmlFor="ci-name">{t("form.key")}</Label>
<Input id="ci-name" value={name} onChange={(e) => setName(slugify(e.target.value))} placeholder={t("form.keyPlaceholder")} disabled={!!instance} />
<Input
id="ci-name"
{...register("name", {
setValueAs: (v: string) => slugify(v),
})}
onChange={(e) => form.setValue("name", slugify(e.target.value), { shouldValidate: true })}
value={form.watch("name")}
placeholder={t("form.keyPlaceholder")}
disabled={!!instance}
/>
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
<p className="text-xs text-muted-foreground">{t("form.keyHint")}</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ci-display">{t("form.displayName")}</Label>
<Input id="ci-display" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder={t("form.displayNamePlaceholder")} />
<Input
id="ci-display"
{...register("displayName")}
placeholder={t("form.displayNamePlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<Label>{t("form.channelType")}</Label>
<Select value={channelType} onValueChange={setChannelType} disabled={!!instance}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CHANNEL_TYPES.map((ct) => (
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
))}
</SelectContent>
</Select>
<Controller
control={control}
name="channelType"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} disabled={!!instance}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CHANNEL_TYPES.map((ct) => (
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="grid gap-1.5">
<Label>{t("form.agent")}</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger><SelectValue placeholder={t("form.selectAgent")} /></SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.display_name || a.agent_key}</SelectItem>
))}
</SelectContent>
</Select>
<Controller
control={control}
name="agentId"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue placeholder={t("form.selectAgent")} /></SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.display_name || a.agent_key}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.agentId && <p className="text-xs text-destructive">{errors.agentId.message}</p>}
</div>
{credsFields.length > 0 && (
@@ -356,7 +391,13 @@ export function ChannelInstanceFormDialog({
)}
<div className="flex items-center gap-2">
<Switch id="ci-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Switch id="ci-enabled" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="ci-enabled">{t("form.enabled")}</Label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
@@ -1,5 +1,7 @@
import type { ManualEnvEntry } from "./cli-credential-env-vars-section";
import { useState, useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
@@ -17,6 +19,7 @@ import { useHttp } from "@/hooks/use-ws";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import type { SecureCLIBinary, CLICredentialInput, CLIPreset } from "./hooks/use-cli-credentials";
import { CliCredentialEnvVarsSection } from "./cli-credential-env-vars-section";
import { cliCredentialSchema, type CliCredentialFormData } from "@/schemas/credential.schema";
interface Props {
@@ -37,16 +40,8 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
const http = useHttp();
const { agents } = useAgents();
// Non-form state: preset selection, env vars, binary check, loading
const [selectedPreset, setSelectedPreset] = useState(NONE_PRESET);
const [binaryName, setBinaryName] = useState("");
const [binaryPath, setBinaryPath] = useState("");
const [description, setDescription] = useState("");
const [denyArgs, setDenyArgs] = useState("");
const [denyVerbose, setDenyVerbose] = useState("");
const [timeout, setTimeout] = useState(30);
const [tips, setTips] = useState("");
const [agentId, setAgentId] = useState("");
const [enabled, setEnabled] = useState(true);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [manualEnvEntries, setManualEnvEntries] = useState<ManualEnvEntry[]>([]);
const [initialEnvKeys, setInitialEnvKeys] = useState<string[]>([]);
@@ -62,18 +57,39 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
const activePreset: CLIPreset | null = selectedPreset !== NONE_PRESET ? (presets[selectedPreset] ?? null) : null;
const isManualMode = selectedPreset === NONE_PRESET;
const form = useForm<CliCredentialFormData>({
resolver: zodResolver(cliCredentialSchema),
mode: "onChange",
defaultValues: {
binaryName: "",
binaryPath: "",
description: "",
denyArgs: "",
denyVerbose: "",
timeout: 30,
tips: "",
agentId: "",
enabled: true,
},
});
const { register, control, formState: { errors }, setValue, watch } = form;
const binaryName = watch("binaryName");
useEffect(() => {
if (!open) return;
setSelectedPreset(NONE_PRESET);
setBinaryName(credential?.binary_name ?? "");
setBinaryPath(credential?.binary_path ?? "");
setDescription(credential?.description ?? "");
setDenyArgs((credential?.deny_args ?? []).join(", "));
setDenyVerbose((credential?.deny_verbose ?? []).join(", "));
setTimeout(credential?.timeout_seconds ?? 30);
setTips(credential?.tips ?? "");
setAgentId(credential?.agent_id ?? "");
setEnabled(credential?.enabled ?? true);
form.reset({
binaryName: credential?.binary_name ?? "",
binaryPath: credential?.binary_path ?? "",
description: credential?.description ?? "",
denyArgs: (credential?.deny_args ?? []).join(", "),
denyVerbose: (credential?.deny_verbose ?? []).join(", "),
timeout: credential?.timeout_seconds ?? 30,
tips: credential?.tips ?? "",
agentId: credential?.agent_id ?? "",
enabled: credential?.enabled ?? true,
});
setEnvValues({});
setError("");
setCheckResult(null);
@@ -105,19 +121,24 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
}
})();
return () => { cancelled = true; };
}, [open, credential, http]);
}, [open, credential, http, form]);
const applyPreset = (key: string) => {
setSelectedPreset(key);
if (key === NONE_PRESET) return;
const p = presets[key];
if (!p) return;
setBinaryName(p.binary_name);
setDescription(p.description);
setDenyArgs(p.deny_args.join(", "));
setDenyVerbose(p.deny_verbose.join(", "));
setTimeout(p.timeout);
setTips(p.tips);
form.reset({
binaryName: p.binary_name,
binaryPath: "",
description: p.description,
denyArgs: p.deny_args.join(", "),
denyVerbose: p.deny_verbose.join(", "),
timeout: p.timeout,
tips: p.tips,
agentId: "",
enabled: true,
});
setEnvValues({});
setManualEnvEntries([]);
};
@@ -133,7 +154,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
{ binary_name: name },
);
setCheckResult(res);
if (res.found && res.path) setBinaryPath(res.path);
if (res.found && res.path) setValue("binaryPath", res.path);
} catch {
setCheckResult({ found: false, error: t("form.binaryNotFound") });
} finally {
@@ -158,21 +179,20 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
return env;
};
const handleSubmit = async () => {
if (!binaryName.trim()) { setError(t("form.binaryNameRequired")); return; }
const handleSubmit = form.handleSubmit(async (values) => {
setLoading(true);
setError("");
try {
const payload: CLICredentialInput = {
binary_name: binaryName.trim(),
binary_path: binaryPath.trim() || undefined,
description: description.trim(),
deny_args: splitCommaList(denyArgs),
deny_verbose: splitCommaList(denyVerbose),
timeout_seconds: timeout,
tips: tips.trim(),
agent_id: agentId.trim() || undefined,
enabled,
binary_name: values.binaryName.trim(),
binary_path: values.binaryPath?.trim() || undefined,
description: values.description?.trim() ?? "",
deny_args: splitCommaList(values.denyArgs ?? ""),
deny_verbose: splitCommaList(values.denyVerbose ?? ""),
timeout_seconds: values.timeout,
tips: values.tips?.trim() ?? "",
agent_id: values.agentId?.trim() || undefined,
enabled: values.enabled,
};
if (selectedPreset !== NONE_PRESET) payload.preset = selectedPreset;
const env = buildEnvPayload();
@@ -189,7 +209,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
} finally {
setLoading(false);
}
};
});
return (
<Dialog open={open} onOpenChange={(v) => !loading && onOpenChange(v)}>
@@ -242,8 +262,9 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
<div className="flex gap-1.5">
<Input
id="cc-name"
value={binaryName}
onChange={(e) => { setBinaryName(e.target.value); setCheckResult(null); }}
{...register("binaryName", {
onChange: () => setCheckResult(null),
})}
placeholder={t("placeholders.binaryName")}
className="text-base md:text-sm"
/>
@@ -259,6 +280,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
<Search className="h-4 w-4" />
</Button>
</div>
{errors.binaryName && <p className="text-xs text-destructive">{errors.binaryName.message}</p>}
{checkResult && (
<p className={`text-xs flex items-center gap-1 ${checkResult.found ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
{checkResult.found ? <Check className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
@@ -275,8 +297,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
</Label>
<Input
id="cc-path"
value={binaryPath}
onChange={(e) => setBinaryPath(e.target.value)}
{...register("binaryPath")}
placeholder={t("placeholders.binaryPath")}
className="text-base md:text-sm"
/>
@@ -288,8 +309,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
<Label htmlFor="cc-desc">{tc("description")}</Label>
<Textarea
id="cc-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
{...register("description")}
placeholder={t("placeholders.description")}
rows={2}
className="text-base md:text-sm"
@@ -303,8 +323,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
</Label>
<Input
id="cc-deny-args"
value={denyArgs}
onChange={(e) => setDenyArgs(e.target.value)}
{...register("denyArgs")}
placeholder={t("placeholders.denyArgs")}
className="text-base md:text-sm"
/>
@@ -315,8 +334,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
id="cc-timeout"
type="number"
min={1}
value={timeout}
onChange={(e) => setTimeout(Number(e.target.value))}
{...register("timeout", { valueAsNumber: true })}
className="text-base md:text-sm"
/>
</div>
@@ -328,8 +346,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
</Label>
<Input
id="cc-deny-verbose"
value={denyVerbose}
onChange={(e) => setDenyVerbose(e.target.value)}
{...register("denyVerbose")}
placeholder={t("placeholders.denyVerbose")}
className="text-base md:text-sm"
/>
@@ -339,8 +356,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
<Label htmlFor="cc-tips">{t("form.tips")}</Label>
<Textarea
id="cc-tips"
value={tips}
onChange={(e) => setTips(e.target.value)}
{...register("tips")}
placeholder={t("placeholders.tips")}
rows={2}
className="text-base md:text-sm"
@@ -351,23 +367,38 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
<Label>
{t("form.agentId")} <span className="text-xs text-muted-foreground">({t("form.agentIdHint")})</span>
</Label>
<Select value={agentId || GLOBAL_AGENT} onValueChange={(v) => setAgentId(v === GLOBAL_AGENT ? "" : v)}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("placeholders.agentId")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={GLOBAL_AGENT}>{t("placeholders.agentId")}</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key || a.id}
</SelectItem>
))}
</SelectContent>
</Select>
<Controller
control={control}
name="agentId"
render={({ field }) => (
<Select
value={field.value || GLOBAL_AGENT}
onValueChange={(v) => field.onChange(v === GLOBAL_AGENT ? "" : v)}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("placeholders.agentId")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={GLOBAL_AGENT}>{t("placeholders.agentId")}</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key || a.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="flex items-center gap-2">
<Switch id="cc-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Switch id="cc-enabled" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="cc-enabled">{tc("enabled")}</Label>
</div>
+76 -57
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import {
Dialog,
@@ -13,8 +14,9 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { CronSchedule } from "./hooks/use-cron";
import { slugify, isValidSlug } from "@/lib/slug";
import { slugify } from "@/lib/slug";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { cronCreateSchema, type CronCreateFormData } from "@/schemas/cron.schema";
interface CronFormDialogProps {
open: boolean;
@@ -27,46 +29,43 @@ interface CronFormDialogProps {
}) => Promise<void>;
}
type ScheduleKind = "every" | "cron" | "at";
export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogProps) {
const { t } = useTranslation("cron");
const { agents } = useAgents();
const [name, setName] = useState("");
const [message, setMessage] = useState("");
const [agentId, setAgentId] = useState("");
const [scheduleKind, setScheduleKind] = useState<ScheduleKind>("every");
const [everyValue, setEveryValue] = useState("60");
const [cronExpr, setCronExpr] = useState("0 * * * *");
const [saving, setSaving] = useState(false);
const handleSubmit = async () => {
if (!name.trim() || !message.trim()) return;
const { register, control, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<CronCreateFormData>({
resolver: zodResolver(cronCreateSchema),
mode: "onChange",
defaultValues: {
name: "",
message: "",
agentId: "",
scheduleKind: "every",
everyValue: "60",
cronExpr: "0 * * * *",
},
});
const scheduleKind = watch("scheduleKind");
const onFormSubmit = async (data: CronCreateFormData) => {
let schedule: CronSchedule;
if (scheduleKind === "every") {
schedule = { kind: "every", everyMs: Number(everyValue) * 1000 };
} else if (scheduleKind === "cron") {
schedule = { kind: "cron", expr: cronExpr };
if (data.scheduleKind === "every") {
schedule = { kind: "every", everyMs: Number(data.everyValue) * 1000 };
} else if (data.scheduleKind === "cron") {
schedule = { kind: "cron", expr: data.cronExpr };
} else {
schedule = { kind: "at", atMs: Date.now() + 60000 };
}
setSaving(true);
try {
await onSubmit({
name: name.trim(),
schedule,
message: message.trim(),
agentId: agentId.trim() || undefined,
});
onOpenChange(false);
setName("");
setMessage("");
setAgentId("");
} finally {
setSaving(false);
}
await onSubmit({
name: data.name,
schedule,
message: data.message,
agentId: data.agentId || undefined,
});
onOpenChange(false);
reset();
};
return (
@@ -78,25 +77,42 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
<div className="space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="space-y-2">
<Label>{t("create.name")}</Label>
<Input value={name} onChange={(e) => setName(slugify(e.target.value))} placeholder={t("create.namePlaceholder")} />
<p className="text-xs text-muted-foreground">{t("create.nameHint")}</p>
<Input
{...register("name")}
onChange={(e) => setValue("name", slugify(e.target.value), { shouldValidate: true })}
placeholder={t("create.namePlaceholder")}
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">{t("create.nameHint")}</p>
)}
</div>
<div className="space-y-2">
<Label>{t("create.agentId")}</Label>
<Select name="agentId" value={agentId || "__default__"} onValueChange={(v) => setAgentId(v === "__default__" ? "" : v)}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("create.agentIdPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">{t("create.agentIdPlaceholder")}</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key || a.id}
</SelectItem>
))}
</SelectContent>
</Select>
<Controller
control={control}
name="agentId"
render={({ field }) => (
<Select
value={field.value || "__default__"}
onValueChange={(v) => field.onChange(v === "__default__" ? "" : v)}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("create.agentIdPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">{t("create.agentIdPlaceholder")}</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key || a.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
@@ -107,7 +123,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
key={kind}
variant={scheduleKind === kind ? "default" : "outline"}
size="sm"
onClick={() => setScheduleKind(kind)}
onClick={() => setValue("scheduleKind", kind)}
>
{kind === "every" ? t("create.every") : kind === "cron" ? t("create.cron") : t("create.once")}
</Button>
@@ -121,8 +137,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
<Input
type="number"
min={1}
value={everyValue}
onChange={(e) => setEveryValue(e.target.value)}
{...register("everyValue")}
placeholder="60"
/>
</div>
@@ -132,8 +147,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
<div className="space-y-2">
<Label>{t("create.cronExpression")}</Label>
<Input
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
{...register("cronExpr")}
placeholder="0 * * * *"
/>
<p className="text-xs text-muted-foreground">{t("create.cronHint")}</p>
@@ -149,19 +163,24 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
<div className="space-y-2">
<Label>{t("create.message")}</Label>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
{...register("message")}
placeholder={t("create.messagePlaceholder")}
rows={3}
/>
{errors.message && (
<p className="text-xs text-destructive">{errors.message.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{t("create.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={saving || !name.trim() || !isValidSlug(name.trim()) || !message.trim()}>
{saving ? t("create.creating") : t("create.create")}
<Button
onClick={handleSubmit(onFormSubmit)}
disabled={isSubmitting || !!errors.name || !!errors.message}
>
{isSubmitting ? t("create.creating") : t("create.create")}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,4 +1,6 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
@@ -21,12 +23,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { ProviderData, ProviderInput } from "./hooks/use-providers";
import { slugify, isValidSlug } from "@/lib/slug";
import { slugify } from "@/lib/slug";
import { DEFAULT_CODEX_OAUTH_ALIAS, PROVIDER_TYPES, suggestUniqueProviderAlias } from "@/constants/providers";
import { OAuthSection } from "./provider-oauth-section";
import { CLISection } from "./provider-cli-section";
import { ACPSection } from "./provider-acp-section";
import { Loader2 } from "lucide-react";
import { providerCreateSchema, type ProviderCreateFormData } from "@/schemas/provider.schema";
interface ProviderFormDialogProps {
open: boolean;
@@ -38,81 +41,91 @@ interface ProviderFormDialogProps {
export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProviders = [] }: ProviderFormDialogProps) {
const { t } = useTranslation("providers");
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
const [providerType, setProviderType] = useState("openai_compat");
const [apiBase, setApiBase] = useState("");
const [apiKey, setApiKey] = useState("");
const [enabled, setEnabled] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// ACP fields
const [acpBinary, setAcpBinary] = useState("");
const [acpArgs, setAcpArgs] = useState("");
const [acpIdleTTL, setAcpIdleTTL] = useState("");
const [acpPermMode, setAcpPermMode] = useState("approve-all");
const [acpWorkDir, setAcpWorkDir] = useState("");
const form = useForm<ProviderCreateFormData>({
resolver: zodResolver(providerCreateSchema),
mode: "onChange",
defaultValues: {
name: "",
displayName: "",
providerType: "openai_compat",
apiBase: "",
apiKey: "",
enabled: true,
acpBinary: "",
acpArgs: "",
acpIdleTTL: "",
acpPermMode: "approve-all",
acpWorkDir: "",
},
});
const { register, control, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = form;
const providerType = watch("providerType");
const name = watch("name");
const hasClaudeCLI = existingProviders.some((p) => p.provider_type === "claude_cli");
const isOAuth = providerType === "chatgpt_oauth";
const isCLI = providerType === "claude_cli";
const isACP = providerType === "acp";
// Reset form when dialog opens
useEffect(() => {
if (open) {
setError("");
setName("");
setDisplayName("");
setProviderType("openai_compat");
setApiBase("");
setApiKey("");
setEnabled(true);
setAcpBinary("");
setAcpArgs("");
setAcpIdleTTL("");
setAcpPermMode("approve-all");
setAcpWorkDir("");
reset({
name: "",
displayName: "",
providerType: "openai_compat",
apiBase: "",
apiKey: "",
enabled: true,
acpBinary: "",
acpArgs: "",
acpIdleTTL: "",
acpPermMode: "approve-all",
acpWorkDir: "",
});
}
}, [open]);
}, [open, reset]);
const handleSubmit = async () => {
if (!name.trim() || !providerType) return;
setLoading(true);
try {
const data: ProviderInput = {
name: name.trim(),
display_name: displayName.trim() || undefined,
provider_type: providerType,
api_base: apiBase.trim() || undefined,
enabled,
};
const onFormSubmit = async (data: ProviderCreateFormData) => {
const payload: ProviderInput = {
name: data.name,
display_name: data.displayName || undefined,
provider_type: data.providerType,
api_base: data.apiBase || undefined,
enabled: data.enabled,
};
if (isACP) {
data.api_base = acpBinary.trim() || undefined;
const settings: Record<string, unknown> = {};
if (acpArgs.trim()) {
settings.args = acpArgs.trim().split(/\s+/);
}
if (acpIdleTTL.trim()) settings.idle_ttl = acpIdleTTL.trim();
if (acpPermMode) settings.perm_mode = acpPermMode;
if (acpWorkDir.trim()) settings.work_dir = acpWorkDir.trim();
if (Object.keys(settings).length > 0) {
data.settings = settings;
}
if (isACP) {
payload.api_base = data.acpBinary || undefined;
const settings: Record<string, unknown> = {};
if (data.acpArgs?.trim()) settings.args = data.acpArgs.trim().split(/\s+/);
if (data.acpIdleTTL?.trim()) settings.idle_ttl = data.acpIdleTTL.trim();
if (data.acpPermMode) settings.perm_mode = data.acpPermMode;
if (data.acpWorkDir?.trim()) settings.work_dir = data.acpWorkDir.trim();
if (Object.keys(settings).length > 0) payload.settings = settings;
}
if (data.apiKey && data.apiKey !== "***") {
payload.api_key = data.apiKey;
}
await onSubmit(payload);
onOpenChange(false);
};
const handleProviderTypeChange = (v: string) => {
setValue("providerType", v, { shouldValidate: true });
const preset = PROVIDER_TYPES.find((pt) => pt.value === v);
setValue("apiBase", preset?.apiBase || "");
if (v === "chatgpt_oauth") {
if (!name || providerType !== "chatgpt_oauth") {
setValue("name", suggestUniqueProviderAlias(existingProviders));
}
if (apiKey && apiKey !== "***") {
data.api_key = apiKey;
}
await onSubmit(data);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("form.saving"));
} finally {
setLoading(false);
} else {
if (name === DEFAULT_CODEX_OAUTH_ALIAS) setValue("name", "");
}
};
@@ -129,19 +142,7 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi
hasClaudeCLI={hasClaudeCLI}
alreadyAddedLabel={t("form.alreadyAdded")}
providerTypeLabel={t("form.providerType")}
onChange={(v) => {
setProviderType(v);
const preset = PROVIDER_TYPES.find((pt) => pt.value === v);
setApiBase(preset?.apiBase || "");
if (v === "chatgpt_oauth") {
if (!name || providerType !== "chatgpt_oauth") {
setName(suggestUniqueProviderAlias(existingProviders));
}
if (displayName === "ChatGPT (OAuth)") setDisplayName("");
} else {
if (name === DEFAULT_CODEX_OAUTH_ALIAS) setName("");
}
}}
onChange={handleProviderTypeChange}
/>
{isOAuth ? (
@@ -151,8 +152,8 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi
<Label htmlFor="oauth-name">{t("form.oauthAlias")}</Label>
<Input
id="oauth-name"
value={name}
onChange={(e) => setName(slugify(e.target.value))}
{...register("name")}
onChange={(e) => setValue("name", slugify(e.target.value), { shouldValidate: true })}
placeholder={t("form.oauthAliasPlaceholder")}
className="text-base md:text-sm"
/>
@@ -161,8 +162,7 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi
<Label htmlFor="oauth-display-name">{t("form.displayName")}</Label>
<Input
id="oauth-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
{...register("displayName")}
placeholder={t("form.oauthDisplayNamePlaceholder")}
className="text-base md:text-sm"
/>
@@ -170,8 +170,8 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi
</div>
<OAuthSection
providerName={name}
displayName={displayName}
apiBase={apiBase}
displayName={watch("displayName") || ""}
apiBase={watch("apiBase") || ""}
authenticatedActionLabel={t("form.close")}
onSuccess={() => { queryClient.invalidateQueries({ queryKey: ["providers"] }); onOpenChange(false); }}
/>
@@ -183,19 +183,22 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi
<Label htmlFor="name">{t("form.name")}</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(slugify(e.target.value))}
{...register("name")}
onChange={(e) => setValue("name", slugify(e.target.value), { shouldValidate: true })}
placeholder={t("form.namePlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("form.nameHint")}</p>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">{t("form.nameHint")}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="displayName">{t("form.displayName")}</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
{...register("displayName")}
placeholder={t("form.displayNamePlaceholder")}
className="text-base md:text-sm"
/>
@@ -206,16 +209,16 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi
{isACP && (
<ACPSection
binary={acpBinary}
onBinaryChange={setAcpBinary}
args={acpArgs}
onArgsChange={setAcpArgs}
idleTTL={acpIdleTTL}
onIdleTTLChange={setAcpIdleTTL}
permMode={acpPermMode}
onPermModeChange={setAcpPermMode}
workDir={acpWorkDir}
onWorkDirChange={setAcpWorkDir}
binary={watch("acpBinary") || ""}
onBinaryChange={(v) => setValue("acpBinary", v)}
args={watch("acpArgs") || ""}
onArgsChange={(v) => setValue("acpArgs", v)}
idleTTL={watch("acpIdleTTL") || ""}
onIdleTTLChange={(v) => setValue("acpIdleTTL", v)}
permMode={watch("acpPermMode") || "approve-all"}
onPermModeChange={(v) => setValue("acpPermMode", v)}
workDir={watch("acpWorkDir") || ""}
onWorkDirChange={(v) => setValue("acpWorkDir", v)}
/>
)}
@@ -225,8 +228,7 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi
<Label htmlFor="apiBase">{t("form.apiBase")}</Label>
<Input
id="apiBase"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
{...register("apiBase")}
placeholder={PROVIDER_TYPES.find((pt) => pt.value === providerType)?.placeholder || PROVIDER_TYPES.find((pt) => pt.value === providerType)?.apiBase || "https://api.example.com/v1"}
className="text-base md:text-sm"
/>
@@ -237,8 +239,7 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
{...register("apiKey")}
placeholder={t("form.apiKeyPlaceholder")}
className="text-base md:text-sm"
/>
@@ -248,26 +249,36 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi
<div className="flex items-center justify-between">
<Label htmlFor="enabled">{t("form.enabled")}</Label>
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Switch id="enabled" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
{errors.root && (
<p className="text-sm text-destructive">{errors.root.message}</p>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{isOAuth ? t("form.close") : t("form.cancel")}
</Button>
{!isOAuth && (
<Button
onClick={handleSubmit}
disabled={!name.trim() || !isValidSlug(name) || !providerType || loading}
onClick={handleSubmit(onFormSubmit, (errs) => {
// surface first field error as root error for display
const first = Object.values(errs)[0];
if (first?.message) form.setError("root", { message: first.message });
})}
disabled={!name || !!errors.name || !providerType || isSubmitting}
className="gap-1"
>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{loading ? t("form.creating") : t("form.create")}
{isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isSubmitting ? t("form.creating") : t("form.create")}
</Button>
)}
</DialogFooter>
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod";
import { isValidSlug } from "@/lib/slug";
export const agentCreateSchema = z.object({
emoji: z.string().max(2).optional(),
displayName: z.string().min(1, "Required"),
agentKey: z
.string()
.min(1, "Required")
.refine(isValidSlug, "Only lowercase letters, numbers, and hyphens"),
provider: z.string().min(1, "Required"),
model: z.string().min(1, "Required"),
agentType: z.enum(["open", "predefined"]),
description: z.string().optional(),
selfEvolve: z.boolean(),
});
export type AgentCreateFormData = z.infer<typeof agentCreateSchema>;
+15
View File
@@ -0,0 +1,15 @@
import { z } from "zod";
import { isValidSlug } from "@/lib/slug";
export const channelInstanceSchema = z.object({
name: z
.string()
.min(1, "Required")
.refine(isValidSlug, "Only lowercase letters, numbers, and hyphens"),
displayName: z.string().optional(),
channelType: z.string().min(1, "Required"),
agentId: z.string().min(1, "Required"),
enabled: z.boolean(),
});
export type ChannelInstanceFormData = z.infer<typeof channelInstanceSchema>;
+15
View File
@@ -0,0 +1,15 @@
import { z } from "zod";
export const cliCredentialSchema = z.object({
binaryName: z.string().min(1, "Required"),
binaryPath: z.string().optional(),
description: z.string().optional(),
denyArgs: z.string().optional(),
denyVerbose: z.string().optional(),
timeout: z.number().min(1),
tips: z.string().optional(),
agentId: z.string().optional(),
enabled: z.boolean(),
});
export type CliCredentialFormData = z.infer<typeof cliCredentialSchema>;
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod";
import { isValidSlug } from "@/lib/slug";
export const cronCreateSchema = z.object({
name: z
.string()
.min(1, "Required")
.refine(isValidSlug, "Only lowercase letters, numbers, and hyphens"),
message: z.string().min(1, "Required"),
agentId: z.string().optional(),
scheduleKind: z.enum(["every", "cron", "at"]),
everyValue: z.string().min(1, "Required"),
cronExpr: z.string().min(1, "Required"),
});
export type CronCreateFormData = z.infer<typeof cronCreateSchema>;
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
export const heartbeatConfigSchema = z.object({
enabled: z.boolean(),
intervalMin: z.number().min(5),
ackMaxChars: z.number().min(0),
maxRetries: z.number().min(0),
isolatedSession: z.boolean(),
lightContext: z.boolean(),
activeHoursStart: z.string().optional(),
activeHoursEnd: z.string().optional(),
timezone: z.string().optional(),
channel: z.string().optional(),
chatId: z.string().optional(),
hbProvider: z.string().optional(),
hbModel: z.string().optional(),
checklist: z.string().optional(),
});
export type HeartbeatConfigFormData = z.infer<typeof heartbeatConfigSchema>;
+22
View File
@@ -0,0 +1,22 @@
import { z } from "zod";
import { isValidSlug } from "@/lib/slug";
export const providerCreateSchema = z.object({
name: z
.string()
.min(1, "Required")
.refine(isValidSlug, "Only lowercase letters, numbers, and hyphens"),
displayName: z.string().optional(),
providerType: z.string().min(1, "Required"),
apiBase: z.string().optional(),
apiKey: z.string().optional(),
enabled: z.boolean(),
// ACP-specific fields
acpBinary: z.string().optional(),
acpArgs: z.string().optional(),
acpIdleTTL: z.string().optional(),
acpPermMode: z.string().optional(),
acpWorkDir: z.string().optional(),
});
export type ProviderCreateFormData = z.infer<typeof providerCreateSchema>;