Feat/rerank model (#2137)

* feat(provider): add rerank model management as a core model type

* feat(provider): add rerank support to existing requesters and new rerank providers

* feat(web): add rerank model management UI and pipeline config

* fix(provider): correct rerank support_type after verification

- Add rerank to OpenRouter (confirmed /api/v1/rerank endpoint)
- Remove rerank from Ollama (no native support, PR #7219 unmerged)
- Remove rerank from JiekouAI (no rerank docs found, URL path mismatch)

* fix(provider): remove alru_cache from model getters and add rerank param hints

* fix: resolve lint errors

- Remove unused alru_cache import from modelmgr.py
- Remove unused error_message variable in invoke_rerank
- Fix prettier formatting in frontend files

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: remove unused exception variable

- Change `except Exception as e:` to `except Exception:` since e is not used
- Fix prettier formatting in ProviderCard.tsx

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: apply ruff format

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(template): add rerank config fields to default pipeline config

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: remove PR.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ui): remove duplicate rerank model form in AddModelPopover

The form was being rendered twice: once in TabsContent manual mode
and again in a separate conditional block for rerank tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
huanghuoguoguo
2026-04-20 23:32:36 +08:00
committed by GitHub
parent 5a5c4295b1
commit 323481d69b
41 changed files with 950 additions and 41 deletions

View File

@@ -240,6 +240,9 @@ export default function DynamicFormComponent({
case 'embedding-model-selector':
fieldSchema = z.string();
break;
case 'rerank-model-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-selector':
fieldSchema = z.string();
break;

View File

@@ -23,6 +23,7 @@ import {
Bot,
KnowledgeBase,
EmbeddingModel,
RerankModel,
PluginTool,
} from '@/app/infra/entities/api';
import { toast } from 'sonner';
@@ -74,6 +75,7 @@ export default function DynamicFormItemComponent({
}) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
const [rerankModels, setRerankModels] = useState<RerankModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [bots, setBots] = useState<Bot[]>([]);
const [tools, setTools] = useState<PluginTool[]>([]);
@@ -180,6 +182,19 @@ export default function DynamicFormItemComponent({
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) {
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
});
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
fetchLlmModels();
@@ -585,6 +600,45 @@ export default function DynamicFormItemComponent({
</div>
);
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
const groupedRerankModels = rerankModels.reduce(
(acc, model) => {
const providerName = model.provider?.name || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, RerankModel[]>,
);
return (
<div className="max-w-md">
<Select
value={field.value || '__none__'}
onValueChange={(v) => field.onChange(v === '__none__' ? '' : v)}
>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.rerank')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t('common.none')}</SelectItem>
{Object.entries(groupedRerankModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)}
</SelectContent>
</Select>
</div>
);
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Separate space models from regular models
const fbSpaceModels = llmModels.filter(

View File

@@ -147,15 +147,17 @@ export default function ModelsDialog({
setLoadingProviders((prev) => new Set(prev).add(providerUuid));
}
try {
const [llmResp, embeddingResp] = await Promise.all([
const [llmResp, embeddingResp, rerankResp] = await Promise.all([
httpClient.getProviderLLMModels(providerUuid),
httpClient.getProviderEmbeddingModels(providerUuid),
httpClient.getProviderRerankModels(providerUuid),
]);
setProviderModels((prev) => ({
...prev,
[providerUuid]: {
llm: llmResp.models,
embedding: embeddingResp.models,
rerank: rerankResp.models,
},
}));
} catch (err) {
@@ -247,12 +249,18 @@ export default function ModelsDialog({
abilities,
extra_args: extraArgsObj,
} as never);
} else {
} else if (modelType === 'embedding') {
await httpClient.createProviderEmbeddingModel({
name,
provider_uuid: providerUuid,
extra_args: extraArgsObj,
} as never);
} else {
await httpClient.createProviderRerankModel({
name,
provider_uuid: providerUuid,
extra_args: extraArgsObj,
} as never);
}
setAddModelPopoverOpen(null);
loadProviderModels(providerUuid, true);
@@ -341,12 +349,18 @@ export default function ModelsDialog({
abilities,
extra_args: extraArgsObj,
} as never);
} else {
} else if (modelType === 'embedding') {
await httpClient.updateProviderEmbeddingModel(modelId, {
name,
provider_uuid: providerUuid,
extra_args: extraArgsObj,
} as never);
} else {
await httpClient.updateProviderRerankModel(modelId, {
name,
provider_uuid: providerUuid,
extra_args: extraArgsObj,
} as never);
}
setEditModelPopoverOpen(null);
loadProviderModels(providerUuid, true);
@@ -366,8 +380,10 @@ export default function ModelsDialog({
try {
if (modelType === 'llm') {
await httpClient.deleteProviderLLMModel(modelId);
} else {
} else if (modelType === 'embedding') {
await httpClient.deleteProviderEmbeddingModel(modelId);
} else {
await httpClient.deleteProviderRerankModel(modelId);
}
toast.success(t('models.deleteSuccess'));
loadProviderModels(providerUuid, true);
@@ -407,7 +423,7 @@ export default function ModelsDialog({
abilities,
extra_args: extraArgsObj,
} as never);
} else {
} else if (modelType === 'embedding') {
await httpClient.testEmbeddingModel('_', {
uuid: '',
name,
@@ -415,6 +431,14 @@ export default function ModelsDialog({
provider: providerData,
extra_args: extraArgsObj,
} as never);
} else {
await httpClient.testRerankModel('_', {
uuid: '',
name,
provider_uuid: '',
provider: providerData,
extra_args: extraArgsObj,
} as never);
}
const duration = Date.now() - startTime;
setTestResult({ success: true, duration });

View File

@@ -3,6 +3,7 @@ import {
Plus,
MessageSquareText,
Cpu,
ArrowUpDown,
Eye,
Wrench,
Check,
@@ -265,7 +266,7 @@ export default function AddModelPopover({
onClick={(e) => e.stopPropagation()}
>
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="llm">
<MessageSquareText className="h-4 w-4 mr-1" />
{t('models.chat')}
@@ -274,6 +275,10 @@ export default function AddModelPopover({
<Cpu className="h-4 w-4 mr-1" />
{t('models.embedding')}
</TabsTrigger>
<TabsTrigger value="rerank">
<ArrowUpDown className="h-4 w-4 mr-1" />
{t('models.rerank')}
</TabsTrigger>
</TabsList>
<Tabs
@@ -330,7 +335,11 @@ export default function AddModelPopover({
</div>
)}
<ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />
<ExtraArgsEditor
args={extraArgs}
onChange={setExtraArgs}
modelType={tab}
/>
<div className="flex gap-2">
<Button
className="flex-1"
@@ -467,7 +476,9 @@ export default function AddModelPopover({
? t('models.alreadyAdded')
: model.type === 'llm'
? t('models.chat')
: t('models.embedding')}
: model.type === 'embedding'
? t('models.embedding')
: t('models.rerank')}
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Plus, X } from 'lucide-react';
import { Plus, X, HelpCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -9,19 +9,26 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useTranslation } from 'react-i18next';
import { ExtraArg } from '../types';
import { ExtraArg, ModelType } from '../types';
interface ExtraArgsEditorProps {
args: ExtraArg[];
onChange: (args: ExtraArg[]) => void;
disabled?: boolean;
modelType?: ModelType;
}
export default function ExtraArgsEditor({
args,
onChange,
disabled = false,
modelType,
}: ExtraArgsEditorProps) {
const { t } = useTranslation();
@@ -46,7 +53,27 @@ export default function ExtraArgsEditor({
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t('models.extraParameters')}</Label>
<div className="flex items-center gap-1">
<Label>{t('models.extraParameters')}</Label>
{modelType === 'rerank' && (
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="space-y-1 text-sm">
<p>
<strong>rerank_url</strong>: {t('models.rerankUrlTooltip')}
</p>
<p>
<strong>rerank_path</strong>:{' '}
{t('models.rerankPathTooltip')}
</p>
</div>
</TooltipContent>
</Tooltip>
)}
</div>
{!disabled && (
<Button
type="button"

View File

@@ -139,7 +139,11 @@ export default function ModelItem({
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{model.name}</span>
<Badge variant="secondary" className="text-xs">
{modelType === 'llm' ? t('models.chat') : t('models.embedding')}
{modelType === 'llm'
? t('models.chat')
: modelType === 'embedding'
? t('models.embedding')
: t('models.rerank')}
</Badge>
{modelType === 'llm' &&
(model as LLMModel).abilities?.includes('vision') && (
@@ -263,6 +267,7 @@ export default function ModelItem({
args={editExtraArgs}
onChange={setEditExtraArgs}
disabled={isLangBotModels}
modelType={modelType}
/>
<div className="flex gap-2">

View File

@@ -134,9 +134,12 @@ export default function ProviderCard({
const canDelete =
!isLangBotModels &&
(provider.llm_count || 0) === 0 &&
(provider.embedding_count || 0) === 0;
(provider.embedding_count || 0) === 0 &&
(provider.rerank_count || 0) === 0;
const totalModels =
(provider.llm_count || 0) + (provider.embedding_count || 0);
(provider.llm_count || 0) +
(provider.embedding_count || 0) +
(provider.rerank_count || 0);
return (
<Card className="mb-2">
@@ -393,11 +396,44 @@ export default function ProviderCard({
onResetTestResult={onResetTestResult}
/>
))}
{models.llm.length === 0 && models.embedding.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t('models.noModels')}
</p>
)}
{models.rerank.map((model) => (
<ModelItem
key={model.uuid}
model={model}
modelType="rerank"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
onCloseEditModel={onCloseEditModel}
onOpenDeleteConfirm={onOpenDeleteConfirm}
onCloseDeleteConfirm={onCloseDeleteConfirm}
onDeleteModel={() => onDeleteModel(model.uuid, 'rerank')}
onUpdateModel={(name, abilities, extraArgs) =>
onUpdateModel(
model.uuid,
'rerank',
name,
abilities,
extraArgs,
)
}
onTestModel={(name, abilities, extraArgs) =>
onTestModel(name, 'rerank', abilities, extraArgs)
}
isSubmitting={isSubmitting}
isTesting={isTesting}
testResult={testResult}
onResetTestResult={onResetTestResult}
/>
))}
{models.llm.length === 0 &&
models.embedding.length === 0 &&
models.rerank.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t('models.noModels')}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">

View File

@@ -1,6 +1,7 @@
import {
LLMModel,
EmbeddingModel,
RerankModel,
ModelProvider,
ProviderScanDebugInfo,
ScannedProviderModel,
@@ -12,11 +13,12 @@ export type ExtraArg = {
value: string;
};
export type ModelType = 'llm' | 'embedding';
export type ModelType = 'llm' | 'embedding' | 'rerank';
export interface ProviderModels {
llm: LLMModel[];
embedding: EmbeddingModel[];
rerank: RerankModel[];
}
export interface TestResult {

View File

@@ -49,6 +49,7 @@ export interface ModelProvider {
api_keys: string[];
llm_count?: number;
embedding_count?: number;
rerank_count?: number;
created_at?: string;
updated_at?: string;
}
@@ -114,6 +115,22 @@ export interface EmbeddingModel {
extra_args?: object;
}
export interface ApiRespProviderRerankModels {
models: RerankModel[];
}
export interface ApiRespProviderRerankModel {
model: RerankModel;
}
export interface RerankModel {
uuid: string;
name: string;
provider_uuid: string;
provider?: ModelProvider;
extra_args?: object;
}
export interface ApiRespPipelines {
pipelines: Pipeline[];
}

View File

@@ -35,6 +35,7 @@ export enum DynamicFormItemType {
SELECT = 'select',
LLM_MODEL_SELECTOR = 'llm-model-selector',
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
RERANK_MODEL_SELECTOR = 'rerank-model-selector',
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
PROMPT_EDITOR = 'prompt-editor',
UNKNOWN = 'unknown',

View File

@@ -31,6 +31,9 @@ import {
ApiRespProviderEmbeddingModels,
ApiRespProviderEmbeddingModel,
EmbeddingModel,
ApiRespProviderRerankModels,
ApiRespProviderRerankModel,
RerankModel,
ApiRespPluginSystemStatus,
ApiRespMCPServers,
ApiRespMCPServer,
@@ -182,6 +185,39 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/provider/models/embedding/${uuid}/test`, model);
}
// ============ Provider Model Rerank ============
public getProviderRerankModels(
providerUuid?: string,
): Promise<ApiRespProviderRerankModels> {
const params = providerUuid ? { provider_uuid: providerUuid } : {};
return this.get('/api/v1/provider/models/rerank', params);
}
public getProviderRerankModel(
uuid: string,
): Promise<ApiRespProviderRerankModel> {
return this.get(`/api/v1/provider/models/rerank/${uuid}`);
}
public createProviderRerankModel(model: RerankModel): Promise<object> {
return this.post('/api/v1/provider/models/rerank', model);
}
public deleteProviderRerankModel(uuid: string): Promise<object> {
return this.delete(`/api/v1/provider/models/rerank/${uuid}`);
}
public updateProviderRerankModel(
uuid: string,
model: RerankModel,
): Promise<object> {
return this.put(`/api/v1/provider/models/rerank/${uuid}`, model);
}
public testRerankModel(uuid: string, model: RerankModel): Promise<object> {
return this.post(`/api/v1/provider/models/rerank/${uuid}/test`, model);
}
// ============ Pipeline API ============
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
// as designed, this method will be deprecated, and only for developer to check the prefered config schema

View File

@@ -271,6 +271,10 @@ const enUS = {
loadError: 'Failed to load data',
chat: 'Chat',
embedding: 'Embedding',
rerank: 'Rerank',
rerankUrlTooltip:
'Full URL override for rerank endpoint (e.g. https://dashscope.aliyuncs.com/compatible-api/v1/reranks)',
rerankPathTooltip: 'Path appended to base URL (default: rerank)',
modelsCount: '{{count}} model(s)',
expandModels: 'Expand',
collapseModels: 'Collapse',

View File

@@ -260,6 +260,10 @@ const zhHans = {
loadError: '加载数据失败',
chat: '对话',
embedding: '嵌入',
rerank: '重排序',
rerankUrlTooltip:
'重排序接口的完整 URL 覆盖(如 https://dashscope.aliyuncs.com/compatible-api/v1/reranks',
rerankPathTooltip: '添加到基础 URL 后的重排序路径默认rerank',
modelsCount: '{{count}} 个模型',
expandModels: '展开',
collapseModels: '收起',