Files
deerflow-code/frontend-web/src/pages/PromptPrefixSettingsPage.tsx
T
2026-06-02 11:01:07 +08:00

153 lines
5.7 KiB
TypeScript

"use client";
import { Loader2Icon,AlertCircle,CheckCircle2, } from "lucide-react";
import { useEffect, useRef, 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 { Textarea } from "@/components/ui/textarea";
import {
usePromptPrefixSettings,
useUpdatePromptPrefixSettings,
} from "@/core/system-settings/hooks";
import { useTheme } from "@/contexts/ThemeContext";
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function PromptPrefixSettingsPage() {
const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings();
const { theme } = useTheme();
const [enabled, setEnabled] = useState(false);
const [switchLabel, setSwitchLabel] = useState("");
const [promptPrefix, setPromptPrefix] = useState("");
// Only seed the draft from server data once. Re-syncing every time react-query
// refetches (window focus, route remount, etc.) would silently overwrite the
// user's in-progress edits — and made the toggle look unresponsive: a click
// would flip state to true and the next refetch would reset it to false.
const initializedRef = useRef(false);
useEffect(() => {
if (!data || initializedRef.current) return;
setEnabled(data.enabled);
setSwitchLabel(data.switch_label);
setPromptPrefix(data.prompt_prefix);
initializedRef.current = true;
}, [data]);
const handleSave = async () => {
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
if (enabled && !switchLabel.trim()) {
toast.error("开启时必须填写开关名称",darkToastOpts.error);
return;
}
if (enabled && !promptPrefix.trim()) {
toast.error("开启时必须填写拼接的前缀内容",darkToastOpts.error);
return;
}
try {
await update.mutateAsync({
enabled,
switch_label: switchLabel,
prompt_prefix: promptPrefix,
});
toast.success("已保存",darkToastOpts.success);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败",darkToastOpts.error);
}
};
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div>
<h1 className="text-xl font-semibold">问答前缀配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
会将下方配置的前缀文本和原问题拼接后再发送给模型。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">向用户暴露开关</div>
<div className="text-muted-foreground mt-0.5 text-xs">
关闭时,问答页不显示该开关,且后端不会做任何拼接。
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">开关名称</p>
<p className="text-muted-foreground text-xs">
展示在问答页输入框附近的标签文本。
</p>
<Input
value={switchLabel}
onChange={(e) => setSwitchLabel(e.target.value)}
placeholder="例:加入立场前缀"
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">拼接内容</p>
<p className="text-muted-foreground text-xs">
拼接到首条提问最前面的文字。例如:你需要站在中方角度,进行评价。
</p>
<Textarea
rows={4}
value={promptPrefix}
onChange={(e) => setPromptPrefix(e.target.value)}
placeholder="在新会话的第一次提问前自动加入这段文字"
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
)}
</div>
);
}