Files
deerflow-code/frontend-web/src/components/workspace/settings/context-window-settings-page.tsx
T

244 lines
9.9 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 { 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>
);
}