244 lines
9.9 KiB
TypeScript
244 lines
9.9 KiB
TypeScript
"use client";
|
||
|
||
import { ListOrderedIcon, Loader2Icon, ShrinkIcon } 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 { Switch } from "@/components/ui/switch";
|
||
import {
|
||
useConversationContextSettings,
|
||
useUpdateConversationContextSettings,
|
||
} from "@/core/system-settings/hooks";
|
||
|
||
export function ContextWindowSettingsPage() {
|
||
const { data, isLoading, isError, isFetching, refetch } =
|
||
useConversationContextSettings();
|
||
const update = useUpdateConversationContextSettings();
|
||
const enabled = data?.recent_turn_window_enabled ?? false;
|
||
const recentTurns = data?.recent_turns ?? 3;
|
||
const currentRoundLimit = data?.max_conversation_rounds ?? 20;
|
||
const [roundLimit, setRoundLimit] = useState(String(currentRoundLimit));
|
||
const [roundLimitTouched, setRoundLimitTouched] = useState(false);
|
||
const parsedRoundLimit = Number(roundLimit);
|
||
const roundLimitInvalid =
|
||
!Number.isInteger(parsedRoundLimit) ||
|
||
parsedRoundLimit < 1 ||
|
||
parsedRoundLimit > 50;
|
||
const useQuestionAsTitle = data?.use_question_as_title ?? false;
|
||
const [stepDraft, setStepDraft] = useState<string | null>(null);
|
||
const stepValue = stepDraft ?? String(data?.max_run_steps ?? 1000);
|
||
const steps = Number(stepValue);
|
||
const validSteps = stepValue.trim() !== "" && Number.isInteger(steps) && steps >= 20 && steps <= 10000;
|
||
|
||
useEffect(() => {
|
||
setRoundLimit(String(currentRoundLimit));
|
||
setRoundLimitTouched(false);
|
||
}, [currentRoundLimit]);
|
||
|
||
const saveSteps = async () => {
|
||
if (!validSteps) return;
|
||
try {
|
||
await update.mutateAsync({ max_run_steps: steps });
|
||
setStepDraft(null);
|
||
toast.success("单次问答步骤上限已保存,从下一次问答生效");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
}
|
||
};
|
||
|
||
const handleChange = async (checked: boolean) => {
|
||
try {
|
||
await update.mutateAsync({ recent_turn_window_enabled: checked });
|
||
toast.success(
|
||
checked
|
||
? `近 ${recentTurns} 轮上下文模式已开启`
|
||
: `近 ${recentTurns} 轮上下文模式已关闭`,
|
||
);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
}
|
||
};
|
||
|
||
const saveRoundLimit = async () => {
|
||
setRoundLimitTouched(true);
|
||
if (roundLimitInvalid) {
|
||
toast.error("对话轮数限制请填写 1 到 50 的整数");
|
||
return;
|
||
}
|
||
try {
|
||
await update.mutateAsync({ max_conversation_rounds: parsedRoundLimit });
|
||
toast.success(`单个对话最多可进行 ${parsedRoundLimit} 轮`);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
}
|
||
};
|
||
|
||
const handleQuestionTitleChange = async (checked: boolean) => {
|
||
try {
|
||
await update.mutateAsync({ use_question_as_title: checked });
|
||
toast.success(
|
||
checked
|
||
? "已开启:对话标题使用首个问题的前 20 个字"
|
||
: "已关闭:恢复回答后的标题生成",
|
||
);
|
||
} 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">
|
||
<ShrinkIcon className="size-5" />
|
||
上下文管理
|
||
</h3>
|
||
<p className="text-muted-foreground text-sm">
|
||
管理对话历史范围、单个对话轮数、标题生成方式和单次问答步骤上限。设置对所有用户生效,仅管理员可修改。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||
<div className="space-y-1">
|
||
<div className="font-medium">仅保留最近 {recentTurns} 轮对话</div>
|
||
<p className="text-muted-foreground text-sm">
|
||
开启后,主问答引擎的模型请求只携带最近 {recentTurns}
|
||
个真实用户回合(含当前轮)及其回复和工具结果,不再调用摘要模型。完整聊天记录仍会保存并显示;若这三轮本身仍超过模型上限,最终安全限制会继续裁剪超长内容。
|
||
</p>
|
||
</div>
|
||
{isLoading ? (
|
||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||
) : (
|
||
<Switch
|
||
checked={enabled}
|
||
disabled={isError || update.isPending}
|
||
onCheckedChange={handleChange}
|
||
aria-label={`仅保留最近 ${recentTurns} 轮对话`}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
|
||
<div className="min-w-0 space-y-1">
|
||
<label
|
||
htmlFor="max-conversation-rounds"
|
||
className="flex items-center gap-2 font-medium"
|
||
>
|
||
<ListOrderedIcon className="size-4" />
|
||
单个对话轮数限制
|
||
</label>
|
||
<p
|
||
id="max-conversation-rounds-help"
|
||
className="text-muted-foreground text-sm"
|
||
>
|
||
一轮按一条真实用户提问计算。默认 20,可设置为 1 到 50;达到上限后用户需要新建对话才能继续。
|
||
</p>
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<Input
|
||
id="max-conversation-rounds"
|
||
type="number"
|
||
min={1}
|
||
max={50}
|
||
inputMode="numeric"
|
||
value={roundLimit}
|
||
disabled={isLoading || isError || update.isPending}
|
||
onChange={(event) => setRoundLimit(event.target.value)}
|
||
onBlur={() => setRoundLimitTouched(true)}
|
||
aria-invalid={roundLimitTouched && roundLimitInvalid}
|
||
aria-describedby="max-conversation-rounds-help max-conversation-rounds-error"
|
||
className="w-28"
|
||
/>
|
||
<span className="text-muted-foreground text-sm">轮</span>
|
||
</div>
|
||
<p
|
||
id="max-conversation-rounds-error"
|
||
className={
|
||
roundLimitTouched && roundLimitInvalid
|
||
? "text-destructive min-h-5 text-xs"
|
||
: "text-muted-foreground min-h-5 text-xs"
|
||
}
|
||
role={roundLimitTouched && roundLimitInvalid ? "alert" : undefined}
|
||
>
|
||
{roundLimitTouched && roundLimitInvalid
|
||
? "请输入 1 到 50 的整数。"
|
||
: "保存后,新限制会应用到当前及后续普通对话。"}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
disabled={
|
||
isLoading ||
|
||
isError ||
|
||
update.isPending ||
|
||
(!roundLimitInvalid && parsedRoundLimit === currentRoundLimit)
|
||
}
|
||
onClick={() => void saveRoundLimit()}
|
||
>
|
||
{update.isPending ? "保存中…" : "保存轮数"}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||
<div className="space-y-1">
|
||
<div className="font-medium">标题直接使用首个问题</div>
|
||
<p className="text-muted-foreground text-sm">
|
||
开启后,取首个问题的前 20 个字作为最终标题,不足 20 个字则完整保留;回答结束后不再额外调用模型生成标题,也不轮询标题状态,避免影响下一轮提问。关闭后恢复原来的回答后标题生成方式。
|
||
</p>
|
||
</div>
|
||
{isLoading ? (
|
||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||
) : (
|
||
<Switch
|
||
checked={useQuestionAsTitle}
|
||
disabled={isError || update.isPending}
|
||
onCheckedChange={handleQuestionTitleChange}
|
||
aria-label="标题直接使用首个问题"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-3 rounded-lg border p-4">
|
||
<label htmlFor="max-run-steps" className="font-medium">单次问答步骤上限</label>
|
||
<p className="text-muted-foreground text-sm">
|
||
控制普通问答和智能体问答每次运行的最大执行步数(20–10000,默认 1000)。步骤包括模型、工具及内部处理,不等于界面显示的步骤条数。提高上限可支持更复杂的任务,也会增加耗时和用量。
|
||
</p>
|
||
<div className="flex items-center gap-3">
|
||
<Input id="max-run-steps" type="number" min={20} max={10000} step={1}
|
||
className="w-40" value={stepValue}
|
||
disabled={isLoading || isError || update.isPending}
|
||
onChange={(event) => setStepDraft(event.target.value)} />
|
||
<Button disabled={isLoading || isError || update.isPending || !validSteps}
|
||
onClick={() => void saveSteps()}>保存上限</Button>
|
||
</div>
|
||
{!validSteps && <p className="text-destructive text-sm">请输入 20 到 10000 之间的整数。</p>}
|
||
<p className="text-muted-foreground text-sm">
|
||
达到上限时保留已有对话内容并提示暂停;用户可发送“继续”,在当前对话中接着处理。
|
||
</p>
|
||
</div>
|
||
|
||
{isError && (
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="text-destructive text-sm">
|
||
无法读取当前上下文模式,本页暂不能修改;服务端会继续保持原设置。
|
||
</p>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={isFetching}
|
||
onClick={() => void refetch()}
|
||
>
|
||
{isFetching ? "重新加载中…" : "重新加载"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
<p className="text-muted-foreground text-xs">
|
||
开启后优先于聊天框中的“上下文压缩”选项,并从下一次新问答或运行开始生效,无需重启服务。
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|