feat: add fixed questions and improve authentication

This commit is contained in:
吉鑫升 committed 2026-08-21 17:58:39 +08:00
1 parent 8af4fed89d
commit 4c9f75f31e
42 files changed
+4008 -200

No files matched your search

+11
View File
@@ -775,6 +775,17 @@ export default function AgentChatPage({
onContextChange={(context) =>
setSettings("context", context)
}
knowledgeSelector={
<LlmWikiKnowledgeSelector
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
readOnly
label="知识空间"
/>
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
+13 -14
View File
@@ -837,20 +837,19 @@ export default function ChatPage() {
: undefined
}
knowledgeSelector={
isLandingState ? (
<LlmWikiKnowledgeSelector
source={selectedAgentId ? "agent" : "all"}
agentId={selectedAgentId}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={!!selectedAgentId}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
knowledgeSelectionLoading
}
label="知识空间"
/>
) : undefined
<LlmWikiKnowledgeSelector
source={selectedAgentId ? "agent" : "all"}
agentId={selectedAgentId}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={isLandingState && !!selectedAgentId}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
knowledgeSelectionLoading
}
readOnly={!isLandingState}
label="知识空间"
/>
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
@@ -0,0 +1,310 @@
"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>
);
}
+11
View File
@@ -45,6 +45,7 @@ import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
import MenuManagementPage from "./MenuManagementPage";
import LoginWhitelistPage from "./LoginWhitelistPage";
import FixedQuestionsPage from "./FixedQuestionsPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage";
@@ -443,6 +444,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/fixed-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<FixedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/sensitive-words"
element={