Compare commits

...

6 Commits

Author SHA1 Message Date
WangCham
3b3deec080 feat: modify frontend 2026-05-04 17:50:19 +08:00
WangCham
58ec377413 feat: add filter 2026-05-02 23:02:56 +08:00
WangCham
7c50aabe65 feat: add mcp and skills 2026-05-02 17:38:18 +08:00
huanghuoguoguo
a8fba46040 fix(alembic): check if rerank_models table exists before creating
Migration 0003 failed when rerank_models table already exists from create_all().
Add table existence check to prevent duplicate creation error in CI environments with cached database.
2026-04-20 23:43:48 +08:00
huanghuoguoguo
3115d6f6dd fix(i18n): add missing rerank translations to all locale files
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 23:35:08 +08:00
huanghuoguoguo
323481d69b 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>
2026-04-20 23:32:36 +08:00
57 changed files with 1612 additions and 263 deletions

View File

@@ -97,3 +97,51 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
return self.success()
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
class RerankModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={
'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
}
)
return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
elif quart.request.method == 'POST':
json_data = await quart.request.json
model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data)
return self.success(data={'uuid': model_uuid})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
if quart.request.method == 'GET':
model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
if model is None:
return self.http_status(404, -1, 'model not found')
return self.success(data={'model': model})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
return self.success()
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
json_data = await quart.request.json
await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
return self.success()

View File

@@ -15,6 +15,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
return self.success(data={'providers': providers})
elif quart.request.method == 'POST':
json_data = await quart.request.json
@@ -32,6 +33,7 @@ class ModelProvidersRouterGroup(group.RouterGroup):
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
return self.success(data={'provider': provider})
elif quart.request.method == 'PUT':
json_data = await quart.request.json

View File

