Add knowledge retrieval configuration to Assistant model and related components
- Introduce new fields for knowledge retrieval configuration in AssistantConfig and Assistant models, including mode, top_n, and score_threshold. - Implement KnowledgeRetrievalConfig schema with validation for top_n. - Update backend services and routes to handle knowledge retrieval settings. - Enhance frontend components to support knowledge retrieval configuration, including a new dialog for advanced settings. - Add tests for knowledge retrieval configuration validation and description generation.
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
Video,
|
||||
Smartphone,
|
||||
Wrench,
|
||||
Settings2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
type AssistantType as ApiAssistantType,
|
||||
type AssistantUpsert,
|
||||
type KnowledgeBase,
|
||||
type KnowledgeRetrievalConfig,
|
||||
type ModelResource,
|
||||
type Tool,
|
||||
type TurnConfig,
|
||||
@@ -138,6 +140,7 @@ type AssistantForm = {
|
||||
asr: string;
|
||||
voice: string;
|
||||
knowledgeBase: string;
|
||||
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
|
||||
enableInterrupt: boolean;
|
||||
turnConfig: TurnConfig;
|
||||
visionEnabled: boolean;
|
||||
@@ -204,6 +207,14 @@ function defaultTurnConfig(): TurnConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
||||
return {
|
||||
mode: "automatic",
|
||||
topN: 5,
|
||||
scoreThreshold: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 后端 type(英文) ↔ 列表展示标签(中文)
|
||||
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
||||
prompt: "提示词",
|
||||
@@ -250,6 +261,7 @@ function blankPromptForm(name: string): AssistantForm {
|
||||
asr: "",
|
||||
voice: "",
|
||||
knowledgeBase: "",
|
||||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||
enableInterrupt: true,
|
||||
turnConfig: defaultTurnConfig(),
|
||||
visionEnabled: false,
|
||||
@@ -530,6 +542,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
asr: a.modelResourceIds.ASR ?? "",
|
||||
voice: a.modelResourceIds.TTS ?? "",
|
||||
knowledgeBase: a.knowledgeBaseId ?? "",
|
||||
knowledgeRetrievalConfig:
|
||||
a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(),
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
turnConfig: a.turnConfig,
|
||||
visionEnabled: a.visionEnabled,
|
||||
@@ -600,6 +614,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
visionModelResourceId: null,
|
||||
modelResourceIds: {},
|
||||
knowledgeBaseId: null,
|
||||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||
toolIds: [],
|
||||
prompt: "",
|
||||
apiUrl: "",
|
||||
@@ -665,6 +680,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}),
|
||||
},
|
||||
knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null,
|
||||
knowledgeRetrievalConfig: form.knowledgeRetrievalConfig,
|
||||
toolIds: form.toolIds,
|
||||
prompt: form.prompt,
|
||||
}),
|
||||
@@ -1906,6 +1922,18 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
title="知识库配置"
|
||||
description="选择助手回答时可检索的业务知识来源"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
知识库选择
|
||||
</span>
|
||||
<KnowledgeRetrievalConfigDialog
|
||||
disabled={!form.knowledgeBase}
|
||||
value={form.knowledgeRetrievalConfig}
|
||||
onChange={(config) =>
|
||||
updateForm("knowledgeRetrievalConfig", config)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ResourceSelectField
|
||||
value={form.knowledgeBase}
|
||||
onChange={(value) => updateForm("knowledgeBase", value)}
|
||||
@@ -3095,6 +3123,145 @@ function ResourceSelectField({
|
||||
);
|
||||
}
|
||||
|
||||
function KnowledgeRetrievalConfigDialog({
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
value: KnowledgeRetrievalConfig;
|
||||
onChange: (config: KnowledgeRetrievalConfig) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function openDialog() {
|
||||
setDraft(value);
|
||||
setError(null);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
if (draft.topN === 0 || draft.topN < -1 || !Number.isInteger(draft.topN)) {
|
||||
setError("Top N 必须为 -1 或大于 0 的整数");
|
||||
return;
|
||||
}
|
||||
if (draft.scoreThreshold < 0 || draft.scoreThreshold > 1) {
|
||||
setError("最低相关度必须在 0 到 1 之间");
|
||||
return;
|
||||
}
|
||||
onChange(draft);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={openDialog}
|
||||
aria-label="打开知识库高级配置"
|
||||
title={
|
||||
disabled
|
||||
? "请先选择知识库"
|
||||
: `${value.mode === "automatic" ? "自动检索" : "模型主动检索"} · Top N ${value.topN === -1 ? "不限" : value.topN} · 最低相关度 ${value.scoreThreshold}`
|
||||
}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>知识库高级配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
设置检索触发方式、返回数量和相关度过滤条件。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">检索方式</div>
|
||||
<Select
|
||||
value={draft.mode}
|
||||
onValueChange={(mode: "automatic" | "on_demand") =>
|
||||
setDraft({ ...draft, mode })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="automatic">自动检索</SelectItem>
|
||||
<SelectItem value="on_demand">模型主动检索</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{draft.mode === "automatic"
|
||||
? "每轮用户提问后自动检索,响应行为更稳定。"
|
||||
: "由大模型判断是否调用知识库,依赖模型的工具调用能力。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-foreground">
|
||||
最多返回片段数
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="-1"
|
||||
value={draft.topN}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, topN: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||
填写 -1 时保留所有达到阈值的结果。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-foreground">
|
||||
最低相关度
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={draft.scoreThreshold}
|
||||
onChange={(event) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
scoreThreshold: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||
仅保留相关度达到该值的片段,范围 0–1。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={saveDraft}>
|
||||
保存配置
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolPicker({
|
||||
tools,
|
||||
selectedIds,
|
||||
|
||||
@@ -976,16 +976,16 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
||||
</Card>
|
||||
|
||||
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>知识库设置</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改用途说明与 Embedding 模型。名称可在页面标题处直接编辑。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 rounded-xl border border-hairline bg-surface-strong/20 p-4">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">描述</span>
|
||||
|
||||
<div className="space-y-5">
|
||||
<FieldSection title="描述">
|
||||
<Textarea
|
||||
placeholder="用途说明(可选)"
|
||||
value={description}
|
||||
@@ -993,9 +993,9 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
||||
rows={4}
|
||||
className="field-sizing-fixed min-h-24 resize-y"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">Embedding 模型</span>
|
||||
</FieldSection>
|
||||
|
||||
<FieldSection title="Embedding 模型">
|
||||
<Select value={embeddingId} onValueChange={setEmbeddingId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="选择 Embedding 模型" />
|
||||
@@ -1009,12 +1009,13 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{documents.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
已有文档时不能更换 Embedding 模型。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</FieldSection>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
|
||||
取消
|
||||
@@ -1023,7 +1024,7 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
||||
disabled={busy || !embeddingId}
|
||||
onClick={() => void saveSettings()}
|
||||
>
|
||||
{busy ? <Loader2 className="animate-spin" size={15} /> : null}
|
||||
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -1038,7 +1039,8 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
||||
直接验证当前知识库能否召回正确片段。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
|
||||
<FieldSection title="检索测试" scrollable>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入问题,例如:退款规则是什么?"
|
||||
@@ -1054,38 +1056,37 @@ function KnowledgeEditView({ knowledgeBaseId }: { knowledgeBaseId: string }) {
|
||||
onClick={() => void testSearch()}
|
||||
>
|
||||
{searching ? (
|
||||
<Loader2 className="animate-spin" size={15} />
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Search size={15} />
|
||||
<Search size={14} />
|
||||
)}
|
||||
检索
|
||||
</Button>
|
||||
</div>
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
输入问题后点击检索查看结果。
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[50vh] divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
|
||||
{searchResults.map((result, index) => (
|
||||
<div
|
||||
key={`${result.document}-${index}`}
|
||||
className="space-y-2 px-4 py-3"
|
||||
>
|
||||
<div className="flex justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="truncate">来源:{result.document}</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
相关度 {result.score}
|
||||
</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||
{result.content}
|
||||
</p>
|
||||
searchResults.map((result, index) => (
|
||||
<div
|
||||
key={`${result.document}-${index}`}
|
||||
className="rounded-xl border border-hairline p-4"
|
||||
>
|
||||
<div className="mb-2 flex justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="truncate">来源:{result.document}</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
相关度 {result.score}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||
{result.content}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</FieldSection>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSearchOpen(false)}>
|
||||
关闭
|
||||
@@ -1222,3 +1223,30 @@ function EditableTitle({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 与模型资源弹窗相同的分区卡片 */
|
||||
function FieldSection({
|
||||
title,
|
||||
scrollable,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
scrollable?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-hairline bg-surface-strong/20">
|
||||
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
"space-y-4 p-4",
|
||||
scrollable ? "max-h-72 overflow-y-auto" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ export type Assistant = {
|
||||
visionModelResourceId: string | null;
|
||||
modelResourceIds: Partial<Record<ModelType, string>>;
|
||||
knowledgeBaseId: string | null;
|
||||
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
|
||||
toolIds: string[];
|
||||
prompt: string;
|
||||
apiUrl: string;
|
||||
@@ -189,6 +190,12 @@ export type Assistant = {
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type KnowledgeRetrievalConfig = {
|
||||
mode: "automatic" | "on_demand";
|
||||
topN: number;
|
||||
scoreThreshold: number;
|
||||
};
|
||||
|
||||
export type AssistantUpsert = Omit<Assistant, "id" | "updatedAt">;
|
||||
|
||||
export const assistantsApi = {
|
||||
|
||||
Reference in New Issue
Block a user