Files
ttstd-infinite-canvas/web/src/components/channel-headers-editor.tsx

110 lines
5.5 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.
import { Button, Input, Tooltip } from "antd";
import { Plus, Trash2 } from "lucide-react";
import type { ChangeEvent } from "react";
import type { ChannelHeader } from "@/stores/use-config-store";
const DEFAULT_USER_AGENT = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)";
const MAX_HEADER_COUNT = 32;
const BLOCKED_HEADERS = new Set([
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"host",
"content-length",
"content-type",
"accept",
"connection",
"proxy-connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"forwarded",
"x-goog-api-key",
]);
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
type Props = {
value?: ChannelHeader[];
onChange?: (headers: ChannelHeader[]) => void;
disabled?: boolean;
};
export function ChannelHeadersEditor({ value = [], onChange, disabled }: Props) {
const headers = Array.isArray(value) ? value : [];
const validation = validateChannelHeaders(headers);
const hasUserAgent = headers.some((header) => header.name.trim().toLowerCase() === "user-agent");
const updateHeader = (index: number, patch: Partial<ChannelHeader>) => {
onChange?.(headers.map((header, itemIndex) => (itemIndex === index ? { ...header, ...patch } : header)));
};
return (
<div className="space-y-2 rounded-md border border-border/70 bg-muted/[.1] p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">自定义请求头</div>
<div className="mt-0.5 break-all text-xs leading-5 text-foreground/50">默认发送 {DEFAULT_USER_AGENT};添加 User-Agent 后会覆盖默认值。</div>
</div>
{!hasUserAgent ? (
<Button size="small" disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "User-Agent", value: DEFAULT_USER_AGENT }])}>
添加 User-Agent
</Button>
) : null}
</div>
{headers.length ? (
<div className="space-y-2">
{headers.map((header, index) => {
const inputProps = {
"aria-label": `请求头 ${index + 1} 值`,
disabled,
value: header.value,
placeholder: "Header 值",
onChange: (event: ChangeEvent<HTMLInputElement>) => updateHeader(index, { value: event.target.value }),
};
return (
<div key={index} className="grid min-w-0 grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_32px] gap-2">
<Input aria-label={`请求头 ${index + 1} 名称`} disabled={disabled} value={header.name} placeholder="Header 名称" onChange={(event) => updateHeader(index, { name: event.target.value })} />
{/token|key|secret/i.test(header.name) ? <Input.Password {...inputProps} /> : <Input {...inputProps} />}
<Tooltip title="删除请求头">
<Button aria-label={`删除请求头 ${index + 1}`} className="size-8 p-0" disabled={disabled} icon={<Trash2 className="size-3.5" />} onClick={() => onChange?.(headers.filter((_, itemIndex) => itemIndex !== index))} />
</Tooltip>
</div>
);
})}
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<Button size="small" type="dashed" icon={<Plus className="size-3.5" />} disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "", value: "" }])}>
添加请求头
</Button>
<span className={`text-xs ${validation ? "text-destructive" : "text-foreground/45"}`}>{validation || `${headers.length}/${MAX_HEADER_COUNT}`}</span>
</div>
</div>
);
}
export function validateChannelHeaders(headers?: ChannelHeader[]) {
if (!headers?.length) return "";
if (headers.length > MAX_HEADER_COUNT) return `自定义请求头最多支持 ${MAX_HEADER_COUNT} 项`;
const seen = new Set<string>();
let totalBytes = 0;
for (const header of headers) {
const name = header.name.trim();
const value = header.value.trim();
if (!name && !value) continue;
if (!name || !HEADER_NAME_PATTERN.test(name)) return "请求头名称无效,请使用标准 HTTP Header 名称";
const normalizedName = name.toLowerCase();
if (BLOCKED_HEADERS.has(normalizedName) || normalizedName.startsWith("x-canvas-") || normalizedName.startsWith("x-forwarded-")) return `${name} 由系统管理,不允许自定义`;
if (seen.has(normalizedName)) return `请求头不能重复:${name}`;
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) return `${name} 的值包含非法控制字符`;
if (name.length > 128 || value.length > 4096) return "单个请求头名称或值过长";
totalBytes += new TextEncoder().encode(name + value).length;
if (totalBytes > 16 * 1024) return "自定义请求头总大小不能超过 16KB";
seen.add(normalizedName);
}
return "";
}