@@ -367,3 +367,162 @@ class EmbeddingModelsService:
input_text=['Hello, world!'],
extra_args={},
)
class RerankModelsService:
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_rerank_models(self) -> list[dict]:
"""Get all rerank models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
models = result.all()
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
)
providers = {p.uuid: p for p in providers_result.all()}
models_list = []
for model in models:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
models_list.append(model_dict)
return models_list
async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
"""Get rerank models by provider UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new rerank model"""
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
persistence_model.RerankModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
return model_data['uuid']
async def get_rerank_model(self, model_uuid: str) -> dict | None:
"""Get a single rerank model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
)
model = result.first()
if model is None:
return None
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
return model_dict
async def update_rerank_model(self, model_uuid: str, model_data: dict) -> None:
"""Update an existing rerank model"""
if 'uuid' in model_data:
del model_data['uuid']
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.RerankModel)
.where(persistence_model.RerankModel.uuid == model_uuid)
.values(**model_data)
)
await self.ap.model_mgr.remove_rerank_model(model_uuid)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
persistence_model.RerankModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
async def delete_rerank_model(self, model_uuid: str) -> None:
"""Delete a rerank model"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
)
await self.ap.model_mgr.remove_rerank_model(model_uuid)
async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
"""Test a rerank model"""
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
if model_uuid != '_':
for model in self.ap.model_mgr.rerank_models:
if model.model_entity.uuid == model_uuid:
runtime_rerank_model = model
break
if runtime_rerank_model is None:
raise Exception('model not found')
else:
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
await runtime_rerank_model.provider.invoke_rerank(
model=runtime_rerank_model,
query='What is artificial intelligence?',
documents=[
'Artificial intelligence is a branch of computer science.',
'The weather is nice today.',
],
)

View File

@@ -98,6 +98,14 @@ class ModelProviderService:
if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it')
rerank_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
)
)
if rerank_result.first() is not None:
raise ValueError('Cannot delete provider: Rerank models still reference it')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
@@ -122,7 +130,14 @@ class ModelProviderService:
)
embedding_count = embedding_result.scalar() or 0
return {'llm_count': llm_count, 'embedding_count': embedding_count}
rerank_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.RerankModel)
.where(persistence_model.RerankModel.provider_uuid == provider_uuid)
)
rerank_count = rerank_result.scalar() or 0
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
"""Find existing provider or create new one"""

View File

@@ -133,6 +133,8 @@ class Application:
embedding_models_service: model_service.EmbeddingModelsService = None
rerank_models_service: model_service.RerankModelsService = None
provider_service: provider_service.ModelProviderService = None
pipeline_service: pipeline_service.PipelineService = None

View File

@@ -61,6 +61,9 @@ class BuildAppStage(stage.BootingStage):
embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
ap.embedding_models_service = embedding_models_service_inst
rerank_models_service_inst = model_service.RerankModelsService(ap)
ap.rerank_models_service = rerank_models_service_inst
provider_service_inst = provider_service.ModelProviderService(ap)
ap.provider_service = provider_service_inst

View File

@@ -59,3 +59,22 @@ class EmbeddingModel(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
class RerankModel(Base):
"""Rerank model"""
__tablename__ = 'rerank_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)

View File

@@ -0,0 +1,35 @@
"""add rerank_models table
Revision ID: 0003_add_rerank_models
Revises: 0002_sample
Create Date: 2026-04-19
"""
import sqlalchemy as sa
from alembic import op
revision = '0003_add_rerank_models'
down_revision = '0002_sample'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Check if table already exists (may have been created by create_all())
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'rerank_models' not in inspector.get_table_names():
op.create_table(
'rerank_models',
sa.Column('uuid', sa.String(255), primary_key=True, unique=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('provider_uuid', sa.String(255), nullable=False),
sa.Column('extra_args', sa.JSON, nullable=False, server_default='{}'),
sa.Column('prefered_ranking', sa.Integer, nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('rerank_models')

View File

@@ -9,7 +9,6 @@ from ...discover import engine
from . import token
from ...entity.persistence import model as persistence_model
from ...entity.errors import provider as provider_errors
from async_lru import alru_cache
class ModelManager:
@@ -24,6 +23,8 @@ class ModelManager:
embedding_models: list[requester.RuntimeEmbeddingModel]
rerank_models: list[requester.RuntimeRerankModel]
requester_components: list[engine.Component]
requester_dict: dict[str, type[requester.ProviderAPIRequester]]
@@ -32,6 +33,7 @@ class ModelManager:
self.ap = ap
self.llm_models = []
self.embedding_models = []
self.rerank_models = []
self.requester_components = []
self.requester_dict = {}
@@ -64,8 +66,7 @@ class ModelManager:
self.llm_models = []
self.embedding_models = []
# Load all providers first
self.rerank_models = []
self.provider_dict = {}
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
@@ -110,6 +111,22 @@ class ModelManager:
except Exception as e:
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
# Load rerank models
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
rerank_models = result.all()
for rerank_model in rerank_models:
try:
provider = self.provider_dict.get(rerank_model.provider_uuid)
if provider is None:
self.ap.logger.warning(
f'Provider {rerank_model.provider_uuid} not found for model {rerank_model.uuid}'
)
continue
runtime_rerank_model = await self.load_rerank_model_with_provider(rerank_model, provider)
self.rerank_models.append(runtime_rerank_model)
except Exception as e:
self.ap.logger.error(f'Failed to load model {rerank_model.uuid}: {e}\n{traceback.format_exc()}')
async def sync_new_models_from_space(self):
"""Sync models from Space"""
space_model_provider = await self.ap.persistence_mgr.execute_async(
@@ -212,6 +229,26 @@ class ModelManager:
return runtime_embedding_model
async def init_temporary_runtime_rerank_model(
self,
model_info: dict,
) -> requester.RuntimeRerankModel:
"""Initialize runtime rerank model from dict (for testing)"""
provider_info = model_info.get('provider', {})
runtime_provider = await self.load_provider(provider_info)
runtime_rerank_model = requester.RuntimeRerankModel(
model_entity=persistence_model.RerankModel(
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid='',
extra_args=model_info.get('extra_args', {}),
),
provider=runtime_provider,
)
return runtime_rerank_model
async def load_provider(
self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict
) -> requester.RuntimeProvider:
@@ -269,6 +306,9 @@ class ModelManager:
for model in self.embedding_models:
if model.provider.provider_entity.uuid == provider_uuid:
model.provider = new_runtime_provider
for model in self.rerank_models:
if model.provider.provider_entity.uuid == provider_uuid:
model.provider = new_runtime_provider
# update ref in provider dict
self.provider_dict[provider_uuid] = new_runtime_provider
@@ -305,6 +345,22 @@ class ModelManager:
return runtime_embedding_model
async def load_rerank_model_with_provider(
self,
model_info: persistence_model.RerankModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeRerankModel:
"""Load rerank model with provider info"""
if isinstance(model_info, sqlalchemy.Row):
model_info = persistence_model.RerankModel(**model_info._mapping)
runtime_rerank_model = requester.RuntimeRerankModel(
model_entity=model_info,
provider=provider,
)
return runtime_rerank_model
async def load_llm_model(self, model_info: dict):
"""Load LLM model from dict (with provider info)"""
provider_info = model_info.get('provider', {})
@@ -352,7 +408,6 @@ class ModelManager:
await self.load_embedding_model_with_provider(model_entity, provider_entity)
@alru_cache(ttl=60 * 5)
async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
"""Get LLM model by uuid"""
for model in self.llm_models:
@@ -360,7 +415,6 @@ class ModelManager:
return model
raise ValueError(f'LLM model {uuid} not found')
@alru_cache(ttl=60 * 5)
async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
"""Get embedding model by uuid"""
for model in self.embedding_models:
@@ -368,6 +422,13 @@ class ModelManager:
return model
raise ValueError(f'Embedding model {uuid} not found')
async def get_rerank_model_by_uuid(self, uuid: str) -> requester.RuntimeRerankModel:
"""Get rerank model by uuid"""
for model in self.rerank_models:
if model.model_entity.uuid == uuid:
return model
raise ValueError(f'Rerank model {uuid} not found')
async def remove_llm_model(self, model_uuid: str):
"""Remove LLM model"""
for model in self.llm_models:
@@ -382,6 +443,13 @@ class ModelManager:
self.embedding_models.remove(model)
return
async def remove_rerank_model(self, model_uuid: str):
"""Remove rerank model"""
for model in self.rerank_models:
if model.model_entity.uuid == model_uuid:
self.rerank_models.remove(model)
return
def get_available_requesters_info(self, model_type: str) -> list[dict]:
"""Get all available requesters"""
if model_type != '':

View File

@@ -247,6 +247,40 @@ class RuntimeProvider:
except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')
async def invoke_rerank(
self,
model: RuntimeRerankModel,
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[dict]:
"""Bridge method for invoking rerank with monitoring"""
start_time = time.time()
status = 'success'
try:
result = await self.requester.invoke_rerank(
model=model,
query=query,
documents=documents,
extra_args=extra_args,
)
return result
except Exception:
status = 'error'
raise
finally:
duration_ms = int((time.time() - start_time) * 1000)
try:
self.requester.ap.logger.debug(
f'[Rerank] model={model.model_entity.name} docs={len(documents)} '
f'duration={duration_ms}ms status={status}'
)
except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record rerank call: {monitor_err}')
class RuntimeLLMModel:
"""运行时模型"""
@@ -284,6 +318,24 @@ class RuntimeEmbeddingModel:
self.provider = provider
class RuntimeRerankModel:
"""运行时 Rerank 模型"""
model_entity: persistence_model.RerankModel
"""模型数据"""
provider: RuntimeProvider
"""提供商实例"""
def __init__(
self,
model_entity: persistence_model.RerankModel,
provider: RuntimeProvider,
):
self.model_entity = model_entity
self.provider = provider
class ProviderAPIRequester(metaclass=abc.ABCMeta):
"""Provider API请求器"""
@@ -376,3 +428,23 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
或者 tuple[typing.List[typing.List[float]], dict]: 返回 (embedding 向量, usage_info)
"""
pass
async def invoke_rerank(
self,
model: RuntimeRerankModel,
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[dict]:
"""调用 Rerank API
Args:
model (RuntimeRerankModel): 使用的模型信息
query (str): 查询文本
documents (typing.List[str]): 待重排序的文档列表
extra_args (dict[str, typing.Any], optional): 额外的参数. Defaults to {}.
Returns:
typing.List[dict]: [{"index": int, "relevance_score": float}, ...]
"""
raise NotImplementedError('This requester does not support rerank')

View File

@@ -25,6 +25,7 @@ spec:
support_type:
- llm
- text-embedding
- rerank
provider_category: maas
execution:
python:

View File

@@ -24,6 +24,7 @@ spec:
default: 120
support_type:
- llm
- rerank
provider_category: maas
execution:
python:

View File

@@ -615,3 +615,88 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')
except openai.APIError as e:
raise errors.RequesterError(f'请求错误: {e.message}')
async def invoke_rerank(
self,
model: requester.RuntimeRerankModel,
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[dict]:
"""Standard /rerank endpoint (Jina/Cohere/SiliconFlow/Voyage/DashScope compatible)
Supports extra_args from model.extra_args:
- rerank_url: full URL override (e.g. "https://dashscope.aliyuncs.com/compatible-api/v1/reranks")
- rerank_path: path override appended to base_url (e.g. "reranks" instead of default "rerank")
- Any other fields are merged into the request payload.
"""
api_key = model.provider.token_mgr.get_token()
base_url = self.requester_cfg.get('base_url', '').rstrip('/')
timeout = self.requester_cfg.get('timeout', 120)
merged_args = {}
if model.model_entity.extra_args:
merged_args.update(model.model_entity.extra_args)
if extra_args:
merged_args.update(extra_args)
rerank_url = merged_args.pop('rerank_url', None)
rerank_path = merged_args.pop('rerank_path', 'rerank')
if not rerank_url:
rerank_url = f'{base_url}/{rerank_path}'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
}
payload = {
'model': model.model_entity.name,
'query': query,
'documents': documents[:64],
'top_n': min(len(documents), 64),
}
if merged_args:
payload.update(merged_args)
try:
async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client:
resp = await client.post(rerank_url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
results = self._parse_rerank_response(data)
if results:
scores = [r.get('relevance_score', 0.0) for r in results]
min_score = min(scores)
max_score = max(scores)
if max_score - min_score > 1e-6:
for r in results:
r['relevance_score'] = (r['relevance_score'] - min_score) / (max_score - min_score)
return results
except httpx.HTTPStatusError as e:
raise errors.RequesterError(f'Rerank request failed: {e.response.status_code} - {e.response.text}')
except httpx.TimeoutException:
raise errors.RequesterError('Rerank request timed out')
except Exception as e:
raise errors.RequesterError(f'Rerank request error: {str(e)}')
@staticmethod
def _parse_rerank_response(data: dict) -> typing.List[dict]:
"""Parse rerank response from various providers.
Handles:
- Jina/Cohere/SiliconFlow: {"results": [{"index", "relevance_score"}]}
- Voyage AI: {"data": [{"index", "relevance_score"}]}
- DashScope: {"output": {"results": [{"index", "relevance_score"}]}}
"""
if 'results' in data:
return data['results']
if 'data' in data:
return data['data']
if 'output' in data and isinstance(data['output'], dict):
return data['output'].get('results', [])
return []

View File

@@ -25,6 +25,7 @@ spec:
support_type:
- llm
- text-embedding
- rerank
provider_category: manufacturer
execution:
python:

View File

@@ -1,7 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="5" fill="#7B68EE"/>
<circle cx="12" cy="12" r="6" fill="#FF6B35"/>
<circle cx="12" cy="12" r="3" fill="#7B68EE"/>
<path d="M12 6V18" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6 12H18" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128" id="Chroma--Streamline-Svg-Logos" height="128" width="128">
<desc>
Chroma Streamline Icon: https://streamlinehq.com
</desc>
<path fill="#ffde2d" d="M84.88839999999999 104.10666666666665c23.0732 0 41.77773333333333 -17.956266666666664 41.77773333333333 -40.10653333333333 0 -22.150266666666667 -18.70453333333333 -40.10653333333333 -41.77773333333333 -40.10653333333333 -23.0732 0 -41.77773333333333 17.956266666666664 -41.77773333333333 40.10653333333333 0 22.150266666666667 18.70453333333333 40.10653333333333 41.77773333333333 40.10653333333333Z" stroke-width="1.3333"></path>
<path fill="#327eff" d="M43.111066666666666 104.10666666666665c23.0732 0 41.77773333333333 -17.956266666666664 41.77773333333333 -40.10653333333333 0 -22.150266666666667 -18.70453333333333 -40.10653333333333 -41.77773333333333 -40.10653333333333C20.037866666666666 23.8936 1.3333333333333333 41.849866666666664 1.3333333333333333 64.00013333333334 1.3333333333333333 86.15039999999999 20.037866666666666 104.10666666666665 43.111066666666666 104.10666666666665Z" stroke-width="1.3333"></path>
<path fill="#ff6446" d="M84.88866666666667 64.00013333333334c0 22.150399999999998 -18.704666666666665 40.10626666666666 -41.778 40.10626666666666V64.00013333333334h41.778Zm-41.778 0c0 -22.150266666666667 18.70453333333333 -40.10653333333333 41.778 -40.10653333333333v40.10653333333333H43.11066666666666Z" stroke-width="1.3333"></path>
</svg>

Before

Width:  |  Height:  |  Size: 413 B

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>

After

Width:  |  Height:  |  Size: 769 B

View File

@@ -0,0 +1,31 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: cohere-rerank
label:
en_US: Cohere
zh_Hans: Cohere
icon: cohere.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.cohere.com/v2
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- rerank
provider_category: manufacturer
execution:
python:
path: ./chatcmpl.py
attr: OpenAIChatCompletions

View File

@@ -25,6 +25,7 @@ spec:
support_type:
- llm
- text-embedding
- rerank
provider_category: maas
execution:
python:

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Jina</title><path d="M6.608 21.416a4.608 4.608 0 100-9.217 4.608 4.608 0 000 9.217zM20.894 2.015c.614 0 1.106.492 1.106 1.106v9.002c0 5.13-4.148 9.309-9.217 9.37v-9.355l-.03-9.032c0-.614.491-1.106 1.106-1.106h7.158l-.123.015z"></path></svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@@ -0,0 +1,31 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: jina-rerank
label:
en_US: Jina
zh_Hans: Jina
icon: jina.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.jina.ai/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- rerank
provider_category: manufacturer
execution:
python:
path: ./chatcmpl.py
attr: OpenAIChatCompletions

View File

@@ -25,6 +25,7 @@ spec:
support_type:
- llm
- text-embedding
- rerank
provider_category: maas
execution:
python:

View File

@@ -1,8 +1,17 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="5" fill="#1E3A5F"/>
<path d="M6 12C6 8.68629 8.68629 6 12 6C15.3137 6 18 8.68629 18 12" stroke="#4FC3F7" stroke-width="2" stroke-linecap="round"/>
<path d="M18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12" stroke="#81D4FA" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="12" r="2" fill="#4FC3F7"/>
<circle cx="6" cy="12" r="1.5" fill="#81D4FA"/>
<circle cx="18" cy="12" r="1.5" fill="#4FC3F7"/>
</svg>
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 334.84 76.22">
<defs>
<style>
.cls-1 {
fill: currentColor;
}
</style>
</defs>
<path class="cls-1" d="M308.56,23.63c-5.04,0-9.73,1.43-13.73,3.88V1.08l-12.56,4.61v70h12.56v-3.35c4,2.46,8.71,3.88,13.73,3.88,14.49,0,26.29-11.79,26.29-26.29s-11.79-26.29-26.29-26.29h0ZM308.56,63.88c-6.87,0-12.57-4.98-13.73-11.51v-4.91c1.16-6.54,6.88-11.51,13.73-11.51,7.7,0,13.96,6.26,13.96,13.96s-6.26,13.96-13.96,13.96Z"></path>
<path class="cls-1" d="M255.54,5.69v21.83c-4-2.46-8.71-3.88-13.73-3.88-14.49,0-26.29,11.79-26.29,26.29s11.79,26.29,26.29,26.29c5.04,0,9.73-1.43,13.73-3.88v3.35h12.56V1.08l-12.56,4.61ZM241.81,63.88c-7.7,0-13.96-6.26-13.96-13.96s6.26-13.96,13.96-13.96c6.87,0,12.57,4.98,13.73,11.51v4.91c-1.16,6.54-6.88,11.51-13.73,11.51Z"></path>
<polygon class="cls-1" points="195.35 52.2 186.65 61.17 200.64 75.62 209.32 75.62 218.01 75.62 195.35 52.2"></polygon>
<path class="cls-1" d="M167.14,4.59c.65,3.99.68,8.04.03,12.15-.03.17.16.3.31.21,3.82-2.21,7.82-3.69,12.01-4.33.12-.02.19-.13.17-.23-.68-4.13-.61-8.18-.03-12.16.02-.17-.16-.3-.31-.2-4.01,2.31-8.01,3.81-12.01,4.34-.12.01-.19.12-.17.23h0Z"></path>
<path class="cls-1" d="M198.75,24.09l-19.07,19.72v-25.57c-4.49.67-8.7,2.11-12.56,4.57v52.83h12.56v-13.87l3.78-3.9.02.02,8.68-8.97-.02-.02,23.98-24.8h-17.37Z"></path>
<path class="cls-1" d="M145.03,57.86c-2.56,4.45-7.17,7.2-12.13,7.2-5.96,0-11.3-3.96-13.32-9.85h38.87l.08-.42c.29-1.5.42-3.06.42-4.65,0-14.37-11.69-26.06-26.06-26.06s-26.06,11.69-26.06,26.06,11.69,26.06,26.06,26.06c9.63,0,18.43-5.28,22.98-13.77l.26-.49-11.1-4.08h-.01ZM132.88,35.19h.03c5.96,0,11.3,3.96,13.32,9.85h-26.67c2.02-5.89,7.36-9.85,13.32-9.85Z"></path>
<path class="cls-1" d="M75.92,65.07c-5.96,0-11.29-3.96-13.32-9.85h38.87l.08-.42c.29-1.5.42-3.06.42-4.65,0-14.37-11.69-26.06-26.06-26.06s-26.06,11.69-26.06,26.06,11.69,26.06,26.06,26.06c9.63,0,18.43-5.28,22.98-13.77l.26-.49h0l-11.1-4.08c-2.56,4.45-7.17,7.2-12.13,7.2h-.01ZM75.92,35.19h.03c5.96,0,11.29,3.96,13.32,9.85h-26.67c2.03-5.89,7.36-9.85,13.32-9.85Z"></path>
<path class="cls-1" d="M30.43,45.58l-10.2-1.91c-3.03-.56-4.98-2.25-4.98-4.33,0-1.5,1.61-4.35,7.68-4.35,5.53,0,9.36,3.5,10.25,6.26l10.9-4-.14-.42c-1.17-3.54-3.5-6.58-6.94-9.04-3.49-2.49-8.04-3.69-13.88-3.69s-10.98,1.5-14.78,4.34c-3.88,2.91-5.84,6.76-5.84,11.46,0,7.98,4.72,12.77,14.42,14.64l9.9,1.81c3.05.61,4.94,2.27,4.94,4.33,0,2.61-3.58,4.44-8.7,4.44-5.79,0-9.9-3.72-11.85-7.14L0,62.1l.14.39c1.3,3.8,3.89,7.07,7.7,9.71,3.78,2.6,8.65,3.95,14.51,3.98l.25.03c6.87,0,12.55-1.57,16.43-4.53,3.98-3.05,6-6.99,6-11.74,0-3.73-1.14-6.7-3.6-9.33-2.27-2.42-5.98-4.11-10.98-5.02h-.02Z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 569 B

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -25,6 +25,7 @@ spec:
support_type:
- llm
- text-embedding
- rerank
provider_category: maas
execution:
python:

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Voyage</title><path d="M5.407 0v.066a.974.974 0 00-.048.245c-.011.11-.016.208-.016.295 0 .339.043.715.128 1.13.097.405.274.912.531 1.524l7.125 16.366L20.011 3.39c.161-.404.333-.846.515-1.327.182-.48.273-.966.273-1.458a1.406 1.406 0 00-.096-.54V0H24v.066c-.204.207-.45.578-.74 1.114-.29.535-.606 1.195-.949 1.982L13.095 24h-1.287L3.075 3.965c-.204-.47-.418-.923-.644-1.36-.214-.437-.418-.83-.61-1.18-.194-.36-.365-.66-.515-.9A5.666 5.666 0 001 .064V0h4.407z" fill="#012E33"></path></svg>

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1,31 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: voyageai-rerank
label:
en_US: Voyage AI
zh_Hans: Voyage AI
icon: voyageai.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.voyageai.com/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- rerank
provider_category: manufacturer
execution:
python:
path: ./chatcmpl.py
attr: OpenAIChatCompletions

View File

@@ -172,6 +172,45 @@ class LocalAgentRunner(runner.RequestRunner):
if result:
all_results.extend(result)
# Rerank step: re-score results using a rerank model if configured
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
rerank_model_uuid = local_agent_config.get('rerank-model', '')
if rerank_model_uuid == '__none__':
rerank_model_uuid = ''
self.ap.logger.info(
f'Rerank config: model_uuid={rerank_model_uuid!r}, '
f'results={len(all_results)}, '
f'local_agent_keys={list(local_agent_config.keys())}'
)
if all_results and rerank_model_uuid:
try:
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
rerank_top_k = int(local_agent_config.get('rerank-top-k', 5))
doc_texts = []
for entry in all_results:
text = ' '.join(c.text for c in entry.content if c.type == 'text' and c.text)
doc_texts.append(text)
doc_texts_capped = doc_texts[:64]
scores = await rerank_model.provider.invoke_rerank(
model=rerank_model,
query=user_message_text,
documents=doc_texts_capped,
)
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
top_indices = [s['index'] for s in scored[:rerank_top_k] if s['index'] < len(all_results)]
all_results = [all_results[i] for i in top_indices]
self.ap.logger.info(
f'Rerank complete: {len(doc_texts)} docs reranked -> top {len(all_results)} kept (top_k={rerank_top_k})'
)
except ValueError:
self.ap.logger.warning(f'Rerank model {rerank_model_uuid} not found, skipping rerank')
except Exception as e:
self.ap.logger.warning(f'Rerank failed, using original order: {e}')
final_user_message_text = ''
if all_results:

View File

@@ -52,7 +52,9 @@
"content": "You are a helpful assistant."
}
],
"knowledge-bases": []
"knowledge-bases": [],
"rerank-model": "",
"rerank-top-k": 5
},
"dify-service-api": {
"base-url": "https://api.dify.ai/v1",

View File

@@ -104,6 +104,34 @@ stages:
field: __system.is_wizard
operator: neq
value: true
- name: rerank-model
label:
en_US: Rerank Model
zh_Hans: 重排序模型
description:
en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks
zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量
type: rerank-model-selector
required: false
default: ''
show_if:
field: knowledge-bases
operator: neq
value: []
- name: rerank-top-k
label:
en_US: Rerank Top K
zh_Hans: 重排序保留数量
description:
en_US: Number of top results to keep after reranking
zh_Hans: 重排序后保留的最相关结果数量
type: integer
required: false
default: 5
show_if:
field: rerank-model
operator: neq
value: ''
- name: dify-service-api
label:
en_US: Dify Service API

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

@@ -14,6 +14,7 @@ export interface IPluginCardVO {
components: PluginComponent[];
debug: boolean;
hasUpdate?: boolean;
type?: 'plugin' | 'mcp' | 'skill';
}
export class PluginCardVO implements IPluginCardVO {
@@ -30,6 +31,7 @@ export class PluginCardVO implements IPluginCardVO {
status: string;
components: PluginComponent[];
hasUpdate?: boolean;
type?: 'plugin' | 'mcp' | 'skill';
constructor(prop: IPluginCardVO) {
this.author = prop.author;
@@ -45,5 +47,6 @@ export class PluginCardVO implements IPluginCardVO {
this.install_source = prop.install_source;
this.install_info = prop.install_info;
this.hasUpdate = prop.hasUpdate;
this.type = prop.type;
}
}

View File

@@ -88,6 +88,8 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
// 转换并比较版本号
const pluginCards = installedPlugins.map((plugin) => {
const marketplaceKey = `${plugin.manifest.manifest.metadata.author}/${plugin.manifest.manifest.metadata.name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
const cardVO = new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
@@ -106,13 +108,12 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
priority: plugin.priority,
install_source: plugin.install_source,
install_info: plugin.install_info,
type: marketplacePlugin?.type,
});
// 检查是否来自市场且有更新
if (cardVO.install_source === 'marketplace') {
const marketplaceKey = `${cardVO.author}/${cardVO.name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
if (marketplacePlugin && marketplacePlugin.latest_version) {
if (cardVO.install_source === 'marketplace' && marketplacePlugin) {
if (marketplacePlugin.latest_version) {
cardVO.hasUpdate = isNewerVersion(
marketplacePlugin.latest_version,
cardVO.version,

View File

@@ -60,6 +60,24 @@ export default function PluginCardComponent({
>
v{cardVO.version}
</Badge>
{cardVO.type && (
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${
cardVO.type === 'mcp'
? 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300'
: cardVO.type === 'skill'
? 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300'
: 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300'
}`}
>
{cardVO.type === 'mcp'
? 'MCP'
: cardVO.type === 'skill'
? t('common.skill')
: t('market.typePlugin')}
</Badge>
)}
{cardVO.debug && (
<Badge
variant="outline"

View File

@@ -0,0 +1,77 @@
import { Fragment } from 'react';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function PluginComponentList({
components,
showComponentName,
showTitle,
useBadge,
t,
responsive = false,
}: {
components: Record<string, number>;
showComponentName: boolean;
showTitle: boolean;
useBadge: boolean;
t: TFunction;
responsive?: boolean;
}) {
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
KnowledgeEngine: <Book className="w-5 h-5" />,
Parser: <FileText className="w-5 h-5" />,
};
const componentKindList = Object.keys(components || {});
return (
<>
{showTitle && <div>{t('market.componentsList')}</div>}
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
return (
<Fragment key={kind}>
{useBadge && (
<Badge variant="outline" className="flex items-center gap-1">
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
</span>
) : (
showComponentName && t('market.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</Badge>
)}
{!useBadge && (
<div
className="flex flex-row items-center justify-start gap-[0.2rem]"
>
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
</span>
) : (
showComponentName && t('market.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</div>
)}
</Fragment>
);
})}
</>
)}
{componentKindList.length === 0 && <div>{t('market.noComponents')}</div>}
</>
);
}

View File

@@ -8,14 +8,23 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import {
ToggleGroup,
ToggleGroupItem,
} from '@/components/ui/toggle-group';
import {
Search,
Wrench,
AudioWaveform,
Hash,
Book,
FileText,
SlidersHorizontal,
X,
} from 'lucide-react';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
@@ -26,6 +35,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { TagsFilter } from './TagsFilter';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
@@ -55,6 +65,15 @@ function MarketPageContent({
'Parser',
];
const validTypes = ['plugin', 'mcp', 'skill'];
const extensionTypeOptions = [
{ value: 'all', label: t('market.filters.allFormats'), icon: null },
{ value: 'plugin', label: t('market.typePlugin'), icon: Wrench },
{ value: 'mcp', label: t('market.typeMCP'), icon: AudioWaveform },
{ value: 'skill', label: t('market.typeSkill'), icon: Book },
];
const [searchQuery, setSearchQuery] = useState('');
const [componentFilter, setComponentFilter] = useState<string>(() => {
const category = searchParams.get('category');
@@ -63,6 +82,14 @@ function MarketPageContent({
}
return 'all';
});
const [typeFilter, setTypeFilter] = useState<string>(() => {
const type = searchParams.get('type');
if (type && validTypes.includes(type)) {
return type;
}
return 'all';
});
const activeAdvancedFilters = typeFilter === 'all' ? 0 : 1;
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
const [tagNames, setTagNames] = useState<Record<string, string>>({});
@@ -136,9 +163,44 @@ function MarketPageContent({
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
type: plugin.type,
});
}, []);
const transformMCPToVO = useCallback((mcp: any): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: mcp.author + ' / ' + mcp.name,
author: mcp.author,
pluginName: mcp.name,
label: extractI18nObject(mcp.label),
description: extractI18nObject(mcp.description) || t('market.noDescription'),
installCount: mcp.install_count || 0,
iconURL: mcp.icon || getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name),
githubURL: mcp.repository,
version: mcp.latest_version,
components: mcp.components || {},
tags: mcp.tags || [],
type: 'mcp',
});
}, [t]);
const transformSkillToVO = useCallback((skill: any): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: skill.author + ' / ' + skill.name,
author: skill.author,
pluginName: skill.name,
label: extractI18nObject(skill.label),
description: extractI18nObject(skill.description) || t('market.noDescription'),
installCount: skill.install_count || 0,
iconURL: skill.icon || getCloudServiceClientSync().getPluginIconURL(skill.author, skill.name),
githubURL: skill.repository,
version: skill.latest_version,
components: skill.components || {},
tags: skill.tags || [],
type: 'skill',
});
}, [t]);
// 获取插件列表
const fetchPlugins = useCallback(
async (page: number, isSearch: boolean = false, reset: boolean = false) => {
@@ -152,30 +214,98 @@ function MarketPageContent({
const { sortBy, sortOrder } = getCurrentSort();
const filterValue =
componentFilter === 'all' ? undefined : componentFilter;
const query = isSearch && searchQuery.trim() ? searchQuery.trim() : '';
// Always use searchMarketplacePlugins to support component filtering and tags filtering
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
let newPlugins: PluginMarketCardVO[] = [];
let total = 0;
if (typeFilter === 'all') {
let pluginsResult: PluginMarketCardVO[] = [];
let mcpsResult: PluginMarketCardVO[] = [];
let skillsResult: PluginMarketCardVO[] = [];
let pluginsTotal = 0;
let mcpsTotal = 0;
let skillsTotal = 0;
try {
const pluginsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
'plugin',
);
pluginsResult = pluginsResponse.plugins
.filter((plugin) => {
const keys = Object.keys(plugin.components || {});
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'));
})
.map(transformToVO);
pluginsTotal = pluginsResponse.total || 0;
} catch (e) {
console.warn('Failed to fetch plugins:', e);
}
try {
const mcpsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
'mcp',
);
mcpsResult = (mcpsResponse.plugins || []).map(transformMCPToVO);
mcpsTotal = mcpsResponse.total || 0;
} catch (e) {
console.warn('Failed to fetch mcps:', e);
}
try {
const skillsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
'skill',
);
skillsResult = (skillsResponse.plugins || []).map(transformSkillToVO);
skillsTotal = skillsResponse.total || 0;
} catch (e) {
console.warn('Failed to fetch skills:', e);
}
newPlugins = [...pluginsResult, ...mcpsResult, ...skillsResult];
total = pluginsTotal + mcpsTotal + skillsTotal;
} else {
const response = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
typeFilter === 'all' ? undefined : typeFilter,
);
const data: ApiRespMarketplacePlugins = response;
const newPlugins = data.plugins
.filter((plugin) => {
// Hide plugins that only contain deprecated KnowledgeRetriever components
const keys = Object.keys(plugin.components || {});
return !(
keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')
);
})
.map(transformToVO);
const total = data.total;
const data: ApiRespMarketplacePlugins = response;
newPlugins = data.plugins
.filter((plugin) => {
const keys = Object.keys(plugin.components || {});
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'));
})
.map(transformToVO);
total = data.total;
}
if (reset || page === 1) {
setPlugins(newPlugins);
@@ -185,8 +315,8 @@ function MarketPageContent({
setTotal(total);
setHasMore(
data.plugins.length === pageSize &&
plugins.length + newPlugins.length < total,
newPlugins.length > 0 &&
(reset || page === 1 ? newPlugins.length : plugins.length + newPlugins.length) < total,
);
} catch (error) {
console.error('Failed to fetch plugins:', error);
@@ -202,8 +332,11 @@ function MarketPageContent({
selectedTags,
pageSize,
transformToVO,
transformMCPToVO,
transformSkillToVO,
plugins.length,
getCurrentSort,
typeFilter,
],
);
@@ -313,10 +446,29 @@ function MarketPageContent({
// fetchPlugins will be called by useEffect when componentFilter changes
}, []);
// Handle type filter change
const handleTypeFilterChange = useCallback((value: string) => {
setTypeFilter(value);
setCurrentPage(1);
setPlugins([]);
// Update URL query param to keep it in sync
const params = new URLSearchParams(window.location.search);
if (value === 'all') {
params.delete('type');
} else {
params.set('type', value);
}
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
}, []);
// 当排序选项或组件筛选变化时重新加载数据
useEffect(() => {
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter]);
}, [sortOption, componentFilter, typeFilter]);
// Tags 筛选变化时重新搜索
useEffect(() => {
@@ -429,9 +581,9 @@ function MarketPageContent({
<div className="h-full flex flex-col">
{/* Fixed header with search and sort controls */}
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
{/* Search box and Tags filter */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="relative w-full max-w-2xl">
{/* Search box */}
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-center gap-3">
<div className="relative w-full lg:max-w-xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('market.searchPlaceholder')}
@@ -446,7 +598,6 @@ function MarketPageContent({
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// Immediately search, clear debounce timer
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
@@ -457,90 +608,9 @@ function MarketPageContent({
/>
</div>
{/* Tags filter */}
<TagsFilter
availableTags={availableTags}
selectedTags={selectedTags}
onTagsChange={handleTagsChange}
/>
</div>
{/* Component filter and sort */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
{/* Component filter */}
<div className="flex flex-col sm:flex-row items-center gap-2 min-w-0 max-w-full">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.filterByComponent')}:
</span>
<div className="overflow-x-auto max-w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={componentFilter}
onValueChange={(value) => {
if (value) handleComponentFilterChange(value);
}}
className="justify-start flex-nowrap"
>
<ToggleGroupItem
value="all"
aria-label="All components"
className="text-xs sm:text-sm cursor-pointer"
>
{t('market.allComponents')}
</ToggleGroupItem>
<ToggleGroupItem
value="Tool"
aria-label="Tool"
className="text-xs sm:text-sm cursor-pointer"
>
<Wrench className="h-4 w-4 mr-1" />
{t('plugins.componentName.Tool')}
</ToggleGroupItem>
<ToggleGroupItem
value="Command"
aria-label="Command"
className="text-xs sm:text-sm cursor-pointer"
>
<Hash className="h-4 w-4 mr-1" />
{t('plugins.componentName.Command')}
</ToggleGroupItem>
<ToggleGroupItem
value="EventListener"
aria-label="EventListener"
className="text-xs sm:text-sm cursor-pointer"
>
<AudioWaveform className="h-4 w-4 mr-1" />
{t('plugins.componentName.EventListener')}
</ToggleGroupItem>
<ToggleGroupItem
value="KnowledgeEngine"
aria-label="KnowledgeEngine"
className="text-xs sm:text-sm cursor-pointer"
>
<Book className="h-4 w-4 mr-1" />
{t('plugins.componentName.KnowledgeEngine')}
</ToggleGroupItem>
<ToggleGroupItem
value="Parser"
aria-label="Parser"
className="text-xs sm:text-sm cursor-pointer"
>
<FileText className="h-4 w-4 mr-1" />
{t('plugins.componentName.Parser')}
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
{/* Sort dropdown */}
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
<div className="flex w-full items-center justify-end gap-2 lg:w-auto">
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
<SelectTrigger className="w-[128px] sm:w-40 text-xs sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -551,9 +621,96 @@ function MarketPageContent({
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="relative">
<SlidersHorizontal className="h-4 w-4" />
<span className="hidden sm:inline">{t('market.filters.more')}</span>
{activeAdvancedFilters > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none text-primary-foreground">
{activeAdvancedFilters}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[320px] space-y-4">
<div>
<div className="text-sm font-medium">{t('market.filters.advancedTitle')}</div>
<div className="mt-1 text-xs text-muted-foreground">
{t('market.filters.advancedDescription')}
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t('market.filters.technicalType')}
</div>
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={typeFilter}
onValueChange={(value) => {
if (value) handleTypeFilterChange(value);
}}
className="flex flex-wrap justify-start gap-2"
>
{extensionTypeOptions.map((option) => {
const Icon = option.icon;
return (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
className="cursor-pointer text-xs"
>
{Icon && <Icon className="mr-1 h-3.5 w-3.5" />}
{option.label}
</ToggleGroupItem>
);
})}
</ToggleGroup>
</div>
</PopoverContent>
</Popover>
</div>
</div>
{/* Quick tag filter buttons */}
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:justify-center sm:overflow-visible">
<Button
type="button"
variant={selectedTags.length === 0 ? 'secondary' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => handleTagsChange([])}
>
{t('market.allExtensions')}
</Button>
{availableTags.map((tag) => {
const selected = selectedTags.includes(tag.tag);
return (
<Button
key={tag.tag}
type="button"
variant={selected ? 'secondary' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => {
const newTags = selected
? selectedTags.filter((t) => t !== tag.tag)
: [...selectedTags, tag.tag];
handleTagsChange(newTags);
}}
>
{tagNames[tag.tag] || tag.tag}
{selected && <X className="h-3.5 w-3.5" />}
</Button>
);
})}
</div>
{/* Search results stats */}
{total > 0 && (
<div className="text-center text-muted-foreground text-sm">

View File

@@ -38,6 +38,7 @@ function pluginToVO(
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
type: plugin.type,
});
}

View File

@@ -1,17 +1,15 @@
import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '../PluginComponentList';
import { Badge } from '@/components/ui/badge';
import { Info, Package } from 'lucide-react';
import {
Wrench,
AudioWaveform,
Hash,
Download,
ExternalLink,
Book,
FileText,
} from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
export default function PluginMarketCardComponent({
cardVO,
@@ -23,11 +21,24 @@ export default function PluginMarketCardComponent({
tagNames?: Record<string, string>;
}) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const [visibleTags, setVisibleTags] = useState(2);
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
const isDeprecated = (() => {
if (!cardVO.components) return false;
const keys = Object.keys(cardVO.components);
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
const showTypeBadge = cardVO.type;
useEffect(() => {
setIconFailed(!cardVO.iconURL);
}, [cardVO.iconURL]);
// Measure how many tags fit in the bottom row
useEffect(() => {
const tags = cardVO.tags;
if (!bottomRef.current || !tags || tags.length === 0) return;
@@ -43,10 +54,7 @@ export default function PluginMarketCardComponent({
}
const tagWidth = 80;
const plusBadgeWidth = 40;
const maxTags = Math.max(
0,
Math.floor((availableForTags - plusBadgeWidth) / tagWidth),
);
const maxTags = Math.max(0, Math.floor((availableForTags - plusBadgeWidth) / tagWidth));
if (maxTags >= tags.length) {
setVisibleTags(tags.length);
} else {
@@ -62,51 +70,72 @@ export default function PluginMarketCardComponent({
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
function handleInstallClick(e: React.MouseEvent) {
e.stopPropagation();
if (onInstall) {
onInstall(cardVO.author, cardVO.pluginName);
}
}
function handleViewDetailsClick(e: React.MouseEvent) {
e.stopPropagation();
const detailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
window.open(detailUrl, '_blank');
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-4 h-4" />,
EventListener: <AudioWaveform className="w-4 h-4" />,
Command: <Hash className="w-4 h-4" />,
KnowledgeEngine: <Book className="w-4 h-4" />,
Parser: <FileText className="w-4 h-4" />,
};
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-3 sm:p-[1rem] hover:border-[#a1a1aa] dark:hover:border-[#3f3f46] transition-all duration-200 dark:bg-[#1f1f22] relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
<a
href={pluginDetailUrl}
target="_blank"
rel="noopener noreferrer"
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22] dark:shadow-[0px_0px_4px_0_rgba(255,255,255,0.1)] dark:hover:shadow-[0px_2px_8px_0_rgba(255,255,255,0.15)] block"
>
<div className="w-full h-full flex flex-col justify-between gap-3">
{/* 上部分:插件信息 */}
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
<img
src={cardVO.iconURL}
alt="plugin icon"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%]"
/>
<div className="w-full h-full flex flex-col justify-between">
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0 flex-1 overflow-hidden">
{iconFailed ? (
<div className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%] border bg-muted text-muted-foreground flex items-center justify-center">
<Package className="w-6 h-6 sm:w-8 sm:h-8" />
</div>
) : (
<img
src={cardVO.iconURL}
alt="plugin icon"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%] object-cover"
loading="lazy"
decoding="async"
fetchPriority="low"
onError={() => setIconFailed(true)}
/>
)}
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 overflow-hidden">
<div className="flex flex-col items-start justify-start w-full min-w-0">
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
{cardVO.pluginId}
</div>
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">{cardVO.pluginId}</div>
<div className="flex items-center gap-1.5 w-full min-w-0">
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
{cardVO.label}
</div>
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">{cardVO.label}</div>
{isDeprecated && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild onClick={(e) => e.preventDefault()}>
<Badge
variant="outline"
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
>
{t('market.deprecated')}
<Info className="w-2.5 h-2.5" />
</Badge>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{t('market.deprecatedTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{showTypeBadge && (
<Badge
variant="outline"
className={`text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 gap-0.5 ${
cardVO.type === 'mcp'
? 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300'
: cardVO.type === 'skill'
? 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300'
: 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300'
}`}
>
{cardVO.type === 'mcp'
? 'MCP'
: cardVO.type === 'skill'
? t('common.skill')
: t('market.typePlugin')}
</Badge>
)}
</div>
</div>
@@ -118,11 +147,12 @@ export default function PluginMarketCardComponent({
<div className="flex flex-row items-start justify-center gap-[0.4rem] flex-shrink-0">
{cardVO.githubURL && (
<svg
className="w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] flex-shrink-0"
className="w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] dark:hover:text-[#c0c0c0] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(cardVO.githubURL, '_blank');
}}
@@ -133,13 +163,8 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* 下部分:下载量、标签和组件列表 */}
<div
ref={bottomRef}
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
>
<div ref={bottomRef} className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden">
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
{/* 下载数量 */}
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
@@ -158,7 +183,6 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* Tags - adaptive */}
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
{cardVO.tags.slice(0, visibleTags).map((tag) => (
@@ -180,9 +204,7 @@ export default function PluginMarketCardComponent({
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
<span className="truncate max-w-[5rem]">
{tagNames[tag] || tag}
</span>
<span className="truncate max-w-[5rem]">{tagNames[tag] || tag}</span>
</Badge>
))}
{remainingTags > 0 && (
@@ -197,52 +219,20 @@ export default function PluginMarketCardComponent({
)}
</div>
{/* 组件列表 */}
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
<div className="flex flex-row items-center gap-1">
{Object.entries(cardVO.components).map(([kind, count]) => (
<Badge
key={kind}
variant="outline"
className="flex items-center gap-1"
>
{kindIconMap[kind]}
<span className="ml-1">{count}</span>
</Badge>
))}
<div className="flex flex-row items-center gap-1 flex-shrink-0">
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={false}
useBadge={true}
t={t}
responsive={false}
/>
</div>
)}
</div>
</div>
{/* Hover overlay with action buttons */}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={handleInstallClick}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<Download className="w-4 h-4" />
{t('market.install')}
</Button>
<Button
onClick={handleViewDetailsClick}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<ExternalLink className="w-4 h-4" />
{t('market.viewDetails')}
</Button>
</div>
</div>
</a>
);
}
}

View File

@@ -10,6 +10,7 @@ export interface IPluginMarketCardVO {
version: string;
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -24,6 +25,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
version: string;
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -37,5 +39,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.version = prop.version;
this.components = prop.components;
this.tags = prop.tags;
this.type = prop.type;
}
}

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

@@ -42,6 +42,7 @@ export interface PluginV4 {
latest_version: string;
components: Record<string, number>;
status: PluginV4Status;
type?: 'plugin' | 'mcp' | 'skill';
created_at: string;
updated_at: string;
}

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

@@ -38,7 +38,49 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order?: string,
component_filter?: string,
tags_filter?: string[],
type_filter?: string,
): Promise<ApiRespMarketplacePlugins> {
// Use different endpoints based on type_filter
if (type_filter === 'mcp') {
return this.post<{ mcps: PluginV4[]; total: number }>(
'/api/v1/marketplace/mcps/search',
{
query,
page,
page_size,
sort_by,
sort_order,
tags_filter,
},
).then((resp) => ({
plugins: (resp?.mcps || []).map((mcp) => ({
...mcp,
plugin_id: mcp.mcp_id || mcp.plugin_id,
type: 'mcp' as const,
})),
total: resp?.total || 0,
}));
} else if (type_filter === 'skill') {
return this.post<{ skills: PluginV4[]; total: number }>(
'/api/v1/marketplace/skills/search',
{
query,
page,
page_size,
sort_by,
sort_order,
tags_filter,
},
).then((resp) => ({
plugins: (resp?.skills || []).map((skill) => ({
...skill,
plugin_id: skill.skill_id || skill.plugin_id,
type: 'skill' as const,
})),
total: resp?.total || 0,
}));
}
return this.post<ApiRespMarketplacePlugins>(
'/api/v1/marketplace/plugins/search',
{
@@ -49,6 +91,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order,
component_filter,
tags_filter,
type_filter,
},
);
}

View File

@@ -36,6 +36,7 @@ const enUS = {
delete: 'Delete',
add: 'Add',
select: 'Select',
skill: 'Skill',
cancel: 'Cancel',
submit: 'Submit',
error: 'Error',
@@ -271,6 +272,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',
@@ -613,11 +618,24 @@ const enUS = {
markAsReadFailed: 'Mark as read failed',
filterByComponent: 'Component',
allComponents: 'All Components',
filterByType: 'Type',
allTypes: 'All Types',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Skill',
requestPlugin: 'Request Plugin',
viewDetails: 'View Details',
deprecated: 'Deprecated',
deprecatedTooltip:
'Please install the corresponding Knowledge Engine plugin.',
filters: {
allFormats: 'All Formats',
more: 'More',
advancedTitle: 'Advanced Filters',
advancedDescription: 'Filter by extension type',
technicalType: 'Technical Type',
},
allExtensions: 'All Extensions',
tags: {
filterByTags: 'Filter by Tags',
selected: 'selected',

View File

@@ -38,6 +38,7 @@ const esES = {
delete: 'Eliminar',
add: 'Añadir',
select: 'Seleccionar',
skill: 'Habilidad',
cancel: 'Cancelar',
submit: 'Enviar',
error: 'Error',
@@ -281,6 +282,11 @@ const esES = {
loadError: 'Error al cargar datos',
chat: 'Chat',
embedding: 'Embedding',
rerank: 'Reordenar',
rerankUrlTooltip:
'URL completa para el endpoint de reordenación (ej: https://dashscope.aliyuncs.com/compatible-api/v1/reranks)',
rerankPathTooltip:
'Ruta añadida a la URL base (predeterminado: rerank, algunos servicios usan reranks)',
modelsCount: '{{count}} modelo(s)',
expandModels: 'Expandir',
collapseModels: 'Contraer',
@@ -625,11 +631,24 @@ const esES = {
markAsReadFailed: 'Error al marcar como leído',
filterByComponent: 'Componente',
allComponents: 'Todos los componentes',
filterByType: 'Tipo',
allTypes: 'Todos los tipos',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Habilidad',
requestPlugin: 'Solicitar plugin',
viewDetails: 'Ver detalles',
deprecated: 'Obsoleto',
deprecatedTooltip:
'Por favor, instala el plugin de motor de conocimiento correspondiente.',
filters: {
allFormats: 'Todos los formatos',
more: 'Más',
advancedTitle: 'Filtros avanzados',
advancedDescription: 'Filtrar por tipo de extensión',
technicalType: 'Tipo técnico',
},
allExtensions: 'Todas las extensiones',
tags: {
filterByTags: 'Filtrar por etiquetas',
selected: 'seleccionadas',

View File

@@ -1,4 +1,4 @@
const jaJP = {
const jaJP = {
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
@@ -37,6 +37,7 @@
delete: '削除',
add: '追加',
select: '選択してください',
skill: 'スキル',
cancel: 'キャンセル',
submit: '送信',
error: 'エラー',
@@ -275,6 +276,11 @@
loadError: 'データの読み込みに失敗しました',
chat: 'チャット',
embedding: '埋め込み',
rerank: '再順位付け',
rerankUrlTooltip:
'再順位付けエンドポイントの完全URL例: https://dashscope.aliyuncs.com/compatible-api/v1/reranks',
rerankPathTooltip:
'ベースURLに追加するパスデフォルト: rerank、一部サービスはreranksを使用',
modelsCount: '{{count}} 個のモデル',
expandModels: '展開',
collapseModels: '折りたたむ',
@@ -617,6 +623,11 @@
markAsReadFailed: '既読に設定に失敗しました',
filterByComponent: 'コンポーネント',
allComponents: '全部コンポーネント',
filterByType: 'タイプ',
allTypes: '全部',
typePlugin: 'プラグイン',
typeMCP: 'MCP',
typeSkill: 'スキル',
requestPlugin: 'プラグインをリクエスト',
tags: {
filterByTags: 'タグで絞り込み',
@@ -625,6 +636,14 @@
clearAll: 'クリア',
noTags: 'タグがありません',
},
filters: {
allFormats: 'すべての形式',
more: 'もっと',
advancedTitle: '高度なフィルター',
advancedDescription: '拡張子タイプでフィルター',
technicalType: '技術タイプ',
},
allExtensions: 'すべての拡張機能',
viewDetails: '詳細を表示',
deprecated: '非推奨',
deprecatedTooltip:

View File

@@ -36,6 +36,7 @@ const ruRU = {
delete: 'Удалить',
add: 'Добавить',
select: 'Выбрать',
skill: 'Навык',
cancel: 'Отмена',
submit: 'Отправить',
error: 'Ошибка',
@@ -278,6 +279,11 @@ const ruRU = {
loadError: 'Не удалось загрузить данные',
chat: 'Чат',
embedding: 'Embedding',
rerank: 'Переранжирование',
rerankUrlTooltip:
'Полный URL для эндпоинта переранжирования (напр.: https://dashscope.aliyuncs.com/compatible-api/v1/reranks)',
rerankPathTooltip:
'Путь, добавляемый к базовому URL (по умолчанию: rerank, некоторые сервисы используют reranks)',
modelsCount: '{{count}} модель(ей)',
expandModels: 'Развернуть',
collapseModels: 'Свернуть',
@@ -622,11 +628,24 @@ const ruRU = {
markAsReadFailed: 'Не удалось отметить как прочитанное',
filterByComponent: 'Компонент',
allComponents: 'Все компоненты',
filterByType: 'Тип',
allTypes: 'Все типы',
typePlugin: 'Плагин',
typeMCP: 'MCP',
typeSkill: 'Навык',
requestPlugin: 'Запросить плагин',
viewDetails: 'Подробнее',
deprecated: 'Устаревший',
deprecatedTooltip:
'Пожалуйста, установите соответствующий плагин движка знаний.',
filters: {
allFormats: 'Все форматы',
more: 'Ещё',
advancedTitle: 'Расширенные фильтры',
advancedDescription: 'Фильтр по типу расширения',
technicalType: 'Технический тип',
},
allExtensions: 'Все расширения',
tags: {
filterByTags: 'Фильтр по тегам',
selected: 'выбрано',

View File

@@ -36,6 +36,7 @@ const thTH = {
delete: 'ลบ',
add: 'เพิ่ม',
select: 'เลือก',
skill: 'สกิล',
cancel: 'ยกเลิก',
submit: 'ส่ง',
error: 'ข้อผิดพลาด',
@@ -267,6 +268,11 @@ const thTH = {
loadError: 'โหลดข้อมูลล้มเหลว',
chat: 'แชท',
embedding: 'Embedding',
rerank: 'จัดลำดับใหม่',
rerankUrlTooltip:
'URL เต็มสำหรับ endpoint จัดลำดับใหม่ (เช่น: https://dashscope.aliyuncs.com/compatible-api/v1/reranks)',
rerankPathTooltip:
'พาธที่เพิ่มเข้าไปใน URL ฐาน (ค่าเริ่มต้น: rerank บางบริการใช้ reranks)',
modelsCount: '{{count}} โมเดล',
expandModels: 'ขยาย',
collapseModels: 'ย่อ',
@@ -604,10 +610,23 @@ const thTH = {
markAsReadFailed: 'ทำเครื่องหมายว่าอ่านแล้วล้มเหลว',
filterByComponent: 'ส่วนประกอบ',
allComponents: 'ส่วนประกอบทั้งหมด',
filterByType: 'ประเภท',
allTypes: 'ทุกประเภท',
typePlugin: 'ปลั๊กอิน',
typeMCP: 'MCP',
typeSkill: 'สกิล',
requestPlugin: 'ขอปลั๊กอิน',
viewDetails: 'ดูรายละเอียด',
deprecated: 'เลิกใช้แล้ว',
deprecatedTooltip: 'กรุณาติดตั้งปลั๊กอินเครื่องมือความรู้ที่เกี่ยวข้อง',
filters: {
allFormats: 'ทุกรูปแบบ',
more: 'เพิ่มเติม',
advancedTitle: 'ตัวกรองขั้นสูง',
advancedDescription: 'กรองตามประเภทส่วนขยาย',
technicalType: 'ประเภทเทคนิค',
},
allExtensions: 'ส่วนขยายทั้งหมด',
tags: {
filterByTags: 'กรองตามแท็ก',
selected: 'เลือกแล้ว',

View File

@@ -36,6 +36,7 @@ const viVN = {
delete: 'Xóa',
add: 'Thêm',
select: 'Chọn',
skill: 'Kỹ năng',
cancel: 'Hủy',
submit: 'Gửi',
error: 'Lỗi',
@@ -275,6 +276,11 @@ const viVN = {
loadError: 'Tải dữ liệu thất bại',
chat: 'Trò chuyện',
embedding: 'Embedding',
rerank: 'Sắp xếp lại',
rerankUrlTooltip:
'URL đầy đủ cho endpoint sắp xếp lại (vd: https://dashscope.aliyuncs.com/compatible-api/v1/reranks)',
rerankPathTooltip:
'Đường dẫn thêm vào URL cơ sở (mặc định: rerank, một số dịch vụ dùng reranks)',
modelsCount: '{{count}} mô hình',
expandModels: 'Mở rộng',
collapseModels: 'Thu gọn',
@@ -616,10 +622,23 @@ const viVN = {
markAsReadFailed: 'Đánh dấu đã đọc thất bại',
filterByComponent: 'Thành phần',
allComponents: 'Tất cả thành phần',
filterByType: 'Loại',
allTypes: 'Tất cả loại',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Kỹ năng',
requestPlugin: 'Yêu cầu Plugin',
viewDetails: 'Xem chi tiết',
deprecated: 'Không còn hỗ trợ',
deprecatedTooltip: 'Vui lòng cài đặt plugin Công cụ tri thức tương ứng.',
filters: {
allFormats: 'Tất cả định dạng',
more: 'Thêm',
advancedTitle: 'Bộ lọc nâng cao',
advancedDescription: 'Lọc theo loại phần mở rộng',
technicalType: 'Loại kỹ thuật',
},
allExtensions: 'Tất cả phần mở rộng',
tags: {
filterByTags: 'Lọc theo thẻ',
selected: 'đã chọn',

View File

@@ -35,6 +35,7 @@ const zhHans = {
delete: '删除',
add: '添加',
select: '请选择',
skill: '技能',
cancel: '取消',
submit: '提交',
error: '错误',
@@ -260,6 +261,10 @@ const zhHans = {
loadError: '加载数据失败',
chat: '对话',
embedding: '嵌入',
rerank: '重排序',
rerankUrlTooltip:
'重排序接口的完整 URL 覆盖(如 https://dashscope.aliyuncs.com/compatible-api/v1/reranks',
rerankPathTooltip: '添加到基础 URL 后的重排序路径默认rerank',
modelsCount: '{{count}} 个模型',
expandModels: '展开',
collapseModels: '收起',
@@ -586,6 +591,11 @@ const zhHans = {
markAsReadFailed: '标记为已读失败',
filterByComponent: '组件',
allComponents: '全部组件',
filterByType: '类型',
allTypes: '全部类型',
typePlugin: '插件',
typeMCP: 'MCP',
typeSkill: '技能',
requestPlugin: '请求插件',
tags: {
filterByTags: '按标签筛选',
@@ -594,6 +604,14 @@ const zhHans = {
clearAll: '清空',
noTags: '暂无标签',
},
filters: {
allFormats: '全部格式',
more: '更多',
advancedTitle: '高级筛选',
advancedDescription: '按扩展类型筛选',
technicalType: '技术类型',
},
allExtensions: '全部扩展',
viewDetails: '查看详情',
deprecated: '已弃用',
deprecatedTooltip: '请安装对应「知识引擎」插件',

View File

@@ -35,6 +35,7 @@ const zhHant = {
delete: '刪除',
add: '新增',
select: '請選擇',
skill: '技能',
cancel: '取消',
submit: '提交',
error: '錯誤',
@@ -259,6 +260,11 @@ const zhHant = {
loadError: '載入資料失敗',
chat: '對話',
embedding: '嵌入',
rerank: '重排序',
rerankUrlTooltip:
'完整 URL 覆蓋重排序端點例如https://dashscope.aliyuncs.com/compatible-api/v1/reranks',
rerankPathTooltip:
'附加到基礎 URL 的路徑預設rerank某些服務使用 reranks',
modelsCount: '{{count}} 個模型',
expandModels: '展開',
collapseModels: '收起',
@@ -585,6 +591,11 @@ const zhHant = {
markAsReadFailed: '標記為已讀失敗',
filterByComponent: '組件',
allComponents: '全部組件',
filterByType: '類型',
allTypes: '全部類型',
typePlugin: '插件',
typeMCP: 'MCP',
typeSkill: '技能',
requestPlugin: '請求插件',
tags: {
filterByTags: '按標籤篩選',
@@ -593,6 +604,14 @@ const zhHant = {
clearAll: '清空',
noTags: '暫無標籤',
},
filters: {
allFormats: '全部格式',
more: '更多',
advancedTitle: '高級篩選',
advancedDescription: '按擴展類型篩選',
technicalType: '技術類型',
},
allExtensions: '全部擴展',
viewDetails: '查看詳情',
deprecated: '已棄用',
deprecatedTooltip: '請安裝對應「知識引擎」插件',