Compare commits
6 Commits
copilot/bu
...
feat/add-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b3deec080 | ||
|
|
58ec377413 | ||
|
|
7c50aabe65 | ||
|
|
a8fba46040 | ||
|
|
3115d6f6dd | ||
|
|
323481d69b |
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
@@ -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 != '':
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -24,6 +24,7 @@ spec:
|
||||
default: 120
|
||||
support_type:
|
||||
- llm
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: manufacturer
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -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 |
1
src/langbot/pkg/provider/modelmgr/requesters/cohere.svg
Normal 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 |
@@ -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
|
||||
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
1
src/langbot/pkg/provider/modelmgr/requesters/jina.svg
Normal 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 |
31
src/langbot/pkg/provider/modelmgr/requesters/jinarerank.yaml
Normal 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
|
||||
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -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 |
@@ -25,6 +25,7 @@ spec:
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -38,6 +38,7 @@ function pluginToVO(
|
||||
version: plugin.latest_version,
|
||||
components: plugin.components,
|
||||
tags: plugin.tags || [],
|
||||
type: plugin.type,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: 'выбрано',
|
||||
|
||||
@@ -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: 'เลือกแล้ว',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '请安装对应「知识引擎」插件',
|
||||
|
||||
@@ -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: '請安裝對應「知識引擎」插件',
|
||||
|
||||