347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Loader2Icon,
|
||
RotateCcwIcon,
|
||
SaveIcon,
|
||
UploadCloudIcon,
|
||
} from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
DEFAULT_UPLOAD_POLICY,
|
||
MEBIBYTE,
|
||
normalizeUploadExtension,
|
||
type UploadPolicy,
|
||
type UploadPolicyGroupKey,
|
||
useResetUploadPolicy,
|
||
useUpdateUploadPolicy,
|
||
useUploadPolicy,
|
||
} from "@/core/uploads";
|
||
|
||
type UploadPolicyDraft = {
|
||
maxFiles: string;
|
||
maxTotalMiB: string;
|
||
groups: Record<
|
||
UploadPolicyGroupKey,
|
||
{ maxMiB: string; extensions: string }
|
||
>;
|
||
thresholdMiB: string;
|
||
maxResultChars: string;
|
||
hardMaxResultChars: string;
|
||
};
|
||
|
||
const GROUP_META: Array<{
|
||
key: UploadPolicyGroupKey;
|
||
label: string;
|
||
description: string;
|
||
}> = [
|
||
{ key: "document", label: "文档", description: "Word、PPT、TXT、PDF 等" },
|
||
{ key: "image", label: "图片", description: "JPG、PNG 等" },
|
||
{ key: "spreadsheet", label: "表格", description: "Excel 等" },
|
||
];
|
||
|
||
function toDraft(policy: UploadPolicy): UploadPolicyDraft {
|
||
return {
|
||
maxFiles: String(policy.max_files_per_message),
|
||
maxTotalMiB: String(policy.max_total_bytes / MEBIBYTE),
|
||
groups: {
|
||
document: {
|
||
maxMiB: String(policy.groups.document.max_bytes / MEBIBYTE),
|
||
extensions: policy.groups.document.extensions.join(", "),
|
||
},
|
||
image: {
|
||
maxMiB: String(policy.groups.image.max_bytes / MEBIBYTE),
|
||
extensions: policy.groups.image.extensions.join(", "),
|
||
},
|
||
spreadsheet: {
|
||
maxMiB: String(policy.groups.spreadsheet.max_bytes / MEBIBYTE),
|
||
extensions: policy.groups.spreadsheet.extensions.join(", "),
|
||
},
|
||
},
|
||
thresholdMiB: String(policy.large_file.threshold_bytes / MEBIBYTE),
|
||
maxResultChars: String(policy.large_file.max_result_chars),
|
||
hardMaxResultChars: String(policy.large_file.hard_max_result_chars),
|
||
};
|
||
}
|
||
|
||
function positiveNumber(value: string, label: string): number {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
throw new Error(`${label}必须大于 0`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function extensionsOf(value: string, label: string): string[] {
|
||
const extensions = Array.from(
|
||
new Set(
|
||
value
|
||
.split(/[,,\s]+/)
|
||
.map(normalizeUploadExtension)
|
||
.filter(Boolean),
|
||
),
|
||
);
|
||
if (extensions.length === 0) throw new Error(`${label}至少填写一个扩展名`);
|
||
return extensions;
|
||
}
|
||
|
||
function fromDraft(draft: UploadPolicyDraft): UploadPolicy {
|
||
const maxResultChars = Math.floor(
|
||
positiveNumber(draft.maxResultChars, "常规输出上限"),
|
||
);
|
||
const hardMaxResultChars = Math.floor(
|
||
positiveNumber(draft.hardMaxResultChars, "硬输出上限"),
|
||
);
|
||
if (maxResultChars > hardMaxResultChars) {
|
||
throw new Error("常规输出上限不能大于硬输出上限");
|
||
}
|
||
|
||
const groups = {} as UploadPolicy["groups"];
|
||
for (const { key, label } of GROUP_META) {
|
||
groups[key] = {
|
||
max_bytes: Math.floor(
|
||
positiveNumber(draft.groups[key].maxMiB, `${label}单文件上限`) *
|
||
MEBIBYTE,
|
||
),
|
||
extensions: extensionsOf(draft.groups[key].extensions, label),
|
||
};
|
||
}
|
||
|
||
return {
|
||
max_files_per_message: Math.floor(
|
||
positiveNumber(draft.maxFiles, "单条消息文件数"),
|
||
),
|
||
max_total_bytes: Math.floor(
|
||
positiveNumber(draft.maxTotalMiB, "附件总大小") * MEBIBYTE,
|
||
),
|
||
groups,
|
||
large_file: {
|
||
threshold_bytes: Math.floor(
|
||
positiveNumber(draft.thresholdMiB, "大文件阈值") * MEBIBYTE,
|
||
),
|
||
max_result_chars: maxResultChars,
|
||
hard_max_result_chars: hardMaxResultChars,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function UploadSettingsPage() {
|
||
const { data, isLoading } = useUploadPolicy();
|
||
const update = useUpdateUploadPolicy();
|
||
const reset = useResetUploadPolicy();
|
||
const [draft, setDraft] = useState<UploadPolicyDraft>(() =>
|
||
toDraft(DEFAULT_UPLOAD_POLICY),
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (data) setDraft(toDraft(data));
|
||
}, [data]);
|
||
|
||
const setGroup = (
|
||
key: UploadPolicyGroupKey,
|
||
patch: Partial<UploadPolicyDraft["groups"][UploadPolicyGroupKey]>,
|
||
) => {
|
||
setDraft((current) => ({
|
||
...current,
|
||
groups: {
|
||
...current.groups,
|
||
[key]: { ...current.groups[key], ...patch },
|
||
},
|
||
}));
|
||
};
|
||
|
||
const save = async () => {
|
||
try {
|
||
const saved = await update.mutateAsync(fromDraft(draft));
|
||
setDraft(toDraft(saved));
|
||
toast.success("上传设置已保存");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存上传设置失败");
|
||
}
|
||
};
|
||
|
||
const restoreDefaults = async () => {
|
||
if (!window.confirm("确定恢复系统默认上传策略吗?当前自定义设置会被覆盖。")) {
|
||
return;
|
||
}
|
||
try {
|
||
const saved = await reset.mutateAsync();
|
||
setDraft(toDraft(saved));
|
||
toast.success("已恢复默认上传设置");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "恢复默认上传设置失败");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section className="space-y-6">
|
||
<div className="space-y-1">
|
||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||
<UploadCloudIcon className="size-5" />
|
||
上传设置
|
||
</h3>
|
||
<p className="text-muted-foreground text-sm">
|
||
控制聊天附件的数量、格式和大小,并限制大文件读取结果,防止文件内容一次性占满模型上下文。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4 rounded-lg border p-4 sm:grid-cols-2">
|
||
<NumberField
|
||
id="upload-max-files"
|
||
label="单条消息最多文件数"
|
||
value={draft.maxFiles}
|
||
onChange={(maxFiles) => setDraft((value) => ({ ...value, maxFiles }))}
|
||
step="1"
|
||
/>
|
||
<NumberField
|
||
id="upload-max-total"
|
||
label="单条消息附件总上限(MiB)"
|
||
value={draft.maxTotalMiB}
|
||
onChange={(maxTotalMiB) =>
|
||
setDraft((value) => ({ ...value, maxTotalMiB }))
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-semibold">文件分组</h4>
|
||
{GROUP_META.map(({ key, label, description }) => (
|
||
<div
|
||
key={key}
|
||
className="grid gap-4 rounded-lg border p-4 sm:grid-cols-[180px_minmax(0,1fr)]"
|
||
>
|
||
<NumberField
|
||
id={`upload-${key}-size`}
|
||
label={`${label}单文件上限(MiB)`}
|
||
value={draft.groups[key].maxMiB}
|
||
onChange={(maxMiB) => setGroup(key, { maxMiB })}
|
||
/>
|
||
<div className="space-y-2">
|
||
<label
|
||
className="text-sm font-medium"
|
||
htmlFor={`upload-${key}-extensions`}
|
||
>
|
||
{label}扩展名
|
||
</label>
|
||
<Input
|
||
id={`upload-${key}-extensions`}
|
||
value={draft.groups[key].extensions}
|
||
onChange={(event) =>
|
||
setGroup(key, { extensions: event.currentTarget.value })
|
||
}
|
||
placeholder=".doc, .docx"
|
||
/>
|
||
<p className="text-muted-foreground text-xs">
|
||
{description};用逗号或空格分隔,保存时自动补全小数点。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="space-y-3 rounded-lg border p-4">
|
||
<div>
|
||
<h4 className="text-sm font-semibold">大文件读取保护</h4>
|
||
<p className="text-muted-foreground mt-1 text-xs">
|
||
达到阈值后使用头部、中部、尾部采样;输出始终受字符预算约束,并在界面中标明截断或采样。
|
||
</p>
|
||
</div>
|
||
<div className="grid gap-4 sm:grid-cols-3">
|
||
<NumberField
|
||
id="upload-large-threshold"
|
||
label="大文件阈值(MiB)"
|
||
value={draft.thresholdMiB}
|
||
onChange={(thresholdMiB) =>
|
||
setDraft((value) => ({ ...value, thresholdMiB }))
|
||
}
|
||
/>
|
||
<NumberField
|
||
id="upload-result-chars"
|
||
label="常规输出上限(字符)"
|
||
value={draft.maxResultChars}
|
||
onChange={(maxResultChars) =>
|
||
setDraft((value) => ({ ...value, maxResultChars }))
|
||
}
|
||
step="100"
|
||
/>
|
||
<NumberField
|
||
id="upload-hard-result-chars"
|
||
label="硬输出上限(字符)"
|
||
value={draft.hardMaxResultChars}
|
||
onChange={(hardMaxResultChars) =>
|
||
setDraft((value) => ({ ...value, hardMaxResultChars }))
|
||
}
|
||
step="100"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-2">
|
||
{isLoading && (
|
||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||
<Loader2Icon className="size-3.5 animate-spin" /> 正在读取配置
|
||
</span>
|
||
)}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => void restoreDefaults()}
|
||
disabled={update.isPending || reset.isPending}
|
||
>
|
||
{reset.isPending ? (
|
||
<Loader2Icon className="size-4 animate-spin" />
|
||
) : (
|
||
<RotateCcwIcon className="size-4" />
|
||
)}
|
||
恢复默认
|
||
</Button>
|
||
<Button
|
||
onClick={() => void save()}
|
||
disabled={update.isPending || reset.isPending}
|
||
>
|
||
{update.isPending ? (
|
||
<Loader2Icon className="size-4 animate-spin" />
|
||
) : (
|
||
<SaveIcon className="size-4" />
|
||
)}
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function NumberField({
|
||
id,
|
||
label,
|
||
value,
|
||
onChange,
|
||
step = "0.1",
|
||
}: {
|
||
id: string;
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
step?: string;
|
||
}) {
|
||
return (
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium" htmlFor={id}>
|
||
{label}
|
||
</label>
|
||
<Input
|
||
id={id}
|
||
type="number"
|
||
min="0.01"
|
||
step={step}
|
||
value={value}
|
||
onChange={(event) => onChange(event.currentTarget.value)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|