mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 02:14:43 +00:00
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:
1 parent
1187146626
commit
7a6a944622
14 files changed
+767
-429
No files matched your search
@@ -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": {
|
||||
|
||||
Generated
+34
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user