153 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|