311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { Navigate } from "react-router-dom";
|
||
import {
|
||
Info,
|
||
MessageSquareText,
|
||
Plus,
|
||
Save,
|
||
Search,
|
||
Trash2,
|
||
Zap,
|
||
} from "lucide-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 { getStoredAuth } from "@/core/auth";
|
||
import {
|
||
type FixedQuestion,
|
||
useFixedQuestionActions,
|
||
useFixedQuestions,
|
||
} from "@/strategy-components/api/fixed-questions";
|
||
|
||
let sequence = 1;
|
||
|
||
function newId(): string {
|
||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||
return crypto.randomUUID();
|
||
}
|
||
return `fixed-${Date.now()}-${sequence++}`;
|
||
}
|
||
|
||
function serialize(rows: FixedQuestion[]): string {
|
||
return JSON.stringify(
|
||
rows
|
||
.map(({ id, question, answer, enabled, tokensPerSecond, sortOrder }) => ({
|
||
id,
|
||
question,
|
||
answer,
|
||
enabled,
|
||
tokensPerSecond,
|
||
sortOrder,
|
||
}))
|
||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||
);
|
||
}
|
||
|
||
export default function FixedQuestionsPage() {
|
||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||
const { data: serverQuestions = [], isLoading } = useFixedQuestions();
|
||
const { save } = useFixedQuestionActions();
|
||
const [rows, setRows] = useState<FixedQuestion[]>([]);
|
||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||
const [query, setQuery] = useState("");
|
||
|
||
const sourceKey = useMemo(
|
||
() => serialize(serverQuestions),
|
||
[serverQuestions],
|
||
);
|
||
useEffect(() => {
|
||
if (loadedKey !== sourceKey) {
|
||
setRows(serverQuestions);
|
||
setLoadedKey(sourceKey);
|
||
}
|
||
}, [loadedKey, serverQuestions, sourceKey]);
|
||
|
||
const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]);
|
||
const search = query.trim().toLowerCase();
|
||
const visibleRows = useMemo(
|
||
() =>
|
||
search
|
||
? rows.filter(
|
||
(row) =>
|
||
row.question.toLowerCase().includes(search) ||
|
||
row.answer.toLowerCase().includes(search),
|
||
)
|
||
: rows,
|
||
[rows, search],
|
||
);
|
||
const enabledCount = rows.filter(
|
||
(row) => row.enabled && row.question.trim() && row.answer.trim(),
|
||
).length;
|
||
|
||
if (!isAdmin) {
|
||
return <Navigate to="/page/workspace/chats/new" replace />;
|
||
}
|
||
|
||
const patchRow = (id: string, patch: Partial<FixedQuestion>) => {
|
||
setRows((current) =>
|
||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||
);
|
||
};
|
||
|
||
const addRow = () => {
|
||
setRows((current) => [
|
||
{
|
||
id: newId(),
|
||
question: "",
|
||
answer: "",
|
||
enabled: true,
|
||
tokensPerSecond: 100,
|
||
sortOrder: current.length,
|
||
},
|
||
...current,
|
||
]);
|
||
};
|
||
|
||
const removeRow = (id: string) => {
|
||
setRows((current) => current.filter((row) => row.id !== id));
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
const cleaned = rows.map((row, index) => ({
|
||
...row,
|
||
question: row.question.trim(),
|
||
tokensPerSecond: Math.round(row.tokensPerSecond),
|
||
sortOrder: index,
|
||
}));
|
||
for (const row of cleaned) {
|
||
if (!row.question) {
|
||
toast.error("问题不能为空");
|
||
return;
|
||
}
|
||
if (!row.answer.trim()) {
|
||
toast.error(`问题「${row.question}」的回答不能为空`);
|
||
return;
|
||
}
|
||
if (
|
||
!Number.isFinite(row.tokensPerSecond) ||
|
||
row.tokensPerSecond < 1 ||
|
||
row.tokensPerSecond > 1000
|
||
) {
|
||
toast.error(`问题「${row.question}」的流式速度须为 1–1000 token/s`);
|
||
return;
|
||
}
|
||
}
|
||
const seen = new Set<string>();
|
||
for (const row of cleaned) {
|
||
if (seen.has(row.question)) {
|
||
toast.error(`问题「${row.question}」重复,请删除或修改重复项`);
|
||
return;
|
||
}
|
||
seen.add(row.question);
|
||
}
|
||
try {
|
||
await save.mutateAsync(cleaned);
|
||
toast.success("问题配置已保存并生效");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="size-full overflow-auto bg-muted/20 px-4 py-6 sm:px-6">
|
||
<div className="mx-auto max-w-5xl space-y-5">
|
||
<section className="rounded-2xl border bg-background p-5 shadow-sm">
|
||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||
<div className="flex items-start gap-3">
|
||
<div className="rounded-xl bg-primary/10 p-2.5 text-primary">
|
||
<MessageSquareText className="size-5" />
|
||
</div>
|
||
<div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h1 className="text-xl font-semibold">问题配置</h1>
|
||
<span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
|
||
{rows.length} 条 / {enabledCount} 条启用
|
||
</span>
|
||
</div>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
为完全相同的问题配置固定回答,命中时直接流式返回,不调用大模型。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={addRow}>
|
||
<Plus className="size-4" />
|
||
新增问题
|
||
</Button>
|
||
<Button
|
||
onClick={handleSave}
|
||
disabled={!dirty || save.isPending}
|
||
>
|
||
<Save className="size-4" />
|
||
{save.isPending ? "保存中…" : "保存配置"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3 text-sm text-amber-900">
|
||
<Info className="mt-0.5 size-4 shrink-0" />
|
||
<span>
|
||
匹配区分大小写、标点和空格;停用的配置会保留但不参与匹配。带附件或图片的提问不会使用固定回答。
|
||
</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="flex flex-col gap-3 rounded-2xl border bg-background p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="relative w-full sm:max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
value={query}
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
placeholder="搜索问题或回答…"
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<Zap className="size-3.5 text-amber-500" />
|
||
启用项保存后立即生效
|
||
</div>
|
||
</section>
|
||
|
||
{isLoading ? (
|
||
<div className="rounded-2xl border bg-background py-16 text-center text-sm text-muted-foreground">
|
||
加载中…
|
||
</div>
|
||
) : visibleRows.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed bg-background py-16 text-center text-sm text-muted-foreground">
|
||
{rows.length === 0
|
||
? "暂无问题配置,点击「新增问题」开始配置"
|
||
: "没有匹配的问题或回答"}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{visibleRows.map((row, index) => (
|
||
<section
|
||
key={row.id}
|
||
className={`rounded-2xl border bg-background p-4 shadow-sm transition-opacity ${
|
||
row.enabled ? "" : "opacity-60"
|
||
}`}
|
||
>
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2 text-sm font-medium">
|
||
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">
|
||
{index + 1}
|
||
</span>
|
||
固定问答
|
||
</div>
|
||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<span>流式速度</span>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={1000}
|
||
step={10}
|
||
value={row.tokensPerSecond}
|
||
onChange={(event) =>
|
||
patchRow(row.id, {
|
||
tokensPerSecond: Number(event.target.value),
|
||
})
|
||
}
|
||
className="h-8 w-24"
|
||
aria-label="流式返回速度"
|
||
/>
|
||
<span>token/s</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Switch
|
||
checked={row.enabled}
|
||
onCheckedChange={(enabled) =>
|
||
patchRow(row.id, { enabled })
|
||
}
|
||
/>
|
||
{row.enabled ? "已启用" : "已停用"}
|
||
</label>
|
||
<button
|
||
type="button"
|
||
title="删除"
|
||
onClick={() => removeRow(row.id)}
|
||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||
>
|
||
<Trash2 className="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<label className="space-y-1.5 text-sm font-medium">
|
||
<span>问题</span>
|
||
<Textarea
|
||
value={row.question}
|
||
onChange={(event) =>
|
||
patchRow(row.id, { question: event.target.value })
|
||
}
|
||
placeholder="输入需要精确匹配的问题"
|
||
className="min-h-28 resize-y"
|
||
/>
|
||
</label>
|
||
<label className="space-y-1.5 text-sm font-medium">
|
||
<span>回答</span>
|
||
<Textarea
|
||
value={row.answer}
|
||
onChange={(event) =>
|
||
patchRow(row.id, { answer: event.target.value })
|
||
}
|
||
placeholder="输入命中后流式返回的回答(支持 Markdown)"
|
||
className="min-h-28 resize-y"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|