Files
ai-video-fullstack/frontend/src/components/pages/ComponentsToolsPage.tsx
2026-07-18 00:00:06 +08:00

961 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronDown,
ChevronUp,
Copy,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { McpServersSection } from "@/components/tools/McpServersSection";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataList, type DataListColumn } from "@/components/ui/data-list";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { FilterPills } from "@/components/ui/filter-pills";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { PageHeader } from "@/components/ui/page-header";
import { SearchInput } from "@/components/ui/search-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
toolsApi,
type HttpToolDefinition,
type Tool,
type ToolParameter,
type ToolStatus,
type ToolUpsert,
} from "@/lib/api";
type ToolKind = "end_call" | "http" | "mcp";
type HttpMethod = HttpToolDefinition["config"]["method"];
type ToolFilter = "全部" | "End Call" | "HTTP" | "MCP";
type SortOrder = "newest" | "oldest";
const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP", "MCP"];
type ToolForm = {
name: string;
functionName: string;
type: ToolKind;
description: string;
status: ToolStatus;
messageType: "none" | "custom";
customMessage: string;
captureReason: boolean;
method: HttpMethod;
url: string;
timeoutSeconds: string;
headers: string;
secretHeaders: string;
parameters: string;
body: string;
dynamicVariableAssignments: string;
secretDynamicVariables: string;
mcpServerId: string;
remoteToolName: string;
inputSchema: string;
schemaHash: string;
};
const EMPTY_OBJECT = "{}";
const EMPTY_ARRAY = "[]";
function blankForm(): ToolForm {
return {
name: "",
functionName: "",
type: "end_call",
description: "",
status: "active",
messageType: "none",
customMessage: "",
captureReason: true,
method: "GET",
url: "",
timeoutSeconds: "15",
headers: EMPTY_OBJECT,
secretHeaders: EMPTY_OBJECT,
parameters: EMPTY_ARRAY,
body: EMPTY_OBJECT,
dynamicVariableAssignments: EMPTY_OBJECT,
secretDynamicVariables: EMPTY_OBJECT,
mcpServerId: "",
remoteToolName: "",
inputSchema: EMPTY_OBJECT,
schemaHash: "",
};
}
function pretty(value: unknown, fallback: string): string {
return JSON.stringify(value ?? JSON.parse(fallback), null, 2);
}
function formFromTool(tool: Tool): ToolForm {
const base = { ...blankForm(), name: tool.name, functionName: tool.functionName };
base.type = tool.type;
base.description = tool.description;
base.status = tool.status;
if (tool.definition.type === "end_call") {
base.messageType = tool.definition.config.messageType;
base.customMessage = tool.definition.config.customMessage;
base.captureReason = tool.definition.config.captureReason;
return base;
}
if (tool.definition.type === "mcp") {
base.mcpServerId = tool.mcpServerId ?? "";
base.remoteToolName = tool.definition.config.remoteToolName;
base.inputSchema = pretty(tool.definition.config.inputSchema, EMPTY_OBJECT);
base.schemaHash = tool.definition.config.schemaHash;
base.dynamicVariableAssignments = pretty(
tool.definition.config.dynamicVariableAssignments ?? {},
EMPTY_OBJECT,
);
return base;
}
base.method = tool.definition.config.method;
base.url = tool.definition.config.url;
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
base.headers = pretty(tool.definition.config.headers, EMPTY_OBJECT);
base.parameters = pretty(tool.definition.config.parameters, EMPTY_ARRAY);
base.body = pretty(tool.definition.config.body, EMPTY_OBJECT);
base.dynamicVariableAssignments = pretty(
tool.definition.config.dynamicVariableAssignments ?? {},
EMPTY_OBJECT,
);
const secrets = tool.secrets as {
headers?: Record<string, string>;
dynamic_variables?: Record<string, string>;
};
base.secretHeaders = pretty(secrets.headers ?? {}, EMPTY_OBJECT);
base.secretDynamicVariables = pretty(
secrets.dynamic_variables ?? {},
EMPTY_OBJECT,
);
return base;
}
function parseObject(value: string, label: string): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(value || EMPTY_OBJECT);
} catch {
throw new Error(`${label}不是有效的 JSON`);
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error(`${label}必须是 JSON 对象`);
}
return parsed as Record<string, unknown>;
}
function parseParameters(value: string): ToolParameter[] {
let parsed: unknown;
try {
parsed = JSON.parse(value || EMPTY_ARRAY);
} catch {
throw new Error("参数定义不是有效的 JSON");
}
if (!Array.isArray(parsed)) throw new Error("参数定义必须是 JSON 数组");
return parsed as ToolParameter[];
}
function functionNameFrom(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 64);
}
function payloadFromForm(form: ToolForm): ToolUpsert {
if (!form.name.trim()) throw new Error("请输入工具名称");
if (!/^[a-z][a-z0-9_]{0,63}$/.test(form.functionName)) {
throw new Error("函数名必须以小写字母开头,且只能包含小写字母、数字和下划线");
}
if (form.type === "end_call") {
return {
name: form.name.trim(),
functionName: form.functionName,
description: form.description.trim(),
status: form.status,
secrets: {},
definition: {
schemaVersion: 1,
type: "end_call",
config: {
messageType: form.messageType,
customMessage: form.messageType === "custom" ? form.customMessage : "",
captureReason: form.captureReason,
},
},
};
}
if (form.type === "mcp") {
return {
name: form.name.trim(),
functionName: form.functionName,
description: form.description.trim(),
status: form.status,
mcpServerId: form.mcpServerId,
secrets: {},
definition: {
schemaVersion: 1,
type: "mcp",
config: {
remoteToolName: form.remoteToolName,
inputSchema: parseObject(form.inputSchema, "MCP Input Schema"),
schemaHash: form.schemaHash,
dynamicVariableAssignments: parseObject(
form.dynamicVariableAssignments,
"变量赋值",
) as Record<string, string>,
},
},
};
}
const timeoutSeconds = Number(form.timeoutSeconds);
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
throw new Error("超时时间必须是 1 到 120 秒之间的整数");
}
const headers = parseObject(form.headers, "Header");
const secretHeaders = parseObject(form.secretHeaders, "敏感 Header");
const body = parseObject(form.body, "固定 Body");
const dynamicVariableAssignments = parseObject(
form.dynamicVariableAssignments,
"变量赋值",
);
const secretDynamicVariables = parseObject(
form.secretDynamicVariables,
"密钥变量",
);
const parameters = parseParameters(form.parameters);
return {
name: form.name.trim(),
functionName: form.functionName,
description: form.description.trim(),
status: form.status,
definition: {
schemaVersion: 1,
type: "http",
config: {
method: form.method,
url: form.url.trim(),
timeoutSeconds,
headers: headers as Record<string, string>,
parameters,
body,
dynamicVariableAssignments: dynamicVariableAssignments as Record<string, string>,
},
},
secrets: {
headers: secretHeaders,
dynamic_variables: secretDynamicVariables,
},
};
}
function formatTimestamp(value?: string | null): string {
if (!value) return "—";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function updatedAtValue(value?: string | null): number {
const time = value ? new Date(value).getTime() : NaN;
return Number.isNaN(time) ? 0 : time;
}
export function ComponentsToolsPage() {
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<ToolFilter>("全部");
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
const [currentPage, setCurrentPage] = useState(1);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Tool | null>(null);
const [form, setForm] = useState<ToolForm>(blankForm);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [functionNameTouched, setFunctionNameTouched] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
const loadTools = useCallback(async () => {
setLoading(true);
setError(null);
try {
setTools(await toolsApi.list());
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "加载工具失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadTools();
}, [loadTools]);
const filteredTools = useMemo(() => {
const query = search.trim().toLowerCase();
return tools.filter((tool) =>
(filter === "全部" ||
(filter === "End Call" && tool.type === "end_call") ||
(filter === "HTTP" && tool.type === "http") ||
(filter === "MCP" && tool.type === "mcp")) &&
(!query ||
[tool.name, tool.functionName, tool.description].some((value) =>
value.toLowerCase().includes(query),
)),
);
}, [filter, search, tools]);
const sortedTools = useMemo(() => {
return [...filteredTools].sort((a, b) => {
const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt);
if (diff !== 0) return sortOrder === "newest" ? diff : -diff;
return a.id.localeCompare(b.id);
});
}, [filteredTools, sortOrder]);
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(sortedTools.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize;
const paginatedTools = sortedTools.slice(pageStart, pageEnd);
function changeFilter(value: ToolFilter) {
setFilter(value);
setCurrentPage(1);
}
function changeSearch(value: string) {
setSearch(value);
setCurrentPage(1);
}
function openCreate() {
setEditing(null);
setForm(blankForm());
setFunctionNameTouched(false);
setFormError(null);
setDialogOpen(true);
}
function openEdit(tool: Tool) {
setEditing(tool);
setForm(formFromTool(tool));
setFunctionNameTouched(true);
setFormError(null);
setDialogOpen(true);
}
async function saveTool() {
if (saving) return;
setSaving(true);
setFormError(null);
try {
const payload = payloadFromForm(form);
if (editing) await toolsApi.update(editing.id, payload);
else await toolsApi.create(payload);
setDialogOpen(false);
await loadTools();
} catch (saveError) {
setFormError(saveError instanceof Error ? saveError.message : "保存失败");
} finally {
setSaving(false);
}
}
async function removeTool(tool: Tool) {
if (!window.confirm(`确认删除工具“${tool.name}”?`)) return;
setDeletingId(tool.id);
try {
await toolsApi.remove(tool.id);
await loadTools();
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : "删除失败");
} finally {
setDeletingId(null);
}
}
async function duplicateTool(id: string) {
setDuplicatingId(id);
try {
await toolsApi.duplicate(id);
await loadTools();
} catch (duplicateError) {
setError(duplicateError instanceof Error ? duplicateError.message : "复制失败");
} finally {
setDuplicatingId(null);
}
}
const columns: DataListColumn<Tool>[] = [
{
key: "name",
header: "工具名称",
width: "md:w-[360px]",
cell: (tool) => (
<>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-foreground">{tool.name}</span>
</div>
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
{tool.functionName}
</div>
</>
),
},
{
key: "type",
header: "类型",
width: "md:w-[128px]",
cell: (tool) => (
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground"
>
{tool.type === "end_call"
? "End Call"
: tool.type === "mcp"
? "MCP"
: "HTTP"}
</Badge>
),
},
{
key: "status",
header: "状态",
width: "md:w-[156px]",
cell: (tool) => (
<Badge variant="outline" className="h-6 px-3">
{tool.status === "active" ? "启用" : tool.status === "draft" ? "草稿" : "已归档"}
</Badge>
),
},
{
key: "updated",
width: "md:w-[176px]",
header: (
<button
type="button"
onClick={() => {
setSortOrder((current) => (current === "newest" ? "oldest" : "newest"));
setCurrentPage(1);
}}
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={sortOrder === "newest" ? "当前按最近更新排序" : "当前按最早更新排序"}
>
{sortOrder === "newest" ? <ChevronDown size={13} /> : <ChevronUp size={13} />}
</button>
),
cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground",
cell: (tool) => formatTimestamp(tool.updatedAt),
},
{
key: "actions",
header: "操作",
align: "right",
cellClassName: "flex justify-end gap-2",
cell: (tool) => (
<>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={() => openEdit(tool)}
>
<Pencil size={14} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${tool.name} 更多操作`}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem
className="rounded-lg"
disabled={duplicatingId === tool.id || tool.type === "mcp"}
onSelect={(event) => {
event.preventDefault();
void duplicateTool(tool.id);
}}
>
{duplicatingId === tool.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Copy size={14} />
)}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
disabled={deletingId === tool.id}
onSelect={(event) => {
event.preventDefault();
void removeTool(tool);
}}
>
{deletingId === tool.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
),
},
];
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<PageHeader
title="工具资源"
description="管理可复用的助手工具,并将启用的工具绑定到提示词助手。"
action={
<Button size="lg" className="w-full gap-2 sm:w-auto" onClick={openCreate}>
<Plus size={16} />
</Button>
}
/>
<McpServersSection onToolsChanged={loadTools} />
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<ListToolbar
filters={
<FilterPills options={toolFilters} value={filter} onChange={changeFilter} />
}
search={
<SearchInput
value={search}
onChange={changeSearch}
placeholder="搜索名称、函数名或描述"
className="lg:w-[320px]"
/>
}
/>
<DataList<Tool>
columns={columns}
rows={paginatedTools}
rowKey={(tool) => tool.id}
loading={loading}
loadingText="正在加载工具…"
error={error}
onRetry={() => void loadTools()}
empty={{
title: tools.length === 0 ? "暂无工具资源" : "未找到匹配的工具资源",
description:
tools.length === 0
? "点击右上角「添加工具」开始。"
: "请调整关键词或筛选条件后再试。",
}}
pagination={{
page: safeCurrentPage,
totalPages,
onPageChange: setCurrentPage,
summary:
filteredTools.length === 0
? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredTools.length)} / 共 ${filteredTools.length} 个工具资源`,
}}
/>
</section>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:overflow-hidden">
<DialogHeader>
<DialogTitle>{editing ? "编辑工具资源" : "添加工具资源"}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 lg:grid-cols-2">
<FieldSection title="基本信息" tall>
<Field label="工具名称" required>
<Input
value={form.name}
onChange={(event) => {
const name = event.target.value;
setForm((current) => ({
...current,
name,
functionName: functionNameTouched
? current.functionName
: functionNameFrom(name),
}));
}}
/>
</Field>
<Field label="函数名" required>
<Input
value={form.functionName}
onChange={(event) => {
setFunctionNameTouched(true);
setForm((current) => ({ ...current, functionName: event.target.value }));
}}
placeholder="query_order"
/>
</Field>
<Field label="工具类型" required>
<Select
value={form.type}
disabled={form.type === "mcp"}
onValueChange={(type: ToolKind) =>
setForm((current) => ({ ...current, type }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="end_call">End Call</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="mcp" disabled={!editing || form.type !== "mcp"}>
MCP Server
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="状态">
<Select
value={form.status}
onValueChange={(status: ToolStatus) =>
setForm((current) => ({ ...current, status }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="描述">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
rows={5}
/>
</Field>
</FieldSection>
<FieldSection title="参数配置" scrollable tall>
{form.type === "end_call" ? (
<EndCallFields form={form} setForm={setForm} />
) : form.type === "mcp" ? (
<McpFields form={form} setForm={setForm} />
) : (
<HttpFields form={form} setForm={setForm} />
)}
</FieldSection>
</div>
{formError && <div className="text-sm text-destructive">{formError}</div>}
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button onClick={() => void saveTool()} disabled={saving}>
{saving && <Loader2 size={15} className="animate-spin" />}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function EndCallFields({
form,
setForm,
}: {
form: ToolForm;
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
}) {
return (
<div className="space-y-4">
<Field label="结束语">
<Select
value={form.messageType}
onValueChange={(messageType: "none" | "custom") =>
setForm((current) => ({ ...current, messageType }))
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</Field>
{form.messageType === "custom" && (
<Field label="自定义结束语">
<Textarea
value={form.customMessage}
onChange={(event) =>
setForm((current) => ({ ...current, customMessage: event.target.value }))
}
rows={3}
/>
</Field>
)}
<div className="flex items-center justify-between gap-4 rounded-lg border border-hairline-strong px-4 py-3">
<div>
<div className="font-medium text-foreground"></div>
<div className="mt-0.5 text-xs text-muted-foreground"> reason </div>
</div>
<Switch
checked={form.captureReason}
onCheckedChange={(captureReason) =>
setForm((current) => ({ ...current, captureReason }))
}
/>
</div>
</div>
);
}
function McpFields({
form,
setForm,
}: {
form: ToolForm;
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
}) {
return (
<div className="space-y-4">
<div className="rounded-xl border border-hairline bg-canvas-soft px-4 py-3 text-sm text-muted-foreground">
Schema MCP Server
</div>
<Field label="远端工具名称">
<Input value={form.remoteToolName} disabled className="font-mono" />
</Field>
<Field label="MCP Server ID">
<Input value={form.mcpServerId} disabled className="font-mono" />
</Field>
<JsonField
label="Input Schema只读"
value={form.inputSchema}
onChange={() => undefined}
rows={10}
disabled
/>
<JsonField
label="响应变量赋值"
value={form.dynamicVariableAssignments}
onChange={(dynamicVariableAssignments) =>
setForm((current) => ({ ...current, dynamicVariableAssignments }))
}
/>
</div>
);
}
function HttpFields({
form,
setForm,
}: {
form: ToolForm;
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
}) {
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[140px_1fr]">
<Field label="方法">
<Select
value={form.method}
onValueChange={(method: HttpMethod) =>
setForm((current) => ({ ...current, method }))
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background"><SelectValue /></SelectTrigger>
<SelectContent>
{(["GET", "POST", "PUT", "PATCH", "DELETE"] as HttpMethod[]).map((method) => (
<SelectItem key={method} value={method}>{method}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="URL">
<Input
value={form.url}
onChange={(event) => setForm((current) => ({ ...current, url: event.target.value }))}
placeholder="https://api.example.com/orders/{order_id}"
/>
</Field>
</div>
<Field label="超时时间(秒)">
<Input
type="number"
min={1}
max={120}
value={form.timeoutSeconds}
onChange={(event) =>
setForm((current) => ({ ...current, timeoutSeconds: event.target.value }))
}
/>
</Field>
<JsonField label="Header" value={form.headers} onChange={(headers) => setForm((current) => ({ ...current, headers }))} />
<JsonField label="敏感 Header" value={form.secretHeaders} onChange={(secretHeaders) => setForm((current) => ({ ...current, secretHeaders }))} />
<JsonField label="参数定义" value={form.parameters} onChange={(parameters) => setForm((current) => ({ ...current, parameters }))} rows={6} />
<JsonField label="固定 Body" value={form.body} onChange={(body) => setForm((current) => ({ ...current, body }))} />
<JsonField
label="响应变量赋值"
value={form.dynamicVariableAssignments}
onChange={(dynamicVariableAssignments) =>
setForm((current) => ({ ...current, dynamicVariableAssignments }))
}
/>
<JsonField
label="密钥变量"
value={form.secretDynamicVariables}
onChange={(secretDynamicVariables) =>
setForm((current) => ({ ...current, secretDynamicVariables }))
}
/>
</div>
);
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</span>
{children}
</label>
);
}
function FieldSection({
title,
scrollable,
tall,
children,
}: {
title: string;
scrollable?: boolean;
tall?: boolean;
children: React.ReactNode;
}) {
return (
<section
className={[
"rounded-xl border border-hairline bg-surface-strong/20",
tall ? "lg:flex lg:h-[38rem] lg:flex-col" : "",
].join(" ")}
>
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
{title}
</div>
<div
className={[
"space-y-4 p-4",
scrollable ? "max-h-72 overflow-y-auto" : "",
tall ? "lg:max-h-none lg:flex-1" : "",
].join(" ")}
>
{children}
</div>
</section>
);
}
function JsonField({
label,
value,
onChange,
rows = 4,
disabled = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
disabled?: boolean;
}) {
return (
<Field label={label}>
<Textarea
value={value}
onChange={(event) => onChange(event.target.value)}
rows={rows}
disabled={disabled}
className="font-mono text-xs"
spellCheck={false}
/>
</Field>
);
}