mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '收起',
|
||||
|
||||
Reference in New Issue
Block a user