You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

518 lines
16 KiB
Vue

<!-- 分段设置组件 对应源代码 SegmentSetting.tsx -->
<template>
<div :class="[styles.content, styles.segmentContent]">
<h3 :class="styles.segmentTitle">分段设置</h3>
<div :class="styles.segmentLayout">
<div :class="styles.segmentLeft">
<SegmenteInfo
:indexing-estimate="indexingEstimate"
:set-indexing-estimate="setIndexingEstimate"
:preview-indexing-estimate="previewIndexingEstimate"
:file-list="fileList"
:selected-file-uid="selectedFileUid"
:set-selected-file-uid="setSelectedFileUid"
:set-current-file-index="setCurrentFileIndex"
/>
<ElFormItem label="Embedding 模型" :class="styles.embeddingItem">
<ElSelect
:class="styles.embeddingSelect"
placeholder="搜索模型"
filterable
:model-value="toModelSelectValue(embeddingModel)"
:loading="loadingTextEmbedding"
@change="(value: any) => setEmbeddingModel(parseModelSelectValue(String(value ?? '')))"
>
<ElOptionGroup
v-for="group in embeddingOptions"
:key="group.label"
:label="group.label"
>
<ElOption
v-for="opt in group.options"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
<div :class="styles.embeddingOption">
<img
v-if="opt.icon"
:class="styles.embeddingOptionIcon"
:src="opt.icon"
alt=""
/>
<span :class="styles.embeddingOptionLabel">{{ opt.label }}</span>
</div>
</ElOption>
</ElOptionGroup>
</ElSelect>
</ElFormItem>
<div :class="styles.retrievalSection">
<h3 :class="styles.segmentTitle">检索设置</h3>
<div :class="styles.settingCard">
<div :class="styles.settingHeader">
<div :class="styles.settingIcon">
<ElIcon><Search /></ElIcon>
</div>
<div>
<div :class="styles.settingName">混合检索</div>
<div :class="styles.settingDesc">
同时执行全文检索和向量检索,并应用重排序步骤,从两类查询结果中选择匹配用户问题的最佳结果,用户可以选择设置权重或配置重新排序模型。
</div>
</div>
</div>
<div :class="styles.retrievalForm">
<div :class="styles.retrievalRerankRow">
<div :class="styles.retrievalLabel">
<span>
Rerank 模型
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
</span>
</div>
<ElSwitch :model-value="rerankEnabled" @update:model-value="(v: any) => setRerankEnabled(Boolean(v))" />
</div>
<ElSelect
v-if="rerankEnabled"
:class="styles.retrievalModelSelect"
placeholder="搜索模型"
filterable
:model-value="toModelSelectValue(rerankModel)"
:loading="loadingRerank"
@change="(value: any) => setRerankModel(parseModelSelectValue(String(value ?? '')))"
>
<ElOptionGroup
v-for="group in rerankOptions"
:key="group.label"
:label="group.label"
>
<ElOption
v-for="opt in group.options"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
<div :class="styles.embeddingOption">
<img
v-if="opt.icon"
:class="styles.embeddingOptionIcon"
:src="opt.icon"
alt=""
/>
<span :class="styles.embeddingOptionLabel">{{ opt.label }}</span>
</div>
</ElOption>
</ElOptionGroup>
</ElSelect>
<div :class="styles.retrievalRow">
<div :class="styles.retrievalControl">
<div :class="styles.retrievalControlHeader">
<span>
Top K
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
</span>
</div>
<div :class="styles.retrievalControlBody">
<ElInputNumber
:class="styles.retrievalNumberInput"
:min="1"
:max="20"
:model-value="topK"
@update:model-value="(v: any) => setTopK(Number(v ?? 1))"
/>
<ElSlider
:class="styles.retrievalSlider"
:min="1"
:max="20"
:model-value="topK"
@update:model-value="(v: any) => setTopK(Number(v))"
/>
</div>
</div>
<div :class="styles.retrievalControl">
<div :class="styles.retrievalControlHeader">
<span>
Score 阈值
<ElIcon :class="styles.infoIcon"><InfoFilled /></ElIcon>
</span>
<ElSwitch
size="small"
:model-value="scoreThresholdEnabled"
@update:model-value="(v: any) => setScoreThresholdEnabled(Boolean(v))"
/>
</div>
<div :class="styles.retrievalControlBody">
<ElInputNumber
:class="styles.retrievalNumberInput"
:min="0"
:max="1"
:step="0.01"
:model-value="scoreThreshold"
:disabled="!scoreThresholdEnabled"
@update:model-value="(v: any) => setScoreThreshold(Number(v ?? 0))"
/>
<ElSlider
:class="styles.retrievalSlider"
:min="0"
:max="1"
:step="0.01"
:model-value="scoreThreshold"
:disabled="!scoreThresholdEnabled"
@update:model-value="(v: any) => setScoreThreshold(Number(v))"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<SegmentedPreview
:selected-filename="selectedFilename"
:selected-ext="selectedExt"
:selected-icon-style="selectedIconStyle"
:loading-indexing-estimate="loadingIndexingEstimate"
:indexing-estimate-preview-chunks="indexingEstimatePreviewChunks"
/>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "SegmentSetting" });
import { watch } from "vue";
import { Search, InfoFilled } from "@element-plus/icons-vue";
import SegmenteInfo from "./SegmenteInfo.vue";
import SegmentedPreview from "./SegmentedPreview.vue";
import { getFileIconStyle, type FileIconStyle } from "./fileStyles";
import { useDifyGet, useDifyPost } from "../useDifyApis";
import { knowledgeBase } from "../api";
import {
decodeInputValue,
getQueryIndexingEstimateJson,
type IndexingEstimate,
type ModelType,
type UploadFile,
} from "../types";
import styles from "../style/createKnowledge.module.scss";
type IndexingEstimatePreviewChunk = {
content?: string;
summary?: string | null;
child_chunks?: unknown;
};
type LocalizedText = {
zh_Hans?: string;
en_US?: string;
[key: string]: string | undefined;
};
type TextEmbeddingModel = {
model: string;
label?: LocalizedText;
model_type?: string;
status?: string;
deprecated?: boolean;
};
type TextEmbeddingProvider = {
provider: string;
label?: LocalizedText;
icon_small?: LocalizedText;
status?: string;
models?: TextEmbeddingModel[];
};
type ModelOption = {
key: string;
value: string;
label: string;
icon?: string;
};
type ModelOptionGroup = {
label: string;
options: ModelOption[];
};
interface Props {
fileList: UploadFile[];
fileIds: string[];
rerankModel: ModelType;
embeddingModel: ModelType;
indexingEstimate: IndexingEstimate;
rerankEnabled: boolean;
topK: number;
scoreThreshold: number;
scoreThresholdEnabled: boolean;
setRerankEnabled: (value: boolean) => void;
setTopK: (value: number) => void;
setScoreThreshold: (value: number) => void;
setScoreThresholdEnabled: (value: boolean) => void;
setRerankModel: (model: ModelType) => void;
setEmbeddingModel: (model: ModelType) => void;
setIndexingEstimate: (estimate: IndexingEstimate) => void;
}
const props = defineProps<Props>();
const selectedFileUid = ref<string>(props.fileList[0]?.uid ?? "");
const currentFileIndex = ref(0);
const selectedFile = computed(() =>
props.fileList.find((file) => file.uid === selectedFileUid.value)
);
const selectedFilename = computed(() => selectedFile.value?.name);
const selectedExt = computed(() => {
const name = selectedFilename.value;
if (!name || !name.includes(".")) return "";
return name.split(".").pop()?.toLowerCase() || "";
});
const selectedIconStyle = computed<FileIconStyle>(() =>
getFileIconStyle(selectedExt.value)
);
// 预览块
const {
result: indexingEstimateResult,
doPost: postIndexingEstimate,
loading: loadingIndexingEstimate,
} = useDifyPost({
url: knowledgeBase.indexingEstimate,
});
// textEmbedding 模型
const { result: textModeldingResult, loading: loadingTextEmbedding } = useDifyGet({
url: knowledgeBase.textEmbedding,
moreOperations: {
immediate: true,
},
});
// rerank 模型
const { result: rerankResult, loading: loadingRerank } = useDifyGet({
url: knowledgeBase.rerank,
moreOperations: {
immediate: true,
},
});
watch(
() => props.fileList,
(fileList) => {
if (!fileList?.length) {
selectedFileUid.value = "";
return;
}
if (selectedFileUid.value && fileList.some((f) => f.uid === selectedFileUid.value)) {
return;
}
selectedFileUid.value = fileList[0]?.uid ?? "";
},
{ immediate: true, deep: true }
);
watch(
[currentFileIndex, selectedFileUid],
() => {
if (selectedFileUid.value) {
previewIndexingEstimate();
}
},
{ immediate: true }
);
const indexingEstimatePreviewChunks = computed<IndexingEstimatePreviewChunk[]>(() => {
const resp = indexingEstimateResult.value as any;
const payload = resp?.data ?? resp;
return Array.isArray(payload?.preview) ? payload.preview : [];
});
function pickText(text?: LocalizedText): string {
return text?.zh_Hans ?? text?.en_US ?? Object.values(text ?? {})[0] ?? "";
}
function formatIconUrl(icon?: LocalizedText): string {
const url = pickText(icon);
return url || "";
}
function toModelSelectValue(model: ModelType): string | undefined {
if (!model?.currentProvider || !model?.currentModel) return undefined;
return `${model.currentProvider}::${model.currentModel}`;
}
function parseModelSelectValue(value: string): ModelType {
const [currentProvider, ...rest] = String(value ?? "").split("::");
const currentModel = rest.join("::");
return {
currentProvider: currentProvider ?? "",
currentModel: currentModel ?? "",
};
}
// 点击预览分段
function previewIndexingEstimate() {
const fileId = props.fileIds[currentFileIndex.value];
if (!fileId) return;
postIndexingEstimate({
data: getQueryIndexingEstimateJson(
[fileId],
[
{
id: "remove_extra_spaces",
enabled: props.indexingEstimate.pre_processing_rules.includes(
"remove_extra_spaces"
),
},
{
id: "remove_urls_emails",
enabled: props.indexingEstimate.pre_processing_rules.includes(
"remove_urls_emails"
),
},
],
{
separator: decodeInputValue(props.indexingEstimate.segmentIdentifier),
max_tokens: props.indexingEstimate.maximumSegmentLength,
chunk_overlap: props.indexingEstimate.segmentOverlapLength,
}
),
});
}
function setCurrentFileIndex(index: number) {
currentFileIndex.value = index;
}
function setSelectedFileUid(uid: string) {
selectedFileUid.value = uid;
}
// textEmbedding 选择
const embeddingOptions = computed<ModelOptionGroup[]>(() => {
const resp = textModeldingResult.value as any;
const embeddingProviders: TextEmbeddingProvider[] = Array.isArray(resp?.data)
? resp.data
: [];
return embeddingProviders
.filter((p) => (p?.status ?? "active") === "active")
.map((provider) => {
const providerLabel = pickText(provider.label) || provider.provider;
const iconPath = formatIconUrl(provider.icon_small);
const providerIcon = iconPath ? "/p30" + iconPath : "";
const models = (provider.models ?? [])
.filter((m) => (m?.status ?? "active") === "active")
.filter((m) => !m?.deprecated)
.filter((m) => (m?.model_type ?? "text-embedding") === "text-embedding")
.map((model) => {
const modelLabel = pickText(model.label) || model.model;
return {
key: `${provider.provider}::${model.model}`,
value: `${provider.provider}::${model.model}`,
label: modelLabel,
icon: providerIcon,
};
});
return {
label: providerLabel,
options: models,
};
})
.filter((group) => group.options.length > 0);
});
// rerank 选择
const rerankOptions = computed<ModelOptionGroup[]>(() => {
const resp = rerankResult.value as any;
const rerankProviders: TextEmbeddingProvider[] = Array.isArray(resp?.data)
? resp.data
: [];
return rerankProviders
.filter((p) => (p?.status ?? "active") === "active")
.map((provider) => {
const providerLabel = pickText(provider.label) || provider.provider;
const iconPath = formatIconUrl(provider.icon_small);
const providerIcon = iconPath ? "/p30" + iconPath : "";
const models = (provider.models ?? [])
.filter((m) => (m?.status ?? "active") === "active")
.filter((m) => !m?.deprecated)
.filter((m) => (m?.model_type ?? "rerank") === "rerank")
.map((model) => {
const modelLabel = pickText(model.label) || model.model;
return {
key: `${provider.provider}::${model.model}`,
value: `${provider.provider}::${model.model}`,
label: modelLabel,
icon: providerIcon,
};
});
return {
label: providerLabel,
options: models,
};
})
.filter((group) => group.options.length > 0);
});
// 默认选择第一个模型
watch(
[embeddingOptions, () => props.embeddingModel],
() => {
const firstValue = embeddingOptions.value?.[0]?.options?.[0]?.value;
if (!firstValue) return;
let exists = false;
for (const group of embeddingOptions.value) {
for (const opt of group.options) {
if (opt.value === toModelSelectValue(props.embeddingModel)) {
exists = true;
}
}
}
if (
!props.embeddingModel?.currentProvider ||
!props.embeddingModel?.currentModel ||
!exists
) {
props.setEmbeddingModel(parseModelSelectValue(firstValue));
}
},
{ deep: true }
);
watch(
[rerankOptions, () => props.rerankModel],
() => {
const firstValue = rerankOptions.value?.[0]?.options?.[0]?.value;
if (!firstValue) return;
let exists = false;
for (const group of rerankOptions.value) {
for (const opt of group.options) {
if (opt.value === toModelSelectValue(props.rerankModel)) {
exists = true;
}
}
}
if (
!props.rerankModel?.currentProvider ||
!props.rerankModel?.currentModel ||
!exists
) {
props.setRerankModel(parseModelSelectValue(firstValue));
}
},
{ deep: true }
);
</script>