feat: add fixed questions and improve authentication
This commit is contained in:
1 parent
8af4fed89d
commit
4c9f75f31e
42 files changed
+4008
-200
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
Reference in new issue
Block a user