mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
22 Commits
v4.9.6
...
feat/add-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b3deec080 | ||
|
|
58ec377413 | ||
|
|
7c50aabe65 | ||
|
|
a8fba46040 | ||
|
|
3115d6f6dd | ||
|
|
323481d69b | ||
|
|
5a5c4295b1 | ||
|
|
88111d87ac | ||
|
|
4e5a6ee79a | ||
|
|
05c684d757 | ||
|
|
2838020580 | ||
|
|
9b34ae2db4 | ||
|
|
f8010a20eb | ||
|
|
917edb3413 | ||
|
|
10425ede34 | ||
|
|
e4b40a8fa0 | ||
|
|
0b8ab4b54b | ||
|
|
49239e0e08 | ||
|
|
aec2a30445 | ||
|
|
c8915ca964 | ||
|
|
a715eddd06 | ||
|
|
2f9c235b41 |
25
.github/workflows/check-i18n.yml
vendored
Normal file
25
.github/workflows/check-i18n.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Check i18n Keys
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
check-i18n:
|
||||
name: Check i18n Key Consistency
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Check i18n keys against en-US reference
|
||||
run: node web/scripts/check-i18n.mjs
|
||||
@@ -70,7 +70,7 @@ Plugin Runtime automatically starts each installed plugin and interacts through
|
||||
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
|
||||
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
|
||||
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
|
||||
- If you changed the definition of database entities, please update the migration file in `src/langbot/pkg/persistence/migrations/` and update the constants.py file in `src/langbot/pkg/utils/constants.py` with the new migration number.
|
||||
- LangBot uses [Alembic](https://alembic.sqlalchemy.org/) to manage database migrations, supporting both SQLite and PostgreSQL. Migration files are located in `src/langbot/pkg/persistence/alembic/versions/`. If you changed the definition of database entities (ORM models), generate a new migration script by running `uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change"` in the project root (requires `data/config.yaml` to exist). Review and edit the generated script before committing. Migrations are executed automatically on LangBot startup. For data migrations (e.g. modifying JSON field content), you need to manually add the migration code in the generated script.
|
||||
|
||||
## Some Principles
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ requires-python = ">=3.11,<4.0"
|
||||
dependencies = [
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiofiles>=24.1.0",
|
||||
"aiohttp>=3.11.18",
|
||||
"aiohttp>=3.13.4",
|
||||
"aioshutil>=1.5",
|
||||
"aiosqlite>=0.21.0",
|
||||
"anthropic>=0.51.0",
|
||||
@@ -16,7 +16,7 @@ dependencies = [
|
||||
"async-lru>=2.0.5",
|
||||
"certifi>=2025.4.26",
|
||||
"colorlog~=6.6.0",
|
||||
"cryptography>=44.0.3",
|
||||
"cryptography>=46.0.7",
|
||||
"dashscope>=1.25.10",
|
||||
"dingtalk-stream>=0.24.0",
|
||||
"discord-py>=2.5.2",
|
||||
@@ -27,7 +27,7 @@ dependencies = [
|
||||
"nakuru-project-idk>=0.0.2.1",
|
||||
"ollama>=0.4.8",
|
||||
"openai>1.0.0",
|
||||
"pillow>=11.2.1",
|
||||
"pillow>=12.2.0",
|
||||
"psutil>=7.0.0",
|
||||
"pycryptodome>=3.22.0",
|
||||
"pydantic>2.0",
|
||||
@@ -50,7 +50,7 @@ dependencies = [
|
||||
"pip>=25.1.1",
|
||||
"ruff>=0.11.9",
|
||||
"pre-commit>=4.2.0",
|
||||
"uv>=0.7.11",
|
||||
"uv>=0.11.6",
|
||||
"mypy>=1.16.0",
|
||||
"PyPDF2>=3.0.1",
|
||||
"python-docx>=1.1.0",
|
||||
@@ -61,7 +61,11 @@ dependencies = [
|
||||
"ebooklib>=0.18",
|
||||
"html2text>=2024.2.26",
|
||||
"langchain>=0.2.0",
|
||||
"langchain-text-splitters>=0.0.1",
|
||||
"langchain-core>=1.2.28",
|
||||
"langsmith>=0.7.31",
|
||||
"python-multipart>=0.0.26",
|
||||
"Mako>=1.3.11",
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
@@ -117,7 +121,7 @@ package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=4.2.0",
|
||||
"pytest>=8.4.1",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.11.9",
|
||||
|
||||
@@ -71,6 +71,11 @@ class StreamSession:
|
||||
class StreamSessionManager:
|
||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||
|
||||
# Sessions with registered feedback_ids use a longer TTL to survive the
|
||||
# full like → cancel → dislike feedback flow. Must align with the adapter's
|
||||
# _stream_to_monitoring_msg TTL (wecombot.py).
|
||||
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
|
||||
|
||||
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
|
||||
self.logger = logger
|
||||
|
||||
@@ -214,11 +219,17 @@ class StreamSessionManager:
|
||||
session.last_access = time.time()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""定期清理过期会话,防止队列与映射无上限累积。"""
|
||||
"""定期清理过期会话,防止队列与映射无上限累积。
|
||||
|
||||
已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中
|
||||
不会因为 session 被提前清除而丢失上下文信息。
|
||||
"""
|
||||
now = time.time()
|
||||
expired: list[str] = []
|
||||
for stream_id, session in self._sessions.items():
|
||||
if now - session.last_access > self.ttl:
|
||||
# Sessions with registered feedback_ids use a longer TTL
|
||||
effective_ttl = self._FEEDBACK_SESSION_TTL if session.feedback_id else self.ttl
|
||||
if now - session.last_access > effective_ttl:
|
||||
expired.append(stream_id)
|
||||
|
||||
for stream_id in expired:
|
||||
|
||||
@@ -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
|
||||
@@ -43,3 +45,12 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
||||
return self.success()
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
|
||||
@self.route('/<provider_uuid>/scan-models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(provider_uuid: str) -> str:
|
||||
try:
|
||||
model_type = quart.request.args.get('type')
|
||||
result = await self.ap.provider_service.scan_provider_models(provider_uuid, model_type)
|
||||
return self.success(data=result)
|
||||
except ValueError as e:
|
||||
return self.http_status(400, -1, str(e))
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import traceback
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
@@ -97,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
|
||||
@@ -121,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"""
|
||||
@@ -164,3 +180,66 @@ class ModelProviderService:
|
||||
.values(api_keys=[api_key])
|
||||
)
|
||||
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
|
||||
|
||||
async def scan_provider_models(self, provider_uuid: str, model_type: str | None = None) -> dict:
|
||||
provider = await self.get_provider(provider_uuid)
|
||||
if provider is None:
|
||||
raise ValueError('provider not found')
|
||||
|
||||
runtime_provider = await self.ap.model_mgr.load_provider(provider)
|
||||
|
||||
try:
|
||||
scan_result = await runtime_provider.requester.scan_models(
|
||||
runtime_provider.token_mgr.get_token() if runtime_provider.token_mgr.tokens else None
|
||||
)
|
||||
except NotImplementedError:
|
||||
raise ValueError('current provider does not support model scanning')
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Failed to scan models for provider {provider_uuid}: {exc}\n{traceback.format_exc()}'
|
||||
)
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
if isinstance(scan_result, dict):
|
||||
scanned_models = scan_result.get('models', [])
|
||||
debug_info = scan_result.get('debug')
|
||||
else:
|
||||
scanned_models = scan_result
|
||||
debug_info = None
|
||||
|
||||
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
|
||||
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
|
||||
existing_llm_names = {model['name'] for model in llm_models}
|
||||
existing_embedding_names = {model['name'] for model in embedding_models}
|
||||
|
||||
filtered_models = []
|
||||
for model in scanned_models:
|
||||
scanned_type = model.get('type', 'llm')
|
||||
if model_type and scanned_type != model_type:
|
||||
continue
|
||||
|
||||
model_name = model.get('name') or model.get('id')
|
||||
if not model_name:
|
||||
continue
|
||||
|
||||
filtered_models.append(
|
||||
{
|
||||
'id': model.get('id', model_name),
|
||||
'name': model_name,
|
||||
'type': scanned_type,
|
||||
'abilities': model.get('abilities', []),
|
||||
'display_name': model.get('display_name'),
|
||||
'description': model.get('description'),
|
||||
'context_length': model.get('context_length'),
|
||||
'owned_by': model.get('owned_by'),
|
||||
'input_modalities': model.get('input_modalities', []),
|
||||
'output_modalities': model.get('output_modalities', []),
|
||||
'already_added': (
|
||||
model_name in existing_embedding_names
|
||||
if scanned_type == 'embedding'
|
||||
else model_name in existing_llm_names
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {'models': filtered_models, 'debug': debug_info}
|
||||
|
||||
@@ -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')
|
||||
@@ -297,6 +297,9 @@ class RuntimePipeline:
|
||||
)
|
||||
# Store message_id in query variables for LLM call monitoring
|
||||
query.variables['_monitoring_message_id'] = message_id
|
||||
# Notify adapter so it can map platform-specific IDs to monitoring message ID
|
||||
if hasattr(query.adapter, 'on_monitoring_message_created'):
|
||||
await query.adapter.on_monitoring_message_created(query, message_id)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to record query start: {e}')
|
||||
|
||||
|
||||
@@ -787,6 +787,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||
|
||||
# Monitoring message ID mapping for feedback correlation
|
||||
# Temp: user Lark message ID → monitoring_message_id (populated by on_monitoring_message_created, consumed by create_message_card)
|
||||
pending_monitoring_msg: dict[str, str]
|
||||
# Final: reply Lark message ID → (monitoring_message_id, timestamp) (used by feedback callbacks)
|
||||
reply_to_monitoring_msg: dict[str, tuple[str, float]]
|
||||
_MONITORING_MAPPING_TTL = 600 # 10 minutes
|
||||
|
||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||
bot_uuid: str = None # 机器人UUID
|
||||
app_ticket: str = None # 商店应用用到
|
||||
@@ -833,6 +840,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
else:
|
||||
session_id = None
|
||||
|
||||
# Resolve monitoring message ID from reply message mapping
|
||||
monitoring_msg_id = None
|
||||
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
|
||||
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
|
||||
|
||||
feedback_event = platform_events.FeedbackEvent(
|
||||
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
|
||||
feedback_type=feedback_type,
|
||||
@@ -840,6 +852,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
message_id=open_message_id,
|
||||
stream_id=monitoring_msg_id,
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@@ -878,6 +891,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
logger=logger,
|
||||
lark_tenant_key=config.get('lark_tenant_key', ''),
|
||||
card_id_dict={},
|
||||
pending_monitoring_msg={},
|
||||
reply_to_monitoring_msg={},
|
||||
seq=1,
|
||||
listeners={},
|
||||
quart_app=quart_app,
|
||||
@@ -1018,6 +1033,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
is_stream = True
|
||||
return is_stream
|
||||
|
||||
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
|
||||
"""Called by pipeline after monitoring message is created, to map user message ID to monitoring message ID."""
|
||||
try:
|
||||
user_msg_id = query.message_event.message_chain.message_id
|
||||
if user_msg_id:
|
||||
self.pending_monitoring_msg[user_msg_id] = monitoring_message_id
|
||||
except Exception as e:
|
||||
await self.logger.debug(f'Failed to map message to monitoring message: {e}')
|
||||
|
||||
def _cleanup_monitoring_mapping(self):
|
||||
"""Remove entries older than TTL from the reply-to-monitoring mapping."""
|
||||
now = time.time()
|
||||
expired = [k for k, (_, ts) in self.reply_to_monitoring_msg.items() if now - ts > self._MONITORING_MAPPING_TTL]
|
||||
for k in expired:
|
||||
del self.reply_to_monitoring_msg[k]
|
||||
|
||||
async def create_card_id(self, message_id):
|
||||
try:
|
||||
# self.logger.debug('飞书支持stream输出,创建卡片......')
|
||||
@@ -1257,6 +1288,18 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
raise Exception(
|
||||
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
|
||||
# Transfer monitoring message mapping: user msg ID → reply msg ID
|
||||
try:
|
||||
user_msg_id = event.message_chain.message_id
|
||||
reply_msg_id = getattr(response.data, 'message_id', None)
|
||||
monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, None)
|
||||
if reply_msg_id and monitoring_msg_id:
|
||||
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
|
||||
self._cleanup_monitoring_mapping()
|
||||
except Exception as e:
|
||||
asyncio.create_task(self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}'))
|
||||
|
||||
return True
|
||||
|
||||
async def reply_message(
|
||||
@@ -1567,6 +1610,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
else:
|
||||
session_id = None
|
||||
|
||||
# Resolve monitoring message ID from reply message mapping
|
||||
monitoring_msg_id = None
|
||||
if open_message_id and open_message_id in self.reply_to_monitoring_msg:
|
||||
monitoring_msg_id = self.reply_to_monitoring_msg[open_message_id][0]
|
||||
|
||||
feedback_event = platform_events.FeedbackEvent(
|
||||
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
|
||||
feedback_type=feedback_type,
|
||||
@@ -1574,6 +1622,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
message_id=open_message_id,
|
||||
stream_id=monitoring_msg_id,
|
||||
source_platform_object=data,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import datetime
|
||||
@@ -293,6 +294,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
_ws_mode: bool = False
|
||||
bot_name: str = ''
|
||||
listeners: dict = {}
|
||||
_stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp)
|
||||
_STREAM_MAPPING_TTL = 600 # 10 minutes
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
enable_webhook = config.get('enable-webhook', False)
|
||||
@@ -329,8 +332,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot_account_id=bot_account_id,
|
||||
bot_name=bot_name,
|
||||
event_converter=event_converter,
|
||||
listeners={},
|
||||
_stream_to_monitoring_msg={},
|
||||
)
|
||||
self.listeners = {}
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
@@ -422,6 +426,23 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
|
||||
"""Called by pipeline after monitoring message is created, to map stream_id to monitoring message ID."""
|
||||
try:
|
||||
stream_id = query.message_event.source_platform_object.stream_id
|
||||
if stream_id:
|
||||
self._stream_to_monitoring_msg[stream_id] = (monitoring_message_id, time.time())
|
||||
self._cleanup_stream_mapping()
|
||||
except Exception as e:
|
||||
await self.logger.debug(f'Failed to map stream_id to monitoring message: {e}')
|
||||
|
||||
def _cleanup_stream_mapping(self):
|
||||
"""Remove entries older than TTL from the stream_id to monitoring message mapping."""
|
||||
now = time.time()
|
||||
expired = [k for k, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
|
||||
for k in expired:
|
||||
del self._stream_to_monitoring_msg[k]
|
||||
|
||||
async def _on_feedback(self, **kwargs):
|
||||
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
|
||||
try:
|
||||
@@ -447,6 +468,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
message_id = session.msg_id
|
||||
stream_id = session.stream_id
|
||||
|
||||
# Resolve stream_id to LangBot monitoring message ID if available
|
||||
monitoring_msg_id = None
|
||||
if stream_id and stream_id in self._stream_to_monitoring_msg:
|
||||
monitoring_msg_id = self._stream_to_monitoring_msg[stream_id][0]
|
||||
|
||||
await self.logger.info(
|
||||
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
|
||||
f'session_id={session_id}, user_id={user_id}, message_id={message_id}'
|
||||
@@ -460,7 +486,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
message_id=message_id,
|
||||
stream_id=stream_id,
|
||||
stream_id=monitoring_msg_id or stream_id,
|
||||
source_platform_object=session,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -227,7 +264,8 @@ class ModelManager:
|
||||
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
||||
|
||||
requester_inst = self.requester_dict[provider_entity.requester](
|
||||
ap=self.ap, config={'base_url': provider_entity.base_url}
|
||||
ap=self.ap,
|
||||
config={'base_url': provider_entity.base_url},
|
||||
)
|
||||
await requester_inst.initialize()
|
||||
|
||||
@@ -268,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
|
||||
@@ -304,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', {})
|
||||
@@ -351,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:
|
||||
@@ -359,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:
|
||||
@@ -367,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:
|
||||
@@ -381,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请求器"""
|
||||
|
||||
@@ -303,6 +355,14 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
|
||||
async def initialize(self):
|
||||
pass
|
||||
|
||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any] | list[dict[str, typing.Any]]:
|
||||
"""Scan models supported by the provider.
|
||||
|
||||
The default implementation does not support scanning. Requesters that
|
||||
can enumerate remote models should override this method.
|
||||
"""
|
||||
raise NotImplementedError('This provider does not support model scanning')
|
||||
|
||||
@abc.abstractmethod
|
||||
async def invoke_llm(
|
||||
self,
|
||||
@@ -368,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:
|
||||
|
||||
@@ -31,6 +31,192 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
|
||||
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
||||
)
|
||||
|
||||
def _mask_api_key(self, api_key: str | None) -> str:
|
||||
if not api_key:
|
||||
return ''
|
||||
if len(api_key) <= 8:
|
||||
return '****'
|
||||
return f'{api_key[:4]}...{api_key[-4:]}'
|
||||
|
||||
def _infer_model_type(self, model_id: str) -> str:
|
||||
normalized_model_id = (model_id or '').lower()
|
||||
embedding_keywords = (
|
||||
'embedding',
|
||||
'embed',
|
||||
'bge-',
|
||||
'e5-',
|
||||
'm3e',
|
||||
'gte-',
|
||||
'multilingual-e5',
|
||||
'text-embedding',
|
||||
)
|
||||
return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm'
|
||||
|
||||
def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]:
|
||||
normalized_model_id = (model_id or '').lower()
|
||||
abilities: set[str] = set()
|
||||
|
||||
def _flatten(value: typing.Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value.lower()]
|
||||
if isinstance(value, dict):
|
||||
flattened: list[str] = []
|
||||
for nested_value in value.values():
|
||||
flattened.extend(_flatten(nested_value))
|
||||
return flattened
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
flattened: list[str] = []
|
||||
for nested_value in value:
|
||||
flattened.extend(_flatten(nested_value))
|
||||
return flattened
|
||||
return [str(value).lower()]
|
||||
|
||||
capability_tokens = _flatten(item.get('capabilities'))
|
||||
capability_tokens.extend(_flatten(item.get('modalities')))
|
||||
capability_tokens.extend(_flatten(item.get('input_modalities')))
|
||||
capability_tokens.extend(_flatten(item.get('output_modalities')))
|
||||
capability_tokens.extend(_flatten(item.get('supported_generation_methods')))
|
||||
capability_tokens.extend(_flatten(item.get('supported_parameters')))
|
||||
capability_tokens.extend(_flatten(item.get('architecture')))
|
||||
|
||||
combined_tokens = capability_tokens + [normalized_model_id]
|
||||
|
||||
vision_keywords = (
|
||||
'vision',
|
||||
'image',
|
||||
'file',
|
||||
'video',
|
||||
'multimodal',
|
||||
'vl',
|
||||
'ocr',
|
||||
'omni',
|
||||
)
|
||||
function_call_keywords = (
|
||||
'function',
|
||||
'tool',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'tool_call',
|
||||
'tool-use',
|
||||
'tool_use',
|
||||
)
|
||||
|
||||
if any(any(keyword in token for keyword in vision_keywords) for token in combined_tokens):
|
||||
abilities.add('vision')
|
||||
|
||||
if any(any(keyword in token for keyword in function_call_keywords) for token in combined_tokens):
|
||||
abilities.add('func_call')
|
||||
|
||||
return sorted(abilities)
|
||||
|
||||
def _normalize_modalities(self, value: typing.Any) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
|
||||
def _collect(item: typing.Any):
|
||||
if item is None:
|
||||
return
|
||||
if isinstance(item, str):
|
||||
for part in item.replace('->', ',').replace('+', ',').split(','):
|
||||
token = part.strip().lower()
|
||||
if token and token not in normalized:
|
||||
normalized.append(token)
|
||||
return
|
||||
if isinstance(item, dict):
|
||||
for nested in item.values():
|
||||
_collect(nested)
|
||||
return
|
||||
if isinstance(item, (list, tuple, set)):
|
||||
for nested in item:
|
||||
_collect(nested)
|
||||
return
|
||||
|
||||
_collect(value)
|
||||
return normalized
|
||||
|
||||
def _extract_scan_metadata(self, item: dict[str, typing.Any], model_id: str) -> dict[str, typing.Any]:
|
||||
display_name = item.get('name')
|
||||
if not isinstance(display_name, str) or not display_name.strip() or display_name == model_id:
|
||||
display_name = ''
|
||||
|
||||
description = item.get('description')
|
||||
if not isinstance(description, str) or not description.strip():
|
||||
description = ''
|
||||
|
||||
context_length = item.get('context_length')
|
||||
if context_length is None and isinstance(item.get('top_provider'), dict):
|
||||
context_length = item['top_provider'].get('context_length')
|
||||
|
||||
if not isinstance(context_length, int):
|
||||
try:
|
||||
context_length = int(context_length) if context_length is not None else None
|
||||
except (TypeError, ValueError):
|
||||
context_length = None
|
||||
|
||||
input_modalities = self._normalize_modalities(item.get('input_modalities'))
|
||||
output_modalities = self._normalize_modalities(item.get('output_modalities'))
|
||||
|
||||
if isinstance(item.get('architecture'), dict):
|
||||
if not input_modalities:
|
||||
input_modalities = self._normalize_modalities(item['architecture'].get('input_modalities'))
|
||||
if not output_modalities:
|
||||
output_modalities = self._normalize_modalities(item['architecture'].get('output_modalities'))
|
||||
|
||||
owned_by = item.get('owned_by')
|
||||
if not isinstance(owned_by, str) or not owned_by.strip():
|
||||
owned_by = ''
|
||||
|
||||
return {
|
||||
'display_name': display_name or None,
|
||||
'description': description or None,
|
||||
'context_length': context_length,
|
||||
'owned_by': owned_by or None,
|
||||
'input_modalities': input_modalities,
|
||||
'output_modalities': output_modalities,
|
||||
}
|
||||
|
||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/models'
|
||||
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
|
||||
models = []
|
||||
for item in payload.get('data', []):
|
||||
model_id = item.get('id')
|
||||
if not model_id:
|
||||
continue
|
||||
models.append(
|
||||
{
|
||||
'id': model_id,
|
||||
'name': model_id,
|
||||
'type': self._infer_model_type(model_id),
|
||||
'abilities': self._infer_model_abilities(item, model_id),
|
||||
**self._extract_scan_metadata(item, model_id),
|
||||
}
|
||||
)
|
||||
|
||||
models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
|
||||
return {
|
||||
'models': models,
|
||||
'debug': {
|
||||
'request': {
|
||||
'method': 'GET',
|
||||
'url': models_url,
|
||||
'headers': {
|
||||
'Authorization': f'Bearer {self._mask_api_key(api_key)}' if api_key else '',
|
||||
},
|
||||
},
|
||||
'response': payload,
|
||||
},
|
||||
}
|
||||
|
||||
async def _req(
|
||||
self,
|
||||
args: dict,
|
||||
@@ -429,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:
|
||||
|
||||
8
src/langbot/pkg/provider/modelmgr/requesters/chroma.svg
Normal file
8
src/langbot/pkg/provider/modelmgr/requesters/chroma.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
61
src/langbot/pkg/provider/modelmgr/requesters/chromaembed.py
Normal file
61
src/langbot/pkg/provider/modelmgr/requesters/chromaembed.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .. import requester
|
||||
|
||||
REQUESTER_NAME: str = 'chroma-embedding'
|
||||
|
||||
|
||||
class ChromaEmbedding(requester.ProviderAPIRequester):
|
||||
"""Chroma built-in embedding requester.
|
||||
|
||||
Uses chromadb's DefaultEmbeddingFunction (all-MiniLM-L6-v2).
|
||||
The embedding function runs locally using ONNX Runtime.
|
||||
"""
|
||||
|
||||
default_config: dict[str, typing.Any] = {
|
||||
'base_url': '',
|
||||
}
|
||||
|
||||
_embedding_function = None
|
||||
|
||||
async def initialize(self):
|
||||
try:
|
||||
from chromadb.utils import embedding_functions
|
||||
except ImportError:
|
||||
raise ImportError('chromadb is not installed. Install it with: pip install chromadb')
|
||||
|
||||
self._embedding_function = embedding_functions.DefaultEmbeddingFunction()
|
||||
|
||||
async def invoke_llm(
|
||||
self,
|
||||
query,
|
||||
model: requester.RuntimeLLMModel,
|
||||
messages: typing.List,
|
||||
funcs: typing.List = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
remove_think: bool = False,
|
||||
):
|
||||
raise NotImplementedError('Chroma embedding does not support LLM inference')
|
||||
|
||||
async def invoke_embedding(
|
||||
self,
|
||||
model: requester.RuntimeEmbeddingModel,
|
||||
input_text: typing.List[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> typing.List[typing.List[float]]:
|
||||
"""Generate embeddings using Chroma's DefaultEmbeddingFunction."""
|
||||
if self._embedding_function is None:
|
||||
await self.initialize()
|
||||
|
||||
try:
|
||||
result = self._embedding_function(input_text)
|
||||
# DefaultEmbeddingFunction returns list of ndarray, convert for JSON
|
||||
if isinstance(result, list):
|
||||
return [item.tolist() if hasattr(item, 'tolist') else item for item in result]
|
||||
return result.tolist() if hasattr(result, 'tolist') else result
|
||||
except Exception as e:
|
||||
from .. import errors
|
||||
|
||||
raise errors.RequesterError(f'Chroma embedding failed: {str(e)}')
|
||||
@@ -0,0 +1,21 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: chroma-embedding
|
||||
label:
|
||||
en_US: Chroma Embedding
|
||||
zh_Hans: Chroma 嵌入
|
||||
description:
|
||||
en_US: Chroma built-in embedding model (all-MiniLM-L6-v2), runs locally using ONNX Runtime. First-time use will download model files automatically.
|
||||
zh_Hans: 使用 Chroma 内置嵌入模型 (all-MiniLM-L6-v2),基于 ONNX Runtime 本地运行。首次使用时将自动下载模型文件。
|
||||
ja_JP: Chroma 組み込み埋め込みモデル (all-MiniLM-L6-v2) を使用します。ONNX Runtime でローカル実行。初回使用時にモデルファイルが自動ダウンロードされます。
|
||||
icon: chroma.svg
|
||||
spec:
|
||||
config: []
|
||||
support_type:
|
||||
- text-embedding
|
||||
provider_category: builtin
|
||||
execution:
|
||||
python:
|
||||
path: ./chromaembed.py
|
||||
attr: ChromaEmbedding
|
||||
1
src/langbot/pkg/provider/modelmgr/requesters/cohere.svg
Normal file
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
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import httpx
|
||||
|
||||
from . import chatcmpl
|
||||
|
||||
@@ -20,6 +21,68 @@ class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
||||
models_url = 'https://generativelanguage.googleapis.com/v1beta/models'
|
||||
params = {'key': api_key} if api_key else {}
|
||||
|
||||
all_models: list[dict[str, typing.Any]] = []
|
||||
next_page_token = ''
|
||||
last_payload: dict[str, typing.Any] = {}
|
||||
|
||||
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
|
||||
while True:
|
||||
request_params = dict(params)
|
||||
if next_page_token:
|
||||
request_params['pageToken'] = next_page_token
|
||||
|
||||
response = await client.get(models_url, params=request_params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
last_payload = payload
|
||||
|
||||
for item in payload.get('models', []):
|
||||
model_name = item.get('name', '')
|
||||
model_id = model_name.replace('models/', '', 1)
|
||||
if not model_id:
|
||||
continue
|
||||
|
||||
supported_methods = item.get('supportedGenerationMethods', []) or []
|
||||
if 'embedContent' in supported_methods and 'generateContent' not in supported_methods:
|
||||
model_type = 'embedding'
|
||||
else:
|
||||
model_type = 'llm'
|
||||
|
||||
all_models.append(
|
||||
{
|
||||
'id': model_id,
|
||||
'name': model_id,
|
||||
'type': model_type,
|
||||
'abilities': self._infer_model_abilities(item, model_id),
|
||||
'display_name': item.get('displayName') or None,
|
||||
'description': item.get('description') or None,
|
||||
'context_length': item.get('inputTokenLimit'),
|
||||
'input_modalities': self._normalize_modalities(item.get('inputModalities')),
|
||||
'output_modalities': self._normalize_modalities(item.get('outputModalities')),
|
||||
}
|
||||
)
|
||||
|
||||
next_page_token = payload.get('nextPageToken', '')
|
||||
if not next_page_token:
|
||||
break
|
||||
|
||||
all_models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
|
||||
return {
|
||||
'models': all_models,
|
||||
'debug': {
|
||||
'request': {
|
||||
'method': 'GET',
|
||||
'url': models_url,
|
||||
'query': {'key': self._mask_api_key(api_key)} if api_key else {},
|
||||
},
|
||||
'response': last_payload,
|
||||
},
|
||||
}
|
||||
|
||||
async def _closure_stream(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
|
||||
@@ -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
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
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
|
||||
@@ -31,6 +31,175 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
|
||||
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
|
||||
)
|
||||
|
||||
def _mask_api_key(self, api_key: str | None) -> str:
|
||||
if not api_key:
|
||||
return ''
|
||||
if len(api_key) <= 8:
|
||||
return '****'
|
||||
return f'{api_key[:4]}...{api_key[-4:]}'
|
||||
|
||||
def _infer_model_type(self, model_id: str) -> str:
|
||||
normalized_model_id = (model_id or '').lower()
|
||||
embedding_keywords = (
|
||||
'embedding',
|
||||
'embed',
|
||||
'bge-',
|
||||
'e5-',
|
||||
'm3e',
|
||||
'gte-',
|
||||
'multilingual-e5',
|
||||
'text-embedding',
|
||||
)
|
||||
return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm'
|
||||
|
||||
def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]:
|
||||
normalized_model_id = (model_id or '').lower()
|
||||
abilities: set[str] = set()
|
||||
|
||||
def _flatten(value: typing.Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value.lower()]
|
||||
if isinstance(value, dict):
|
||||
flattened: list[str] = []
|
||||
for nested_value in value.values():
|
||||
flattened.extend(_flatten(nested_value))
|
||||
return flattened
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
flattened: list[str] = []
|
||||
for nested_value in value:
|
||||
flattened.extend(_flatten(nested_value))
|
||||
return flattened
|
||||
return [str(value).lower()]
|
||||
|
||||
capability_tokens = _flatten(item.get('capabilities'))
|
||||
capability_tokens.extend(_flatten(item.get('modalities')))
|
||||
capability_tokens.extend(_flatten(item.get('input_modalities')))
|
||||
capability_tokens.extend(_flatten(item.get('output_modalities')))
|
||||
capability_tokens.extend(_flatten(item.get('supported_generation_methods')))
|
||||
capability_tokens.extend(_flatten(item.get('supported_parameters')))
|
||||
capability_tokens.extend(_flatten(item.get('architecture')))
|
||||
|
||||
combined_tokens = capability_tokens + [normalized_model_id]
|
||||
|
||||
vision_keywords = ('vision', 'image', 'file', 'video', 'multimodal', 'vl', 'ocr', 'omni')
|
||||
function_call_keywords = ('function', 'tool', 'tools', 'tool_choice', 'tool_call', 'tool-use', 'tool_use')
|
||||
|
||||
if any(any(keyword in token for keyword in vision_keywords) for token in combined_tokens):
|
||||
abilities.add('vision')
|
||||
|
||||
if any(any(keyword in token for keyword in function_call_keywords) for token in combined_tokens):
|
||||
abilities.add('func_call')
|
||||
|
||||
return sorted(abilities)
|
||||
|
||||
def _normalize_modalities(self, value: typing.Any) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
|
||||
def _collect(item: typing.Any):
|
||||
if item is None:
|
||||
return
|
||||
if isinstance(item, str):
|
||||
for part in item.replace('->', ',').replace('+', ',').split(','):
|
||||
token = part.strip().lower()
|
||||
if token and token not in normalized:
|
||||
normalized.append(token)
|
||||
return
|
||||
if isinstance(item, dict):
|
||||
for nested in item.values():
|
||||
_collect(nested)
|
||||
return
|
||||
if isinstance(item, (list, tuple, set)):
|
||||
for nested in item:
|
||||
_collect(nested)
|
||||
return
|
||||
|
||||
_collect(value)
|
||||
return normalized
|
||||
|
||||
def _extract_scan_metadata(self, item: dict[str, typing.Any], model_id: str) -> dict[str, typing.Any]:
|
||||
display_name = item.get('name')
|
||||
if not isinstance(display_name, str) or not display_name.strip() or display_name == model_id:
|
||||
display_name = ''
|
||||
|
||||
description = item.get('description')
|
||||
if not isinstance(description, str) or not description.strip():
|
||||
description = ''
|
||||
|
||||
context_length = item.get('context_length')
|
||||
if context_length is None and isinstance(item.get('top_provider'), dict):
|
||||
context_length = item['top_provider'].get('context_length')
|
||||
|
||||
if not isinstance(context_length, int):
|
||||
try:
|
||||
context_length = int(context_length) if context_length is not None else None
|
||||
except (TypeError, ValueError):
|
||||
context_length = None
|
||||
|
||||
input_modalities = self._normalize_modalities(item.get('input_modalities'))
|
||||
output_modalities = self._normalize_modalities(item.get('output_modalities'))
|
||||
|
||||
if isinstance(item.get('architecture'), dict):
|
||||
if not input_modalities:
|
||||
input_modalities = self._normalize_modalities(item['architecture'].get('input_modalities'))
|
||||
if not output_modalities:
|
||||
output_modalities = self._normalize_modalities(item['architecture'].get('output_modalities'))
|
||||
|
||||
owned_by = item.get('owned_by')
|
||||
if not isinstance(owned_by, str) or not owned_by.strip():
|
||||
owned_by = ''
|
||||
|
||||
return {
|
||||
'display_name': display_name or None,
|
||||
'description': description or None,
|
||||
'context_length': context_length,
|
||||
'owned_by': owned_by or None,
|
||||
'input_modalities': input_modalities,
|
||||
'output_modalities': output_modalities,
|
||||
}
|
||||
|
||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/models'
|
||||
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
|
||||
models = []
|
||||
for item in payload.get('data', []):
|
||||
model_id = item.get('id')
|
||||
if not model_id:
|
||||
continue
|
||||
models.append(
|
||||
{
|
||||
'id': model_id,
|
||||
'name': model_id,
|
||||
'type': self._infer_model_type(model_id),
|
||||
'abilities': self._infer_model_abilities(item, model_id),
|
||||
**self._extract_scan_metadata(item, model_id),
|
||||
}
|
||||
)
|
||||
|
||||
models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
|
||||
return {
|
||||
'models': models,
|
||||
'debug': {
|
||||
'request': {
|
||||
'method': 'GET',
|
||||
'url': models_url,
|
||||
'headers': {
|
||||
'Authorization': f'Bearer {self._mask_api_key(api_key)}' if api_key else '',
|
||||
},
|
||||
},
|
||||
'response': payload,
|
||||
},
|
||||
}
|
||||
|
||||
async def _req(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
|
||||
@@ -8,6 +8,7 @@ import uuid
|
||||
import json
|
||||
|
||||
import ollama
|
||||
import httpx
|
||||
|
||||
from .. import errors, requester
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
@@ -31,6 +32,60 @@ class OllamaChatCompletions(requester.ProviderAPIRequester):
|
||||
os.environ['OLLAMA_HOST'] = self.requester_cfg['base_url']
|
||||
self.client = ollama.AsyncClient(timeout=self.requester_cfg['timeout'])
|
||||
|
||||
def _infer_model_type(self, model_id: str) -> str:
|
||||
normalized_model_id = (model_id or '').lower()
|
||||
embedding_keywords = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
|
||||
return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm'
|
||||
|
||||
def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]:
|
||||
normalized_model_id = (model_id or '').lower()
|
||||
abilities: set[str] = set()
|
||||
details = item.get('details', {}) or {}
|
||||
families = details.get('families', []) or []
|
||||
tokens = [normalized_model_id, str(details.get('family', '')).lower()]
|
||||
tokens.extend(str(family).lower() for family in families)
|
||||
|
||||
if any(keyword in token for token in tokens for keyword in ('vision', 'vl', 'omni', 'llava', 'ocr')):
|
||||
abilities.add('vision')
|
||||
if any(keyword in token for token in tokens for keyword in ('tool', 'function')):
|
||||
abilities.add('func_call')
|
||||
return sorted(abilities)
|
||||
|
||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
||||
del api_key
|
||||
models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/api/tags'
|
||||
|
||||
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
|
||||
response = await client.get(models_url)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
|
||||
models: list[dict[str, typing.Any]] = []
|
||||
for item in payload.get('models', []):
|
||||
model_id = item.get('model') or item.get('name')
|
||||
if not model_id:
|
||||
continue
|
||||
models.append(
|
||||
{
|
||||
'id': model_id,
|
||||
'name': item.get('name', model_id),
|
||||
'type': self._infer_model_type(model_id),
|
||||
'abilities': self._infer_model_abilities(item, model_id),
|
||||
}
|
||||
)
|
||||
|
||||
models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
|
||||
return {
|
||||
'models': models,
|
||||
'debug': {
|
||||
'request': {
|
||||
'method': 'GET',
|
||||
'url': models_url,
|
||||
},
|
||||
'response': payload,
|
||||
},
|
||||
}
|
||||
|
||||
async def _req(
|
||||
self,
|
||||
args: dict,
|
||||
@@ -104,6 +159,21 @@ class OllamaChatCompletions(requester.ProviderAPIRequester):
|
||||
|
||||
return ret_msg
|
||||
|
||||
async def _prepare_messages(
|
||||
self,
|
||||
messages: typing.List[provider_message.Message],
|
||||
) -> list[dict]:
|
||||
"""Prepare messages for Ollama API request."""
|
||||
req_messages: list = []
|
||||
for m in messages:
|
||||
msg_dict: dict = m.dict(exclude_none=True)
|
||||
content: Any = msg_dict.get('content')
|
||||
if isinstance(content, list):
|
||||
if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):
|
||||
msg_dict['content'] = '\n'.join(part['text'] for part in content)
|
||||
req_messages.append(msg_dict)
|
||||
return req_messages
|
||||
|
||||
async def invoke_llm(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
@@ -113,14 +183,7 @@ class OllamaChatCompletions(requester.ProviderAPIRequester):
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
remove_think: bool = False,
|
||||
) -> provider_message.Message:
|
||||
req_messages: list = []
|
||||
for m in messages:
|
||||
msg_dict: dict = m.dict(exclude_none=True)
|
||||
content: Any = msg_dict.get('content')
|
||||
if isinstance(content, list):
|
||||
if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):
|
||||
msg_dict['content'] = '\n'.join(part['text'] for part in content)
|
||||
req_messages.append(msg_dict)
|
||||
req_messages = await self._prepare_messages(messages)
|
||||
try:
|
||||
return await self._closure(
|
||||
query=query,
|
||||
@@ -133,6 +196,109 @@ class OllamaChatCompletions(requester.ProviderAPIRequester):
|
||||
except asyncio.TimeoutError:
|
||||
raise errors.RequesterError('请求超时')
|
||||
|
||||
async def invoke_llm_stream(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
model: requester.RuntimeLLMModel,
|
||||
messages: typing.List[provider_message.Message],
|
||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
remove_think: bool = False,
|
||||
) -> provider_message.MessageChunk:
|
||||
req_messages = await self._prepare_messages(messages)
|
||||
|
||||
try:
|
||||
args = extra_args.copy()
|
||||
args['model'] = model.model_entity.name
|
||||
|
||||
# Process messages for Ollama format
|
||||
msgs: list[dict] = req_messages.copy()
|
||||
for msg in msgs:
|
||||
if 'content' in msg and isinstance(msg['content'], list):
|
||||
text_content: list = []
|
||||
image_urls: list = []
|
||||
for me in msg['content']:
|
||||
if me['type'] == 'text':
|
||||
text_content.append(me['text'])
|
||||
elif me['type'] == 'image_base64':
|
||||
image_urls.append(me['image_base64'])
|
||||
msg['content'] = '\n'.join(text_content)
|
||||
msg['images'] = [url.split(',')[1] for url in image_urls]
|
||||
if 'tool_calls' in msg:
|
||||
for tool_call in msg['tool_calls']:
|
||||
tool_call['function']['arguments'] = json.loads(tool_call['function']['arguments'])
|
||||
args['messages'] = msgs
|
||||
|
||||
args['tools'] = []
|
||||
if funcs:
|
||||
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
|
||||
if tools:
|
||||
args['tools'] = tools
|
||||
|
||||
args['stream'] = True
|
||||
|
||||
chunk_idx = 0
|
||||
thinking_started = False
|
||||
thinking_ended = False
|
||||
role = 'assistant'
|
||||
|
||||
async for chunk in await self.client.chat(**args):
|
||||
message: ollama.Message = chunk.message
|
||||
done = chunk.done
|
||||
|
||||
delta_content = message.content or ''
|
||||
reasoning_content = getattr(message, 'thinking', '') or ''
|
||||
|
||||
# Handle reasoning/thinking content
|
||||
if reasoning_content:
|
||||
if remove_think:
|
||||
chunk_idx += 1
|
||||
continue
|
||||
|
||||
if not thinking_started:
|
||||
thinking_started = True
|
||||
delta_content = '<think>\n' + reasoning_content
|
||||
else:
|
||||
delta_content = reasoning_content
|
||||
elif thinking_started and not thinking_ended and delta_content:
|
||||
thinking_ended = True
|
||||
delta_content = '\n</think>\n' + delta_content
|
||||
|
||||
# Handle tool calls
|
||||
tool_calls_data = None
|
||||
if message.tool_calls:
|
||||
tool_calls_data = []
|
||||
for tc in message.tool_calls:
|
||||
tool_calls_data.append(
|
||||
{
|
||||
'id': uuid.uuid4().hex,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tc.function.name,
|
||||
'arguments': json.dumps(tc.function.arguments),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Skip empty first chunk
|
||||
if chunk_idx == 0 and not delta_content and not reasoning_content and not tool_calls_data:
|
||||
chunk_idx += 1
|
||||
continue
|
||||
|
||||
chunk_data = {
|
||||
'role': role,
|
||||
'content': delta_content if delta_content else None,
|
||||
'tool_calls': tool_calls_data,
|
||||
'is_final': bool(done),
|
||||
}
|
||||
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
||||
|
||||
yield provider_message.MessageChunk(**chunk_data)
|
||||
chunk_idx += 1
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise errors.RequesterError('请求超时')
|
||||
|
||||
async def invoke_embedding(
|
||||
self,
|
||||
model: requester.RuntimeEmbeddingModel,
|
||||
|
||||
@@ -15,3 +15,11 @@ class OpenRouterChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions):
|
||||
'base_url': 'https://openrouter.ai/api/v1',
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
||||
original_base_url = self.requester_cfg.get('base_url', '')
|
||||
self.requester_cfg['base_url'] = 'https://openrouter.ai/api/v1'
|
||||
try:
|
||||
return await super().scan_models(api_key)
|
||||
finally:
|
||||
self.requester_cfg['base_url'] = original_base_url
|
||||
|
||||
@@ -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 |
@@ -46,14 +46,15 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> typing.List[typing.List[float]]:
|
||||
"""Generate embeddings using SeekDB's built-in embedding function."""
|
||||
if self._embedding_function is None:
|
||||
await self.initialize()
|
||||
|
||||
try:
|
||||
if self._embedding_function is None:
|
||||
await self.initialize()
|
||||
|
||||
if self._embedding_function is None:
|
||||
raise RuntimeError('SeekDB embedding function initialization failed')
|
||||
|
||||
return self._embedding_function(input_text)
|
||||
result = self._embedding_function(input_text)
|
||||
# Ensure JSON serialization compatibility
|
||||
if isinstance(result, list):
|
||||
return [item.tolist() if hasattr(item, 'tolist') else item for item in result]
|
||||
return result.tolist() if hasattr(result, 'tolist') else result
|
||||
except Exception as e:
|
||||
from .. import errors
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -107,7 +107,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
has_thoughts = True # 获取思考过程
|
||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think')
|
||||
if remove_think:
|
||||
has_thoughts = False
|
||||
# 发送对话请求
|
||||
@@ -141,7 +141,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
idx_chunk += 1
|
||||
# 获取流式传输的output
|
||||
stream_output = chunk.get('output', {})
|
||||
stream_think = stream_output.get('thoughts', [])
|
||||
stream_think = stream_output.get('thoughts') or []
|
||||
if stream_think and stream_think[0].get('thought'):
|
||||
if not think_start:
|
||||
think_start = True
|
||||
@@ -149,7 +149,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
else:
|
||||
# 继续输出 reasoning_content
|
||||
pending_content += stream_think[0].get('thought')
|
||||
elif (not stream_think or stream_think[0].get('thought') == '') and not think_end:
|
||||
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
|
||||
think_end = True
|
||||
pending_content += '\n</think>\n'
|
||||
if stream_output.get('text') is not None:
|
||||
@@ -188,15 +188,15 @@ class DashScopeAPIRunner(runner.RequestRunner):
|
||||
idx_chunk += 1
|
||||
# 获取流式传输的output
|
||||
stream_output = chunk.get('output', {})
|
||||
stream_think = stream_output.get('thoughts', [])
|
||||
if stream_think[0].get('thought'):
|
||||
stream_think = stream_output.get('thoughts') or []
|
||||
if stream_think and stream_think[0].get('thought'):
|
||||
if not think_start:
|
||||
think_start = True
|
||||
pending_content += f'<think>\n{stream_think[0].get("thought")}'
|
||||
else:
|
||||
# 继续输出 reasoning_content
|
||||
pending_content += stream_think[0].get('thought')
|
||||
elif stream_think[0].get('thought') == '' and not think_end:
|
||||
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
|
||||
think_end = True
|
||||
pending_content += '\n</think>\n'
|
||||
if stream_output.get('text') is not None:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -70,11 +70,12 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
return plain_text
|
||||
|
||||
async def _process_stream_response(
|
||||
async def _process_response(
|
||||
self, response: aiohttp.ClientResponse
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""处理流式响应——支持部分 JSON 和多个 JSON 对象在同一 chunk 的情况"""
|
||||
"""处理响应——支持流式格式和普通 JSON 格式"""
|
||||
full_content = ''
|
||||
full_text = ''
|
||||
chunk_idx = 0
|
||||
is_final = False
|
||||
message_idx = 0
|
||||
@@ -93,6 +94,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
else:
|
||||
chunk_str = str(raw_chunk)
|
||||
|
||||
full_text += chunk_str
|
||||
buffer += chunk_str
|
||||
|
||||
# 尝试从 buffer 中循环解析出 JSON 对象(处理多个对象或部分对象)
|
||||
@@ -115,7 +117,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
elif obj.get('type') == 'end':
|
||||
is_final = True
|
||||
|
||||
if is_final or chunk_idx % 8 == 0:
|
||||
if is_final or (chunk_idx > 0 and chunk_idx % 8 == 0):
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
@@ -142,6 +144,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
obj, _ = decoder.raw_decode(buffer)
|
||||
if isinstance(obj, dict):
|
||||
if obj.get('type') == 'item' and 'content' in obj:
|
||||
chunk_idx += 1
|
||||
full_content += obj['content']
|
||||
elif obj.get('type') == 'end':
|
||||
is_final = True
|
||||
@@ -156,6 +159,28 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
preview = buffer[:200]
|
||||
self.ap.logger.warning(f'Failed to parse remaining buffer: {e}; buffer preview: {preview}')
|
||||
|
||||
# n8n 返回普通 JSON 格式(无任何流式 type:item 内容)
|
||||
if chunk_idx == 0:
|
||||
output_content = ''
|
||||
try:
|
||||
response_data = json.loads(full_text.strip())
|
||||
if isinstance(response_data, dict):
|
||||
if self.output_key in response_data:
|
||||
output_content = response_data[self.output_key]
|
||||
else:
|
||||
output_content = json.dumps(response_data, ensure_ascii=False)
|
||||
else:
|
||||
output_content = full_text
|
||||
except json.JSONDecodeError:
|
||||
output_content = full_text
|
||||
self.ap.logger.debug(f'n8n webhook response (non-stream): {full_text[:200]}')
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=output_content,
|
||||
is_final=True,
|
||||
msg_sequence=message_idx + 1,
|
||||
)
|
||||
|
||||
async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用n8n webhook"""
|
||||
# 生成会话ID(如果不存在)
|
||||
@@ -220,49 +245,22 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
# 调用webhook
|
||||
session = httpclient.get_session()
|
||||
if is_stream:
|
||||
# 流式请求
|
||||
async with session.post(
|
||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
async with session.post(
|
||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
|
||||
# 处理流式响应
|
||||
async for chunk in self._process_stream_response(response):
|
||||
async for chunk in self._process_response(response):
|
||||
if is_stream:
|
||||
yield chunk
|
||||
else:
|
||||
async with session.post(
|
||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||
) as response:
|
||||
try:
|
||||
async for chunk in self._process_stream_response(response):
|
||||
output_content = chunk.content if chunk.is_final else ''
|
||||
except:
|
||||
# 非流式请求(保持原有逻辑)
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
|
||||
# 解析响应
|
||||
response_data = await response.json()
|
||||
self.ap.logger.debug(f'n8n webhook response: {response_data}')
|
||||
|
||||
# 从响应中提取输出
|
||||
if self.output_key in response_data:
|
||||
output_content = response_data[self.output_key]
|
||||
else:
|
||||
# 如果没有指定的输出键,则使用整个响应
|
||||
output_content = json.dumps(response_data, ensure_ascii=False)
|
||||
|
||||
# 返回消息
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=output_content,
|
||||
)
|
||||
elif chunk.is_final:
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=chunk.content,
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'n8n webhook call exception: {str(e)}')
|
||||
raise N8nAPIError(f'n8n webhook call exception: {str(e)}')
|
||||
|
||||
@@ -2,11 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from ..core import app
|
||||
from .vdb import VectorDatabase, SearchType
|
||||
from .vdbs.chroma import ChromaVectorDatabase
|
||||
from .vdbs.qdrant import QdrantVectorDatabase
|
||||
from .vdbs.seekdb import SeekDBVectorDatabase
|
||||
from .vdbs.milvus import MilvusVectorDatabase
|
||||
from .vdbs.pgvector_db import PgVectorDatabase
|
||||
|
||||
|
||||
class VectorDBManager:
|
||||
@@ -22,17 +17,25 @@ class VectorDBManager:
|
||||
vdb_type = kb_config.get('use')
|
||||
|
||||
if vdb_type == 'chroma':
|
||||
from .vdbs.chroma import ChromaVectorDatabase
|
||||
|
||||
self.vector_db = ChromaVectorDatabase(self.ap)
|
||||
self.ap.logger.info('Initialized Chroma vector database backend.')
|
||||
|
||||
elif vdb_type == 'qdrant':
|
||||
from .vdbs.qdrant import QdrantVectorDatabase
|
||||
|
||||
self.vector_db = QdrantVectorDatabase(self.ap)
|
||||
self.ap.logger.info('Initialized Qdrant vector database backend.')
|
||||
elif vdb_type == 'seekdb':
|
||||
from .vdbs.seekdb import SeekDBVectorDatabase
|
||||
|
||||
self.vector_db = SeekDBVectorDatabase(self.ap)
|
||||
self.ap.logger.info('Initialized SeekDB vector database backend.')
|
||||
|
||||
elif vdb_type == 'milvus':
|
||||
from .vdbs.milvus import MilvusVectorDatabase
|
||||
|
||||
# Get Milvus configuration
|
||||
milvus_config = kb_config.get('milvus', {})
|
||||
uri = milvus_config.get('uri', './data/milvus.db')
|
||||
@@ -42,6 +45,8 @@ class VectorDBManager:
|
||||
self.ap.logger.info('Initialized Milvus vector database backend.')
|
||||
|
||||
elif vdb_type == 'pgvector':
|
||||
from .vdbs.pgvector_db import PgVectorDatabase
|
||||
|
||||
# Get pgvector configuration
|
||||
pgvector_config = kb_config.get('pgvector', {})
|
||||
connection_string = pgvector_config.get('connection_string')
|
||||
@@ -60,9 +65,13 @@ class VectorDBManager:
|
||||
self.ap.logger.info('Initialized pgvector database backend.')
|
||||
|
||||
else:
|
||||
from .vdbs.chroma import ChromaVectorDatabase
|
||||
|
||||
self.vector_db = ChromaVectorDatabase(self.ap)
|
||||
self.ap.logger.warning('No valid vector database backend configured, defaulting to Chroma.')
|
||||
else:
|
||||
from .vdbs.chroma import ChromaVectorDatabase
|
||||
|
||||
self.vector_db = ChromaVectorDatabase(self.ap)
|
||||
self.ap.logger.warning('No vector database backend configured, defaulting to Chroma.')
|
||||
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
"""Vector database implementations for LangBot."""
|
||||
|
||||
from .chroma import ChromaVectorDatabase
|
||||
from .qdrant import QdrantVectorDatabase
|
||||
from .seekdb import SeekDBVectorDatabase
|
||||
|
||||
__all__ = ['ChromaVectorDatabase', 'QdrantVectorDatabase', 'SeekDBVectorDatabase']
|
||||
|
||||
@@ -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
|
||||
|
||||
328
tests/unit_tests/pipeline/test_n8nsvapi.py
Normal file
328
tests/unit_tests/pipeline/test_n8nsvapi.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Unit tests for N8nServiceAPIRunner._process_response
|
||||
|
||||
Tests cover four scenarios:
|
||||
- Stream adapter + n8n stream format (type:item/end)
|
||||
- Stream adapter + n8n plain JSON
|
||||
- Non-stream adapter + n8n stream format
|
||||
- Non-stream adapter + n8n plain JSON
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
# Break the circular import chain before importing n8nsvapi:
|
||||
# n8nsvapi → runner → app → pipelinemgr → all runners → runner (partially init)
|
||||
_mock_runner = MagicMock()
|
||||
_mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator
|
||||
_mock_runner.RequestRunner = object
|
||||
sys.modules.setdefault('langbot.pkg.provider.runner', _mock_runner)
|
||||
sys.modules.setdefault('langbot.pkg.core.app', MagicMock())
|
||||
sys.modules.setdefault('langbot.pkg.utils.httpclient', MagicMock())
|
||||
|
||||
import pytest
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
|
||||
ap = Mock()
|
||||
ap.logger = Mock()
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'n8n-service-api': {
|
||||
'webhook-url': 'http://test-n8n/webhook',
|
||||
'output-key': output_key,
|
||||
'auth-type': 'none',
|
||||
}
|
||||
}
|
||||
}
|
||||
return N8nServiceAPIRunner(ap, pipeline_config)
|
||||
|
||||
|
||||
def make_mock_response(chunks: list[bytes | str], status: int = 200):
|
||||
"""Build a minimal aiohttp.ClientResponse mock with iter_chunked support."""
|
||||
response = Mock()
|
||||
response.status = status
|
||||
|
||||
async def iter_chunked(size):
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
response.content = Mock()
|
||||
response.content.iter_chunked = iter_chunked
|
||||
return response
|
||||
|
||||
|
||||
async def collect_chunks(runner: N8nServiceAPIRunner, chunks: list[bytes | str]):
|
||||
"""Run _process_response and collect all yielded MessageChunks."""
|
||||
response = make_mock_response(chunks)
|
||||
result = []
|
||||
async for chunk in runner._process_response(response):
|
||||
result.append(chunk)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _process_response: stream format (type:item/end)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_format_single_item():
|
||||
"""Single item + end in one chunk yields final chunk with full content."""
|
||||
runner = make_runner()
|
||||
data = b'{"type":"item","content":"hello"}{"type":"end"}'
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
assert len(chunks) >= 1
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'hello'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_format_multi_item_accumulates():
|
||||
"""Multiple items accumulate into full_content."""
|
||||
runner = make_runner()
|
||||
chunks_data = [
|
||||
b'{"type":"item","content":"foo"}',
|
||||
b'{"type":"item","content":"bar"}',
|
||||
b'{"type":"end"}',
|
||||
]
|
||||
|
||||
chunks = await collect_chunks(runner, chunks_data)
|
||||
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'foobar'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_format_batches_every_8_items():
|
||||
"""Every 8th item triggers an intermediate yield before the final."""
|
||||
runner = make_runner()
|
||||
items = [f'{{"type":"item","content":"{i}"}}' for i in range(8)]
|
||||
items.append('{"type":"end"}')
|
||||
data = ''.join(items).encode()
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
# At least the batch yield at chunk_idx==8 + final yield
|
||||
assert len(chunks) >= 2
|
||||
assert chunks[-1].is_final is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_format_split_across_network_chunks():
|
||||
"""JSON split across multiple network chunks is reassembled correctly."""
|
||||
runner = make_runner()
|
||||
part1 = b'{"type":"item","con'
|
||||
part2 = b'tent":"world"}{"type":"end"}'
|
||||
|
||||
chunks = await collect_chunks(runner, [part1, part2])
|
||||
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'world'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_format_no_spurious_empty_yield():
|
||||
"""chunk_idx==0 guard prevents spurious empty yield before any item is received."""
|
||||
runner = make_runner()
|
||||
# Send some non-stream JSON first, then stream
|
||||
data = b'{"type":"item","content":"x"}{"type":"end"}'
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
# No chunk should have empty content before the real content arrives
|
||||
non_final = [c for c in chunks if not c.is_final]
|
||||
for c in non_final:
|
||||
assert c.content # must be non-empty
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _process_response: plain JSON fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_json_with_output_key():
|
||||
"""Plain JSON with matching output_key extracts value via output_key."""
|
||||
runner = make_runner(output_key='response')
|
||||
data = json.dumps({'response': 'hello world'}).encode()
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'hello world'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_json_output_key_not_found():
|
||||
"""Plain JSON without output_key falls back to entire JSON string."""
|
||||
runner = make_runner(output_key='response')
|
||||
payload = {'other_key': 'hello'}
|
||||
data = json.dumps(payload).encode()
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert json.loads(chunks[0].content) == payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_json_output_key_empty_string():
|
||||
"""output_key present but value is empty string — returns empty string, not whole JSON."""
|
||||
runner = make_runner(output_key='response')
|
||||
data = json.dumps({'response': ''}).encode()
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == ''
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_json_non_dict_response():
|
||||
"""Plain JSON array falls back to raw text."""
|
||||
runner = make_runner()
|
||||
data = b'["a", "b"]'
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == '["a", "b"]'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_returns_raw_text():
|
||||
"""Non-JSON response returns raw text as-is."""
|
||||
runner = make_runner()
|
||||
data = b'plain text response'
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'plain text response'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _call_webhook: output type depends on is_stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_query(is_stream: bool):
|
||||
"""Build a minimal Query mock."""
|
||||
query = Mock()
|
||||
query.adapter = AsyncMock()
|
||||
query.adapter.is_stream_output_supported = AsyncMock(return_value=is_stream)
|
||||
|
||||
session = Mock()
|
||||
session.using_conversation = Mock()
|
||||
session.using_conversation.uuid = 'test-uuid'
|
||||
session.launcher_type = Mock()
|
||||
session.launcher_type.value = 'person'
|
||||
session.launcher_id = '12345'
|
||||
query.session = session
|
||||
|
||||
query.user_message = Mock()
|
||||
query.user_message.content = 'hi'
|
||||
query.variables = {}
|
||||
return query
|
||||
|
||||
|
||||
def make_http_session_mock(response_bytes: bytes, status: int = 200):
|
||||
"""Mock httpclient.get_session() returning a session whose post() yields response_bytes."""
|
||||
mock_response = make_mock_response([response_bytes], status=status)
|
||||
mock_response.status = status
|
||||
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_session.post = Mock(return_value=mock_cm)
|
||||
return mock_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_nonstream_adapter_plain_json():
|
||||
"""Non-stream adapter + plain JSON → single Message with output_key value."""
|
||||
runner = make_runner(output_key='response')
|
||||
query = make_query(is_stream=False)
|
||||
http_session = make_http_session_mock(json.dumps({'response': 'result text'}).encode())
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
results = []
|
||||
async for msg in runner._call_webhook(query):
|
||||
results.append(msg)
|
||||
|
||||
assert len(results) == 1
|
||||
assert isinstance(results[0], provider_message.Message)
|
||||
assert results[0].content == 'result text'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_stream_adapter_stream_format():
|
||||
"""Stream adapter + stream format → MessageChunks, last is_final."""
|
||||
runner = make_runner()
|
||||
query = make_query(is_stream=True)
|
||||
data = b'{"type":"item","content":"hi"}{"type":"end"}'
|
||||
http_session = make_http_session_mock(data)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
results = []
|
||||
async for msg in runner._call_webhook(query):
|
||||
results.append(msg)
|
||||
|
||||
assert all(isinstance(r, provider_message.MessageChunk) for r in results)
|
||||
assert results[-1].is_final is True
|
||||
assert results[-1].content == 'hi'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_stream_adapter_plain_json():
|
||||
"""Stream adapter + plain JSON → single MessageChunk with is_final=True."""
|
||||
runner = make_runner(output_key='response')
|
||||
query = make_query(is_stream=True)
|
||||
data = json.dumps({'response': 'fallback'}).encode()
|
||||
http_session = make_http_session_mock(data)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
results = []
|
||||
async for msg in runner._call_webhook(query):
|
||||
results.append(msg)
|
||||
|
||||
assert all(isinstance(r, provider_message.MessageChunk) for r in results)
|
||||
assert results[-1].is_final is True
|
||||
assert results[-1].content == 'fallback'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_nonstream_adapter_stream_format():
|
||||
"""Non-stream adapter + stream format → single Message with accumulated content."""
|
||||
runner = make_runner()
|
||||
query = make_query(is_stream=False)
|
||||
data = b'{"type":"item","content":"foo"}{"type":"item","content":"bar"}{"type":"end"}'
|
||||
http_session = make_http_session_mock(data)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
results = []
|
||||
async for msg in runner._call_webhook(query):
|
||||
results.append(msg)
|
||||
|
||||
assert len(results) == 1
|
||||
assert isinstance(results[0], provider_message.Message)
|
||||
assert results[0].content == 'foobar'
|
||||
@@ -46,14 +46,14 @@
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^25.1.2",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.0",
|
||||
"lucide-react": "^0.507.0",
|
||||
"postcss": "^8.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -76,7 +76,7 @@
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"uuidjs": "^5.1.0",
|
||||
"vite": "^8.0.3",
|
||||
"vite": "^8.0.5",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
4151
web/pnpm-lock.yaml
generated
4151
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
145
web/scripts/check-i18n.mjs
Normal file
145
web/scripts/check-i18n.mjs
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Check that all i18n locale files have the same keys as en-US.ts (the reference).
|
||||
* Reports missing keys (present in en-US but absent in the locale) and
|
||||
* extra keys (present in the locale but absent in en-US).
|
||||
* Exits with code 1 if any mismatch is found.
|
||||
*
|
||||
* Keys are extracted using a line-by-line parser that handles the known format
|
||||
* of the locale files (no eval or dynamic code execution is used).
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { resolve, dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const LOCALES_DIR = resolve(__dirname, '../src/i18n/locales');
|
||||
const REFERENCE = 'en-US.ts';
|
||||
|
||||
/**
|
||||
* Extract all dot-notation leaf keys from a TypeScript locale file.
|
||||
*
|
||||
* The expected file format is:
|
||||
* const <varName> = {
|
||||
* key: 'value',
|
||||
* nested: {
|
||||
* subKey: 'value',
|
||||
* },
|
||||
* };
|
||||
* export default <varName>;
|
||||
*
|
||||
* The parser tracks indentation depth to build dot-separated key paths and
|
||||
* never executes the file content.
|
||||
*/
|
||||
function extractKeys(filePath) {
|
||||
let src = readFileSync(filePath, 'utf8');
|
||||
|
||||
// Remove UTF-8 BOM if present
|
||||
if (src.charCodeAt(0) === 0xfeff) {
|
||||
src = src.slice(1);
|
||||
}
|
||||
|
||||
const lines = src.split('\n');
|
||||
const keys = [];
|
||||
// Stack of { key, indent } pairs representing the current nesting path
|
||||
const stack = [];
|
||||
|
||||
// Matches an object key at the start of a line (identifier or quoted string)
|
||||
// Captures: [indent, keyName, hasOpenBrace]
|
||||
const KEY_RE = /^(\s+)([\w]+)\s*:/;
|
||||
const OPEN_BRACE_RE = /\{\s*$/;
|
||||
const CLOSE_BRACE_RE = /^\s*\},?\s*$/;
|
||||
|
||||
for (const line of lines) {
|
||||
if (CLOSE_BRACE_RE.test(line)) {
|
||||
// Pop the stack when we encounter a closing brace line
|
||||
const lineIndent = line.match(/^(\s*)/)[1].length;
|
||||
while (stack.length > 0 && stack[stack.length - 1].indent >= lineIndent) {
|
||||
stack.pop();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const m = line.match(KEY_RE);
|
||||
if (!m) continue;
|
||||
|
||||
const indent = m[1].length;
|
||||
const keyName = m[2];
|
||||
|
||||
// Pop stack entries that are at the same or deeper indent level
|
||||
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
const prefix = stack.map((e) => e.key).join('.');
|
||||
const fullKey = prefix ? `${prefix}.${keyName}` : keyName;
|
||||
|
||||
if (OPEN_BRACE_RE.test(line)) {
|
||||
// This is a parent (nested object) key — push onto stack, don't record as leaf
|
||||
stack.push({ key: keyName, indent });
|
||||
} else {
|
||||
// This is a leaf key
|
||||
keys.push(fullKey);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = readdirSync(LOCALES_DIR).filter((f) => f.endsWith('.ts'));
|
||||
|
||||
if (!files.includes(REFERENCE)) {
|
||||
console.error(`Reference file ${REFERENCE} not found in ${LOCALES_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const refKeys = new Set(extractKeys(join(LOCALES_DIR, REFERENCE)));
|
||||
let hasError = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (file === REFERENCE) continue;
|
||||
|
||||
const locale = file.replace('.ts', '');
|
||||
let localeKeys;
|
||||
try {
|
||||
localeKeys = new Set(extractKeys(join(LOCALES_DIR, file)));
|
||||
} catch (e) {
|
||||
console.error(`[${locale}] Failed to parse file: ${e.message}`);
|
||||
hasError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const missing = [...refKeys].filter((k) => !localeKeys.has(k));
|
||||
const extra = [...localeKeys].filter((k) => !refKeys.has(k));
|
||||
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
console.log(`[${locale}] ✅ All keys match.`);
|
||||
} else {
|
||||
hasError = true;
|
||||
console.log(`\n[${locale}] ❌ Key mismatch detected:`);
|
||||
if (missing.length > 0) {
|
||||
console.log(` Missing keys (in en-US but not in ${locale}):`);
|
||||
for (const k of missing) {
|
||||
console.log(` - ${k}`);
|
||||
}
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
console.log(` Extra keys (in ${locale} but not in en-US):`);
|
||||
for (const k of extra) {
|
||||
console.log(` + ${k}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
console.log('\n❌ i18n key check failed. Please fix the mismatches above.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ All i18n locale files have matching keys.');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -10,7 +10,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Ban, Bot, Copy, Check, Workflow } from 'lucide-react';
|
||||
import {
|
||||
Ban,
|
||||
Bot,
|
||||
Copy,
|
||||
Check,
|
||||
Workflow,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
MessageChainComponent,
|
||||
Plain,
|
||||
@@ -54,6 +62,12 @@ interface SessionMessage {
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
interface SessionFeedback {
|
||||
feedback_type: number; // 1=like, 2=dislike
|
||||
feedback_content?: string | null;
|
||||
stream_id?: string | null;
|
||||
}
|
||||
|
||||
export interface BotSessionMonitorHandle {
|
||||
refreshSessions: () => Promise<void>;
|
||||
}
|
||||
@@ -75,6 +89,9 @@ const BotSessionMonitor = forwardRef<
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
const [feedbackMap, setFeedbackMap] = useState<
|
||||
Record<string, SessionFeedback>
|
||||
>({});
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const parseSessionType = (sessionId: string): string | null => {
|
||||
@@ -117,21 +134,50 @@ const BotSessionMonitor = forwardRef<
|
||||
[loadSessions],
|
||||
);
|
||||
|
||||
const loadMessages = useCallback(async (sessionId: string) => {
|
||||
setLoadingMessages(true);
|
||||
try {
|
||||
const response = await httpClient.getSessionMessages(sessionId);
|
||||
const sorted = (response.messages ?? []).sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
setMessages(sorted);
|
||||
} catch (error) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}, []);
|
||||
const loadMessages = useCallback(
|
||||
async (sessionId: string) => {
|
||||
setLoadingMessages(true);
|
||||
try {
|
||||
const messagesRes = await httpClient.getSessionMessages(sessionId);
|
||||
const sorted = (messagesRes.messages ?? []).sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
setMessages(sorted);
|
||||
|
||||
// Collect user message IDs for feedback matching
|
||||
const userMsgIds = new Set(
|
||||
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
|
||||
);
|
||||
|
||||
if (userMsgIds.size > 0) {
|
||||
// Fetch feedback for this bot, then match by stream_id locally
|
||||
const feedbackRes = await httpClient.get<{
|
||||
feedback: SessionFeedback[];
|
||||
}>(
|
||||
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
|
||||
);
|
||||
|
||||
const map: Record<string, SessionFeedback> = {};
|
||||
if (feedbackRes?.feedback) {
|
||||
for (const fb of feedbackRes.feedback) {
|
||||
if (fb.stream_id && userMsgIds.has(fb.stream_id)) {
|
||||
map[fb.stream_id] = fb;
|
||||
}
|
||||
}
|
||||
}
|
||||
setFeedbackMap(map);
|
||||
} else {
|
||||
setFeedbackMap({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
},
|
||||
[botId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
@@ -479,11 +525,21 @@ const BotSessionMonitor = forwardRef<
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => {
|
||||
messages.map((msg, msgIndex) => {
|
||||
const isUser = isUserMessage(msg);
|
||||
const isDiscarded =
|
||||
msg.status === 'discarded' ||
|
||||
msg.pipeline_id === PIPELINE_DISCARD;
|
||||
// For bot replies, find feedback linked to the preceding user message
|
||||
let msgFeedback: SessionFeedback | undefined;
|
||||
if (!isUser) {
|
||||
for (let i = msgIndex - 1; i >= 0; i--) {
|
||||
if (isUserMessage(messages[i])) {
|
||||
msgFeedback = feedbackMap[messages[i].id];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
@@ -543,6 +599,30 @@ const BotSessionMonitor = forwardRef<
|
||||
{msg.runner_name}
|
||||
</span>
|
||||
)}
|
||||
{/* Feedback indicator — same line, pushed right */}
|
||||
{!isUser &&
|
||||
msgFeedback &&
|
||||
(msgFeedback.feedback_type === 1 ? (
|
||||
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
|
||||
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
|
||||
{t('monitoring.feedback.like')}
|
||||
{msgFeedback.feedback_content && (
|
||||
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
|
||||
{msgFeedback.feedback_content}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group">
|
||||
<ThumbsDown className="w-3 h-3 flex-shrink-0" />
|
||||
{t('monitoring.feedback.dislike')}
|
||||
{msgFeedback.feedback_content && (
|
||||
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
|
||||
{msgFeedback.feedback_content}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -16,6 +16,8 @@ import { ProviderCard } from './components';
|
||||
import {
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
SelectedScannedModel,
|
||||
TestResult,
|
||||
ProviderModels,
|
||||
LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
@@ -145,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) {
|
||||
@@ -245,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);
|
||||
@@ -262,6 +272,60 @@ export default function ModelsDialog({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScanModels(
|
||||
providerUuid: string,
|
||||
modelType: ModelType,
|
||||
): Promise<ScanModelsResult> {
|
||||
try {
|
||||
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
|
||||
return {
|
||||
models: resp.models,
|
||||
debug: resp.debug,
|
||||
};
|
||||
} catch (err) {
|
||||
toast.error(t('models.getModelListError') + (err as CustomApiError).msg);
|
||||
return { models: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddScannedModels(
|
||||
providerUuid: string,
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
) {
|
||||
if (models.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
for (const item of models) {
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.createProviderLLMModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities: item.abilities,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.createProviderEmbeddingModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
setAddModelPopoverOpen(null);
|
||||
loadProviderModels(providerUuid, true);
|
||||
loadProviders();
|
||||
toast.success(
|
||||
t('models.addSelectedModelsSuccess', { count: models.length }),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t('models.createError') + (err as CustomApiError).msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateModel(
|
||||
providerUuid: string,
|
||||
modelId: string,
|
||||
@@ -285,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);
|
||||
@@ -310,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);
|
||||
@@ -351,7 +423,7 @@ export default function ModelsDialog({
|
||||
abilities,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else {
|
||||
} else if (modelType === 'embedding') {
|
||||
await httpClient.testEmbeddingModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
@@ -359,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 });
|
||||
@@ -404,6 +484,10 @@ export default function ModelsDialog({
|
||||
onAddModel={(modelType, name, abilities, extraArgs) =>
|
||||
handleAddModel(provider.uuid, modelType, name, abilities, extraArgs)
|
||||
}
|
||||
onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)}
|
||||
onAddScannedModels={(modelType, models) =>
|
||||
handleAddScannedModels(provider.uuid, modelType, models)
|
||||
}
|
||||
onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)}
|
||||
onCloseEditModel={() => setEditModelPopoverOpen(null)}
|
||||
onUpdateModel={(modelId, modelType, name, abilities, extraArgs) =>
|
||||
|
||||
@@ -169,8 +169,6 @@ export default function ProviderForm({
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v);
|
||||
const req = requesterList.find((r) => r.value === v);
|
||||
// Auto-fill default URL when creating new provider
|
||||
// or when base_url is empty in edit mode
|
||||
if (req && (!providerId || !form.getValues('base_url'))) {
|
||||
form.setValue('base_url', req.defaultUrl);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, MessageSquareText, Cpu, Eye, Wrench, Check } from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
MessageSquareText,
|
||||
Cpu,
|
||||
ArrowUpDown,
|
||||
Eye,
|
||||
Wrench,
|
||||
Check,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -11,7 +21,14 @@ import {
|
||||
} from '@/components/ui/popover';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExtraArg, ModelType, TestResult } from '../types';
|
||||
import { ScannedProviderModel } from '@/app/infra/entities/api';
|
||||
import {
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
SelectedScannedModel,
|
||||
TestResult,
|
||||
} from '../types';
|
||||
import ExtraArgsEditor from './ExtraArgsEditor';
|
||||
|
||||
interface AddModelPopoverProps {
|
||||
@@ -24,6 +41,11 @@ interface AddModelPopoverProps {
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
||||
onAddScannedModels: (
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
) => Promise<void>;
|
||||
onTestModel: (
|
||||
name: string,
|
||||
modelType: ModelType,
|
||||
@@ -41,6 +63,8 @@ export default function AddModelPopover({
|
||||
onOpen,
|
||||
onClose,
|
||||
onAddModel,
|
||||
onScanModels,
|
||||
onAddScannedModels,
|
||||
onTestModel,
|
||||
isSubmitting,
|
||||
isTesting,
|
||||
@@ -48,22 +72,44 @@ export default function AddModelPopover({
|
||||
onResetTestResult,
|
||||
}: AddModelPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const prevIsOpenRef = useRef(false);
|
||||
|
||||
const [tab, setTab] = useState<ModelType>('llm');
|
||||
const [mode, setMode] = useState<'manual' | 'scan'>('manual');
|
||||
const [name, setName] = useState('');
|
||||
const [abilities, setAbilities] = useState<string[]>([]);
|
||||
const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]);
|
||||
const [scanLoading, setScanLoading] = useState(false);
|
||||
const [scannedModels, setScannedModels] = useState<ScannedProviderModel[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedScannedModels, setSelectedScannedModels] = useState<
|
||||
Record<string, SelectedScannedModel>
|
||||
>({});
|
||||
const [scanQuery, setScanQuery] = useState('');
|
||||
|
||||
// Reset form when popover opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const wasOpen = prevIsOpenRef.current;
|
||||
if (isOpen && !wasOpen) {
|
||||
setTab('llm');
|
||||
setMode('manual');
|
||||
setName('');
|
||||
setAbilities([]);
|
||||
setExtraArgs([]);
|
||||
setScanLoading(false);
|
||||
setScannedModels([]);
|
||||
setSelectedScannedModels({});
|
||||
setScanQuery('');
|
||||
onResetTestResult();
|
||||
}
|
||||
}, [isOpen]);
|
||||
prevIsOpenRef.current = isOpen;
|
||||
}, [isOpen, onResetTestResult]);
|
||||
|
||||
useEffect(() => {
|
||||
setScannedModels([]);
|
||||
setSelectedScannedModels({});
|
||||
setScanQuery('');
|
||||
}, [tab, mode]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
await onAddModel(tab, name, abilities, extraArgs);
|
||||
@@ -73,6 +119,50 @@ export default function AddModelPopover({
|
||||
await onTestModel(name, tab, tab === 'llm' ? abilities : [], extraArgs);
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanLoading(true);
|
||||
try {
|
||||
const result = await onScanModels(tab);
|
||||
|
||||
// Enrich abilities from debug.response.data (e.g. features.tools.function_calling)
|
||||
const debugData = (
|
||||
result.debug?.response as { data?: Record<string, unknown>[] }
|
||||
)?.data;
|
||||
if (Array.isArray(debugData)) {
|
||||
const debugMap = new Map<string, Record<string, unknown>>();
|
||||
for (const item of debugData) {
|
||||
if (typeof item?.id === 'string') {
|
||||
debugMap.set(item.id, item);
|
||||
}
|
||||
}
|
||||
for (const model of result.models) {
|
||||
const debugItem = debugMap.get(model.id);
|
||||
if (!debugItem) continue;
|
||||
const features = debugItem.features as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const tools = features?.tools as Record<string, unknown> | undefined;
|
||||
if (tools?.function_calling === true) {
|
||||
const abilities = new Set(model.abilities || []);
|
||||
abilities.add('func_call');
|
||||
model.abilities = [...abilities];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setScannedModels(result.models);
|
||||
setSelectedScannedModels({});
|
||||
} finally {
|
||||
setScanLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddScanned = async () => {
|
||||
const selectedModels = Object.values(selectedScannedModels);
|
||||
if (selectedModels.length === 0) return;
|
||||
await onAddScannedModels(tab, selectedModels);
|
||||
};
|
||||
|
||||
const toggleAbility = (ability: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setAbilities([...abilities, ability]);
|
||||
@@ -81,6 +171,76 @@ export default function AddModelPopover({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleScannedModel = (
|
||||
model: ScannedProviderModel,
|
||||
checked: boolean,
|
||||
) => {
|
||||
setSelectedScannedModels((prev) => {
|
||||
const next = { ...prev };
|
||||
if (checked) {
|
||||
next[model.id] = {
|
||||
model,
|
||||
abilities:
|
||||
model.type === 'llm'
|
||||
? prev[model.id]?.abilities || model.abilities || []
|
||||
: [],
|
||||
};
|
||||
} else {
|
||||
delete next[model.id];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleScannedModelAbility = (
|
||||
modelId: string,
|
||||
ability: string,
|
||||
checked: boolean,
|
||||
) => {
|
||||
setSelectedScannedModels((prev) => {
|
||||
const current = prev[modelId];
|
||||
if (!current) return prev;
|
||||
|
||||
const nextAbilities = checked
|
||||
? [...current.abilities, ability]
|
||||
: current.abilities.filter((item) => item !== ability);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[modelId]: {
|
||||
...current,
|
||||
abilities: nextAbilities,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const filteredScannedModels = scannedModels.filter((model) =>
|
||||
model.name.toLowerCase().includes(scanQuery.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
const selectableModels = filteredScannedModels.filter(
|
||||
(m) => !m.already_added,
|
||||
);
|
||||
const allSelected =
|
||||
selectableModels.length > 0 &&
|
||||
selectableModels.every((m) => Boolean(selectedScannedModels[m.id]));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedScannedModels({});
|
||||
} else {
|
||||
const next: Record<string, SelectedScannedModel> = {};
|
||||
for (const model of selectableModels) {
|
||||
next[model.id] = {
|
||||
model,
|
||||
abilities: model.type === 'llm' ? model.abilities || [] : [],
|
||||
};
|
||||
}
|
||||
setSelectedScannedModels(next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
@@ -98,12 +258,15 @@ export default function AddModelPopover({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80"
|
||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] overflow-y-auto"
|
||||
align="end"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
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')}
|
||||
@@ -112,118 +275,272 @@ 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>
|
||||
|
||||
<TabsContent value="llm" className="space-y-3 mt-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.modelName')}</Label>
|
||||
<Input
|
||||
placeholder={t('models.modelName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.abilities')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-vision"
|
||||
checked={abilities.includes('vision')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('vision', checked as boolean)
|
||||
}
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mt-3">
|
||||
<TabsTrigger value="manual">{t('models.manualAdd')}</TabsTrigger>
|
||||
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="manual" className="mt-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.modelName')}</Label>
|
||||
<Input
|
||||
placeholder={t('models.modelName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Label htmlFor="add-vision" className="text-sm">
|
||||
<Eye className="h-3 w-3 inline mr-1" />
|
||||
{t('models.visionAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-func-call"
|
||||
checked={abilities.includes('func_call')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('func_call', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-func-call" className="text-sm">
|
||||
<Wrench className="h-3 w-3 inline mr-1" />
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
|
||||
{tab === 'llm' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.abilities')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-vision"
|
||||
checked={abilities.includes('vision')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('vision', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-vision" className="text-sm">
|
||||
<Eye className="h-3 w-3 inline mr-1" />
|
||||
{t('models.visionAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-func-call"
|
||||
checked={abilities.includes('func_call')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('func_call', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-func-call" className="text-sm">
|
||||
<Wrench className="h-3 w-3 inline mr-1" />
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExtraArgsEditor
|
||||
args={extraArgs}
|
||||
onChange={setExtraArgs}
|
||||
modelType={tab}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
{isSubmitting ? t('common.saving') : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
t('common.loading')
|
||||
) : testResult?.success ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1 text-green-500" />
|
||||
{(testResult.duration / 1000).toFixed(1)}s
|
||||
</>
|
||||
) : (
|
||||
t('common.test')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
{isSubmitting ? t('common.saving') : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
t('common.loading')
|
||||
) : testResult?.success ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1 text-green-500" />
|
||||
{(testResult.duration / 1000).toFixed(1)}s
|
||||
</>
|
||||
) : (
|
||||
t('common.test')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="embedding" className="space-y-3 mt-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.modelName')}</Label>
|
||||
<Input
|
||||
placeholder={t('models.modelName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ExtraArgsEditor args={extraArgs} onChange={setExtraArgs} />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
{isSubmitting ? t('common.saving') : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
t('common.loading')
|
||||
) : testResult?.success ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1 text-green-500" />
|
||||
{(testResult.duration / 1000).toFixed(1)}s
|
||||
</>
|
||||
) : (
|
||||
t('common.test')
|
||||
<TabsContent value="scan" className="space-y-3 mt-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('models.scanModelsHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleScan}
|
||||
disabled={scanLoading || isSubmitting}
|
||||
>
|
||||
{scanLoading ? (
|
||||
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{t('models.scanModels')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={handleAddScanned}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
scanLoading ||
|
||||
Object.keys(selectedScannedModels).length === 0
|
||||
}
|
||||
>
|
||||
{isSubmitting
|
||||
? t('common.saving')
|
||||
: t('models.addSelectedModels')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.scannedModels')}</Label>
|
||||
<Input
|
||||
placeholder={t('models.searchScannedModels')}
|
||||
value={scanQuery}
|
||||
onChange={(e) => setScanQuery(e.target.value)}
|
||||
disabled={scannedModels.length === 0}
|
||||
/>
|
||||
{selectableModels.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="scan-select-all"
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="scan-select-all"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t('models.selectAll')}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({Object.keys(selectedScannedModels).length}/
|
||||
{selectableModels.length})
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-3 space-y-2">
|
||||
{filteredScannedModels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{scannedModels.length === 0
|
||||
? t('models.noScannedModels')
|
||||
: t('models.noScannedModelsMatch')}
|
||||
</p>
|
||||
) : (
|
||||
filteredScannedModels.map((model) => {
|
||||
const isSelected = Boolean(
|
||||
selectedScannedModels[model.id],
|
||||
);
|
||||
const selectedAbilities =
|
||||
selectedScannedModels[model.id]?.abilities || [];
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected || model.already_added}
|
||||
disabled={model.already_added}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleScannedModel(model, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium break-all">
|
||||
{model.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{model.already_added
|
||||
? t('models.alreadyAdded')
|
||||
: model.type === 'llm'
|
||||
? t('models.chat')
|
||||
: model.type === 'embedding'
|
||||
? t('models.embedding')
|
||||
: t('models.rerank')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'llm' &&
|
||||
isSelected &&
|
||||
!model.already_added && (
|
||||
<div className="flex gap-4 pl-7">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`scan-vision-${model.id}`}
|
||||
checked={selectedAbilities.includes(
|
||||
'vision',
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleScannedModelAbility(
|
||||
model.id,
|
||||
'vision',
|
||||
checked as boolean,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`scan-vision-${model.id}`}
|
||||
className="text-sm"
|
||||
>
|
||||
<Eye className="h-3 w-3 inline mr-1" />
|
||||
{t('models.visionAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`scan-func-${model.id}`}
|
||||
checked={selectedAbilities.includes(
|
||||
'func_call',
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleScannedModelAbility(
|
||||
model.id,
|
||||
'func_call',
|
||||
checked as boolean,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`scan-func-${model.id}`}
|
||||
className="text-sm"
|
||||
>
|
||||
<Wrench className="h-3 w-3 inline mr-1" />
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -24,7 +24,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { ExtraArg, ModelType, TestResult, ProviderModels } from '../types';
|
||||
import {
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
SelectedScannedModel,
|
||||
TestResult,
|
||||
ProviderModels,
|
||||
} from '../types';
|
||||
import ModelItem from './ModelItem';
|
||||
import AddModelPopover from './AddModelPopover';
|
||||
|
||||
@@ -53,6 +60,11 @@ interface ProviderCardProps {
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
||||
onAddScannedModels: (
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
) => Promise<void>;
|
||||
onOpenEditModel: (modelId: string) => void;
|
||||
onCloseEditModel: () => void;
|
||||
onUpdateModel: (
|
||||
@@ -101,6 +113,8 @@ export default function ProviderCard({
|
||||
onOpenAddModel,
|
||||
onCloseAddModel,
|
||||
onAddModel,
|
||||
onScanModels,
|
||||
onAddScannedModels,
|
||||
onOpenEditModel,
|
||||
onCloseEditModel,
|
||||
onUpdateModel,
|
||||
@@ -120,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">
|
||||
@@ -298,6 +315,8 @@ export default function ProviderCard({
|
||||
onOpen={onOpenAddModel}
|
||||
onClose={onCloseAddModel}
|
||||
onAddModel={onAddModel}
|
||||
onScanModels={onScanModels}
|
||||
onAddScannedModels={onAddScannedModels}
|
||||
onTestModel={onTestModel}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
@@ -377,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,7 +1,10 @@
|
||||
import {
|
||||
LLMModel,
|
||||
EmbeddingModel,
|
||||
RerankModel,
|
||||
ModelProvider,
|
||||
ProviderScanDebugInfo,
|
||||
ScannedProviderModel,
|
||||
} from '@/app/infra/entities/api';
|
||||
|
||||
export type ExtraArg = {
|
||||
@@ -10,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 {
|
||||
@@ -22,6 +26,16 @@ export interface TestResult {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export type SelectedScannedModel = {
|
||||
model: ScannedProviderModel;
|
||||
abilities: string[];
|
||||
};
|
||||
|
||||
export type ScanModelsResult = {
|
||||
models: ScannedProviderModel[];
|
||||
debug?: ProviderScanDebugInfo;
|
||||
};
|
||||
|
||||
export interface ModelItemProps {
|
||||
model: LLMModel | EmbeddingModel;
|
||||
modelType: ModelType;
|
||||
@@ -75,6 +89,11 @@ export interface ProviderCardProps {
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
||||
onAddScannedModels: (
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
) => Promise<void>;
|
||||
onOpenEditModel: (modelId: string) => void;
|
||||
onCloseEditModel: () => void;
|
||||
onUpdateModel: (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AlertCircle,
|
||||
Users,
|
||||
Layers,
|
||||
ThumbsUp,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -25,7 +26,8 @@ export type ExportType =
|
||||
| 'llm-calls'
|
||||
| 'embedding-calls'
|
||||
| 'errors'
|
||||
| 'sessions';
|
||||
| 'sessions'
|
||||
| 'feedback';
|
||||
|
||||
interface ExportDropdownProps {
|
||||
filterState: FilterState;
|
||||
@@ -162,6 +164,11 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
|
||||
label: t('monitoring.export.sessions'),
|
||||
icon: <Users className="w-4 h-4 mr-2" />,
|
||||
},
|
||||
{
|
||||
type: 'feedback',
|
||||
label: t('monitoring.export.feedback'),
|
||||
icon: <ThumbsUp className="w-4 h-4 mr-2" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -127,6 +127,20 @@ export function FeedbackList({
|
||||
{item.platform}
|
||||
</span>
|
||||
)}
|
||||
{item.streamId && onViewMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewMessage(item.streamId!);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t('monitoring.messageList.viewConversation')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.feedbackContent && (
|
||||
@@ -221,21 +235,8 @@ export function FeedbackList({
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.feedback.messageId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate flex items-center gap-1">
|
||||
<span className="truncate">{item.messageId}</span>
|
||||
{onViewMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewMessage(item.messageId!);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.messageId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Suspense, useState, useMemo } from 'react';
|
||||
import React, { Suspense, useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -69,6 +69,9 @@ function MonitoringPageContent() {
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
|
||||
// Counter to force feedbackTimeRange recomputation on manual refresh
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
|
||||
// Get time range for feedback data
|
||||
const feedbackTimeRange = useMemo(() => {
|
||||
const now = new Date();
|
||||
@@ -106,7 +109,8 @@ function MonitoringPageContent() {
|
||||
startTime: startTime?.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
}, [filterState.timeRange, filterState.customDateRange]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterState.timeRange, filterState.customDateRange, feedbackRefreshKey]);
|
||||
|
||||
// Feedback data hook
|
||||
const {
|
||||
@@ -127,6 +131,12 @@ function MonitoringPageContent() {
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
// Combined refresh handler for both monitoring and feedback data
|
||||
const handleRefresh = useCallback(() => {
|
||||
refetch();
|
||||
setFeedbackRefreshKey((k) => k + 1);
|
||||
}, [refetch]);
|
||||
|
||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -265,7 +275,7 @@ function MonitoringPageContent() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refetch}
|
||||
onClick={handleRefresh}
|
||||
className="shadow-sm flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -61,6 +62,34 @@ export interface ApiRespModelProvider {
|
||||
provider: ModelProvider;
|
||||
}
|
||||
|
||||
export interface ScannedProviderModel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'llm' | 'embedding';
|
||||
abilities?: string[];
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
context_length?: number | null;
|
||||
owned_by?: string;
|
||||
input_modalities?: string[];
|
||||
output_modalities?: string[];
|
||||
already_added: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderScanDebugInfo {
|
||||
request?: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
export interface ApiRespScannedProviderModels {
|
||||
models: ScannedProviderModel[];
|
||||
debug?: ProviderScanDebugInfo;
|
||||
}
|
||||
|
||||
export interface LLMModel {
|
||||
uuid: string;
|
||||
name: string;
|
||||
@@ -86,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[];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface I18nObject {
|
||||
th_TH?: string;
|
||||
vi_VN?: string;
|
||||
es_ES?: string;
|
||||
ru_RU?: string;
|
||||
}
|
||||
|
||||
export interface ComponentManifest {
|
||||
|
||||
@@ -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,12 +31,16 @@ import {
|
||||
ApiRespProviderEmbeddingModels,
|
||||
ApiRespProviderEmbeddingModel,
|
||||
EmbeddingModel,
|
||||
ApiRespProviderRerankModels,
|
||||
ApiRespProviderRerankModel,
|
||||
RerankModel,
|
||||
ApiRespPluginSystemStatus,
|
||||
ApiRespMCPServers,
|
||||
ApiRespMCPServer,
|
||||
MCPServer,
|
||||
ApiRespModelProviders,
|
||||
ApiRespModelProvider,
|
||||
ApiRespScannedProviderModels,
|
||||
ModelProvider,
|
||||
ApiRespKnowledgeEngines,
|
||||
ApiRespParsers,
|
||||
@@ -106,6 +110,14 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.delete(`/api/v1/provider/providers/${uuid}`);
|
||||
}
|
||||
|
||||
public scanProviderModels(
|
||||
uuid: string,
|
||||
modelType?: 'llm' | 'embedding',
|
||||
): Promise<ApiRespScannedProviderModels> {
|
||||
const params = modelType ? { type: modelType } : {};
|
||||
return this.get(`/api/v1/provider/providers/${uuid}/scan-models`, params);
|
||||
}
|
||||
|
||||
// ============ Provider Model LLM ============
|
||||
public getProviderLLMModels(
|
||||
providerUuid?: string,
|
||||
@@ -173,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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ export function LanguageSelector({
|
||||
} else if (i18n.language === 'es' || i18n.language === 'es-ES') {
|
||||
setCurrentLanguage('es-ES');
|
||||
localStorage.setItem('langbot_language', 'es-ES');
|
||||
} else if (i18n.language === 'ru' || i18n.language === 'ru-RU') {
|
||||
setCurrentLanguage('ru-RU');
|
||||
localStorage.setItem('langbot_language', 'ru-RU');
|
||||
} else {
|
||||
setCurrentLanguage('en-US');
|
||||
localStorage.setItem('langbot_language', 'en-US');
|
||||
@@ -74,6 +77,11 @@ export function LanguageSelector({
|
||||
browserLanguage.startsWith('es-')
|
||||
) {
|
||||
detectedLanguage = 'es-ES';
|
||||
} else if (
|
||||
browserLanguage === 'ru' ||
|
||||
browserLanguage.startsWith('ru-')
|
||||
) {
|
||||
detectedLanguage = 'ru-RU';
|
||||
} else {
|
||||
detectedLanguage = 'en-US';
|
||||
}
|
||||
@@ -111,6 +119,7 @@ export function LanguageSelector({
|
||||
<SelectItem value="th-TH">ภาษาไทย</SelectItem>
|
||||
<SelectItem value="vi-VN">Tiếng Việt</SelectItem>
|
||||
<SelectItem value="es-ES">Español</SelectItem>
|
||||
<SelectItem value="ru-RU">Русский</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ export const extractI18nObject = (i18nObject: I18nObject): string => {
|
||||
if (language === 'th_TH' && i18nObject.th_TH) return i18nObject.th_TH;
|
||||
if (language === 'vi_VN' && i18nObject.vi_VN) return i18nObject.vi_VN;
|
||||
if (language === 'es_ES' && i18nObject.es_ES) return i18nObject.es_ES;
|
||||
if (language === 'ru_RU' && i18nObject.ru_RU) return i18nObject.ru_RU;
|
||||
return (
|
||||
i18nObject.en_US ||
|
||||
i18nObject.zh_Hans ||
|
||||
@@ -56,6 +57,8 @@ export const getAPILanguageCode = (): string => {
|
||||
if (language === 'vi-VN') return 'vi_VN';
|
||||
// es-ES -> es_ES
|
||||
if (language === 'es-ES') return 'es_ES';
|
||||
// ru-RU -> ru_RU
|
||||
if (language === 'ru-RU') return 'ru_RU';
|
||||
// 默认返回 en
|
||||
return 'en';
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import jaJP from './locales/ja-JP';
|
||||
import thTH from './locales/th-TH';
|
||||
import viVN from './locales/vi-VN';
|
||||
import esES from './locales/es-ES';
|
||||
import ruRU from './locales/ru-RU';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
@@ -36,6 +37,9 @@ i18n
|
||||
'es-ES': {
|
||||
translation: esES,
|
||||
},
|
||||
'ru-RU': {
|
||||
translation: ruRU,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'zh-Hans',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
@@ -36,6 +36,7 @@ const enUS = {
|
||||
delete: 'Delete',
|
||||
add: 'Add',
|
||||
select: 'Select',
|
||||
skill: 'Skill',
|
||||
cancel: 'Cancel',
|
||||
submit: 'Submit',
|
||||
error: 'Error',
|
||||
@@ -181,6 +182,10 @@ const enUS = {
|
||||
mustBeValidNumber: 'Must be a valid number',
|
||||
mustBeTrueOrFalse: 'Must be true or false',
|
||||
requestURL: 'Request URL',
|
||||
scanURL: 'Scan Models URL',
|
||||
scanURLPlaceholder: 'Leave empty to use Request URL + /models',
|
||||
scanURLDescription:
|
||||
'Fill in the actual model-list endpoint when model scanning does not use the same address as model invocation.',
|
||||
apiKey: 'API Key',
|
||||
abilities: 'Abilities',
|
||||
selectModelAbilities: 'Select model abilities',
|
||||
@@ -218,6 +223,20 @@ const enUS = {
|
||||
providerCount: '{{count}} providers',
|
||||
// New keys for provider-based structure
|
||||
addModel: 'Add Model',
|
||||
manualAdd: 'Manual',
|
||||
scanAdd: 'Scan',
|
||||
scanModels: 'Scan Models',
|
||||
scanModelsHint:
|
||||
'Read available models from the current provider, then select which ones to add.',
|
||||
scannedModels: 'Scanned Models',
|
||||
scanDebug: 'Debug Info',
|
||||
searchScannedModels: 'Search scanned models',
|
||||
noScannedModels: 'No scan results yet. Click the button above to scan.',
|
||||
noScannedModelsMatch: 'No matching models',
|
||||
addSelectedModels: 'Add Selected',
|
||||
addSelectedModelsSuccess: '{{count}} model(s) added',
|
||||
selectAll: 'Select All',
|
||||
alreadyAdded: 'Already added',
|
||||
addLLMModel: 'Add LLM Model',
|
||||
addEmbeddingModel: 'Add Embedding Model',
|
||||
provider: 'Provider',
|
||||
@@ -253,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',
|
||||
@@ -595,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',
|
||||
@@ -1163,7 +1199,7 @@ const enUS = {
|
||||
contextInfo: 'Context Info',
|
||||
userId: 'User ID',
|
||||
messageId: 'Message ID',
|
||||
streamId: 'Stream ID',
|
||||
streamId: 'Related Query ID',
|
||||
inaccurateReasons: 'Inaccurate Reasons',
|
||||
platform: 'Platform',
|
||||
exportFeedback: 'Export Feedback',
|
||||
@@ -1194,6 +1230,7 @@ const enUS = {
|
||||
embeddingCalls: 'Embedding Calls',
|
||||
errors: 'Error Logs',
|
||||
sessions: 'Sessions',
|
||||
feedback: 'User Feedback',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
|
||||
@@ -38,6 +38,7 @@ const esES = {
|
||||
delete: 'Eliminar',
|
||||
add: 'Añadir',
|
||||
select: 'Seleccionar',
|
||||
skill: 'Habilidad',
|
||||
cancel: 'Cancelar',
|
||||
submit: 'Enviar',
|
||||
error: 'Error',
|
||||
@@ -185,6 +186,10 @@ const esES = {
|
||||
mustBeValidNumber: 'Debe ser un número válido',
|
||||
mustBeTrueOrFalse: 'Debe ser verdadero o falso',
|
||||
requestURL: 'URL de solicitud',
|
||||
scanURL: 'URL de escaneo de modelos',
|
||||
scanURLPlaceholder: 'Déjalo vacío para usar URL de solicitud + /models',
|
||||
scanURLDescription:
|
||||
'Ingresa el endpoint real de la lista de modelos cuando el escaneo de modelos no utiliza la misma dirección que la invocación del modelo.',
|
||||
apiKey: 'Clave API',
|
||||
abilities: 'Capacidades',
|
||||
selectModelAbilities: 'Seleccionar capacidades del modelo',
|
||||
@@ -227,6 +232,21 @@ const esES = {
|
||||
providerCount: '{{count}} proveedores',
|
||||
// New keys for provider-based structure
|
||||
addModel: 'Añadir modelo',
|
||||
manualAdd: 'Manual',
|
||||
scanAdd: 'Escanear',
|
||||
scanModels: 'Escanear modelos',
|
||||
scanModelsHint:
|
||||
'Lee los modelos disponibles del proveedor actual y luego elige cuáles agregar.',
|
||||
scannedModels: 'Modelos detectados',
|
||||
scanDebug: 'Información de depuración',
|
||||
searchScannedModels: 'Buscar modelos detectados',
|
||||
noScannedModels:
|
||||
'Todavía no hay resultados. Pulsa el botón superior para escanear.',
|
||||
noScannedModelsMatch: 'No hay modelos coincidentes',
|
||||
addSelectedModels: 'Agregar seleccionados',
|
||||
addSelectedModelsSuccess: 'Se agregaron {{count}} modelo(s)',
|
||||
selectAll: 'Seleccionar todo',
|
||||
alreadyAdded: 'Ya agregado',
|
||||
addLLMModel: 'Añadir modelo LLM',
|
||||
addEmbeddingModel: 'Añadir modelo Embedding',
|
||||
provider: 'Proveedor',
|
||||
@@ -262,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',
|
||||
@@ -606,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',
|
||||
@@ -891,6 +929,7 @@ const esES = {
|
||||
builtInParser: 'Proporcionado por el motor de conocimiento',
|
||||
noParserAvailable:
|
||||
'Ningún analizador admite este tipo de archivo. Por favor, instala un plugin de analizador que pueda manejar este formato.',
|
||||
installParserHint: 'Buscar plugins de analizador en el Marketplace →',
|
||||
confirmUpload: 'Subir',
|
||||
cancelUpload: 'Cancelar',
|
||||
},
|
||||
@@ -1193,7 +1232,7 @@ const esES = {
|
||||
contextInfo: 'Información de contexto',
|
||||
userId: 'ID de usuario',
|
||||
messageId: 'ID de mensaje',
|
||||
streamId: 'ID de flujo',
|
||||
streamId: 'ID de consulta relacionada',
|
||||
inaccurateReasons: 'Razones de inexactitud',
|
||||
platform: 'Plataforma',
|
||||
exportFeedback: 'Exportar comentarios',
|
||||
@@ -1224,6 +1263,7 @@ const esES = {
|
||||
embeddingCalls: 'Llamadas Embedding',
|
||||
errors: 'Registros de errores',
|
||||
sessions: 'Sesiones',
|
||||
feedback: 'Comentarios de usuarios',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const jaJP = {
|
||||
const jaJP = {
|
||||
sidebar: {
|
||||
home: 'ホーム',
|
||||
extensions: '拡張機能',
|
||||
@@ -37,6 +37,7 @@
|
||||
delete: '削除',
|
||||
add: '追加',
|
||||
select: '選択してください',
|
||||
skill: 'スキル',
|
||||
cancel: 'キャンセル',
|
||||
submit: '送信',
|
||||
error: 'エラー',
|
||||
@@ -184,6 +185,10 @@
|
||||
mustBeValidNumber: '有効な数値である必要があります',
|
||||
mustBeTrueOrFalse: 'true または false である必要があります',
|
||||
requestURL: 'リクエストURL',
|
||||
scanURL: 'モデルスキャンURL',
|
||||
scanURLPlaceholder: '空のままにするとリクエストURL + /modelsを使用します',
|
||||
scanURLDescription:
|
||||
'モデルスキャンがモデル呼び出しと同じアドレスを使用しない場合は、実際のモデルリストのエンドポイントを入力してください。',
|
||||
apiKey: 'APIキー',
|
||||
abilities: '機能',
|
||||
selectModelAbilities: 'モデル機能を選択',
|
||||
@@ -197,8 +202,6 @@
|
||||
string: '文字列',
|
||||
number: '数値',
|
||||
boolean: 'ブール値',
|
||||
extraParametersDescription:
|
||||
'リクエストボディに追加されるパラメータ(max_tokens、temperature、top_p など)',
|
||||
selectModelProvider: 'モデルプロバイダーを選択',
|
||||
modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',
|
||||
modelManufacturer: 'モデルメーカー',
|
||||
@@ -223,6 +226,21 @@
|
||||
'ローカルモデルがありません。作成ボタンをクリックしてモデルを追加してください。',
|
||||
providerCount: '{{count}} 件のプロバイダー',
|
||||
addModel: 'モデルを追加',
|
||||
manualAdd: '手動追加',
|
||||
scanAdd: 'スキャン追加',
|
||||
scanModels: 'モデルをスキャン',
|
||||
scanModelsHint:
|
||||
'現在のプロバイダーから利用可能なモデルを取得し、追加するモデルを選択します。',
|
||||
scannedModels: 'スキャン結果',
|
||||
scanDebug: 'デバッグ情報',
|
||||
searchScannedModels: 'スキャン結果を検索',
|
||||
noScannedModels:
|
||||
'まだスキャン結果がありません。上のボタンからスキャンしてください。',
|
||||
noScannedModelsMatch: '一致するモデルがありません',
|
||||
addSelectedModels: '選択したモデルを追加',
|
||||
addSelectedModelsSuccess: '{{count}} 件のモデルを追加しました',
|
||||
selectAll: 'すべて選択',
|
||||
alreadyAdded: '追加済み',
|
||||
addLLMModel: 'LLMモデルを追加',
|
||||
addEmbeddingModel: '埋め込みモデルを追加',
|
||||
provider: 'プロバイダー',
|
||||
@@ -258,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: '折りたたむ',
|
||||
@@ -370,6 +393,10 @@
|
||||
allLevels: 'すべてのレベル',
|
||||
selectLevel: 'レベルを選択',
|
||||
levelsSelected: 'レベル選択済み',
|
||||
viewDetailedLogs: '詳細ログを表示',
|
||||
viewDetails: '詳細',
|
||||
collapse: '折りたたむ',
|
||||
imagesAttached: '枚の画像が添付されています',
|
||||
noLogs: 'ログはまだありません',
|
||||
sessionMonitor: {
|
||||
title: 'セッション監視',
|
||||
@@ -596,6 +623,11 @@
|
||||
markAsReadFailed: '既読に設定に失敗しました',
|
||||
filterByComponent: 'コンポーネント',
|
||||
allComponents: '全部コンポーネント',
|
||||
filterByType: 'タイプ',
|
||||
allTypes: '全部',
|
||||
typePlugin: 'プラグイン',
|
||||
typeMCP: 'MCP',
|
||||
typeSkill: 'スキル',
|
||||
requestPlugin: 'プラグインをリクエスト',
|
||||
tags: {
|
||||
filterByTags: 'タグで絞り込み',
|
||||
@@ -604,6 +636,14 @@
|
||||
clearAll: 'クリア',
|
||||
noTags: 'タグがありません',
|
||||
},
|
||||
filters: {
|
||||
allFormats: 'すべての形式',
|
||||
more: 'もっと',
|
||||
advancedTitle: '高度なフィルター',
|
||||
advancedDescription: '拡張子タイプでフィルター',
|
||||
technicalType: '技術タイプ',
|
||||
},
|
||||
allExtensions: 'すべての拡張機能',
|
||||
viewDetails: '詳細を表示',
|
||||
deprecated: '非推奨',
|
||||
deprecatedTooltip:
|
||||
@@ -872,6 +912,7 @@
|
||||
builtInParser: '知識エンジンが提供',
|
||||
noParserAvailable:
|
||||
'このファイル形式に対応するパーサーがありません。対応するパーサープラグインをインストールしてください。',
|
||||
installParserHint: 'マーケットプレイスでパーサープラグインを探す →',
|
||||
confirmUpload: 'アップロード',
|
||||
cancelUpload: 'キャンセル',
|
||||
},
|
||||
@@ -902,9 +943,18 @@
|
||||
installEngineHint:
|
||||
'先に「ナレッジエンジン」プラグインをインストールしてください',
|
||||
unknownEngine: '不明なエンジン',
|
||||
knowledgeEngine: 'ナレッジエンジン',
|
||||
knowledgeEngineRequired: 'ナレッジエンジンは必須です',
|
||||
selectKnowledgeEngine: 'ナレッジエンジンを選択',
|
||||
builtInEngine: '組み込みエンジン',
|
||||
cannotChangeKnowledgeEngine:
|
||||
'作成後にナレッジエンジンを変更することはできません',
|
||||
createKnowledgeBaseFailed: 'ナレッジベースの作成に失敗しました:',
|
||||
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました:',
|
||||
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました:',
|
||||
getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました:',
|
||||
embeddingModel: 'Embeddingモデル',
|
||||
embeddingModelRequired: 'このエンジンにはEmbeddingモデルが必要です',
|
||||
addExternal: '外部ナレッジベースを追加',
|
||||
createExternalSuccess: '外部ナレッジベースが正常に作成されました',
|
||||
updateExternalSuccess: '外部ナレッジベースが正常に更新されました',
|
||||
@@ -1085,6 +1135,7 @@
|
||||
viewConversation: '会話詳細を表示',
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'LLM呼び出し',
|
||||
model: 'モデル',
|
||||
tokens: 'トークン数',
|
||||
duration: '期間',
|
||||
@@ -1093,6 +1144,8 @@
|
||||
inputTokens: '入力トークン',
|
||||
outputTokens: '出力トークン',
|
||||
totalTokens: '合計トークン数',
|
||||
avgDuration: '平均期間',
|
||||
calls: '呼び出し',
|
||||
},
|
||||
embeddingCalls: {
|
||||
title: 'Embedding呼び出し',
|
||||
@@ -1121,6 +1174,12 @@
|
||||
lastActivity: '最終アクティビティ',
|
||||
noSessions: 'セッションが見つかりません',
|
||||
startTime: '開始時刻',
|
||||
messageStats: 'メッセージ統計',
|
||||
totalMessages: '総メッセージ数',
|
||||
successMessages: '成功',
|
||||
errorMessages: '失敗',
|
||||
llmStats: 'LLM統計',
|
||||
noData: 'セッションが見つかりません',
|
||||
},
|
||||
errors: {
|
||||
errorType: 'エラータイプ',
|
||||
@@ -1145,7 +1204,7 @@
|
||||
contextInfo: 'コンテキスト情報',
|
||||
userId: 'ユーザーID',
|
||||
messageId: 'メッセージID',
|
||||
streamId: 'ストリームID',
|
||||
streamId: '関連質問ID',
|
||||
inaccurateReasons: '不正確な理由',
|
||||
platform: 'プラットフォーム',
|
||||
exportFeedback: 'フィードバックをエクスポート',
|
||||
@@ -1153,9 +1212,18 @@
|
||||
messageDetails: {
|
||||
noData: 'このクエリにはLLM呼び出しやエラーがありません',
|
||||
},
|
||||
queries: {
|
||||
title: 'クエリ',
|
||||
},
|
||||
queryVariables: {
|
||||
title: 'クエリ変数',
|
||||
},
|
||||
trafficChart: {
|
||||
title: 'トラフィック概要',
|
||||
messages: 'メッセージ',
|
||||
llmCalls: 'LLM呼び出し',
|
||||
noData: 'トラフィックデータがありません',
|
||||
},
|
||||
viewMonitoring: 'モニタリングを表示',
|
||||
refreshData: 'データを更新',
|
||||
exportData: 'データをエクスポート',
|
||||
@@ -1167,6 +1235,7 @@
|
||||
embeddingCalls: 'Embedding コール',
|
||||
errors: 'エラーログ',
|
||||
sessions: 'セッション',
|
||||
feedback: 'ユーザーフィードバック',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
|
||||
1319
web/src/i18n/locales/ru-RU.ts
Normal file
1319
web/src/i18n/locales/ru-RU.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@ const thTH = {
|
||||
delete: 'ลบ',
|
||||
add: 'เพิ่ม',
|
||||
select: 'เลือก',
|
||||
skill: 'สกิล',
|
||||
cancel: 'ยกเลิก',
|
||||
submit: 'ส่ง',
|
||||
error: 'ข้อผิดพลาด',
|
||||
@@ -179,6 +180,10 @@ const thTH = {
|
||||
mustBeValidNumber: 'ต้องเป็นตัวเลขที่ถูกต้อง',
|
||||
mustBeTrueOrFalse: 'ต้องเป็น true หรือ false',
|
||||
requestURL: 'URL คำขอ',
|
||||
scanURL: 'URL สแกนโมเดล',
|
||||
scanURLPlaceholder: 'เว้นว่างไว้เพื่อใช้ URL คำขอ + /models',
|
||||
scanURLDescription:
|
||||
'กรอกปลายทางรายการโมเดลจริงเมื่อการสแกนโมเดลไม่ได้ใช้ที่อยู่เดียวกับการเรียกใช้โมเดล',
|
||||
apiKey: 'API Key',
|
||||
abilities: 'ความสามารถ',
|
||||
selectModelAbilities: 'เลือกความสามารถของโมเดล',
|
||||
@@ -215,6 +220,20 @@ const thTH = {
|
||||
noLocalModels: 'ไม่มีโมเดลท้องถิ่น คลิกสร้างเพื่อเพิ่มโมเดล',
|
||||
providerCount: '{{count}} ผู้ให้บริการ',
|
||||
addModel: 'เพิ่มโมเดล',
|
||||
manualAdd: 'เพิ่มเอง',
|
||||
scanAdd: 'สแกน',
|
||||
scanModels: 'สแกนโมเดล',
|
||||
scanModelsHint:
|
||||
'ดึงรายการโมเดลที่ใช้ได้จากผู้ให้บริการปัจจุบัน แล้วเลือกโมเดลที่ต้องการเพิ่ม',
|
||||
scannedModels: 'ผลการสแกน',
|
||||
scanDebug: 'ข้อมูลการดีบัก',
|
||||
searchScannedModels: 'ค้นหาผลการสแกน',
|
||||
noScannedModels: 'ยังไม่มีผลการสแกน กดปุ่มด้านบนเพื่อเริ่มสแกน',
|
||||
noScannedModelsMatch: 'ไม่พบโมเดลที่ตรงกัน',
|
||||
addSelectedModels: 'เพิ่มที่เลือก',
|
||||
addSelectedModelsSuccess: 'เพิ่มแล้ว {{count}} โมเดล',
|
||||
selectAll: 'เลือกทั้งหมด',
|
||||
alreadyAdded: 'เพิ่มแล้ว',
|
||||
addLLMModel: 'เพิ่มโมเดล LLM',
|
||||
addEmbeddingModel: 'เพิ่มโมเดล Embedding',
|
||||
provider: 'ผู้ให้บริการ',
|
||||
@@ -249,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: 'ย่อ',
|
||||
@@ -586,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: 'เลือกแล้ว',
|
||||
@@ -859,6 +896,7 @@ const thTH = {
|
||||
builtInParser: 'จัดเตรียมโดยเครื่องมือความรู้',
|
||||
noParserAvailable:
|
||||
'ไม่มีตัวแยกวิเคราะห์ที่รองรับไฟล์ประเภทนี้ กรุณาติดตั้งปลั๊กอินตัวแยกวิเคราะห์ที่สามารถจัดการรูปแบบนี้ได้',
|
||||
installParserHint: 'เรียกดูปลั๊กอินตัวแยกวิเคราะห์ใน Marketplace →',
|
||||
confirmUpload: 'อัปโหลด',
|
||||
cancelUpload: 'ยกเลิก',
|
||||
},
|
||||
@@ -1141,7 +1179,7 @@ const thTH = {
|
||||
contextInfo: 'ข้อมูลบริบท',
|
||||
userId: 'ID ผู้ใช้',
|
||||
messageId: 'ID ข้อความ',
|
||||
streamId: 'ID สตรีม',
|
||||
streamId: 'ID คำถามที่เกี่ยวข้อง',
|
||||
inaccurateReasons: 'เหตุผลที่ไม่ถูกต้อง',
|
||||
platform: 'แพลตฟอร์ม',
|
||||
exportFeedback: 'ส่งออกความคิดเห็น',
|
||||
@@ -1172,6 +1210,7 @@ const thTH = {
|
||||
embeddingCalls: 'การเรียก Embedding',
|
||||
errors: 'บันทึกข้อผิดพลาด',
|
||||
sessions: 'เซสชัน',
|
||||
feedback: 'ความคิดเห็นผู้ใช้',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
|
||||
@@ -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',
|
||||
@@ -182,6 +183,10 @@ const viVN = {
|
||||
mustBeValidNumber: 'Phải là một số hợp lệ',
|
||||
mustBeTrueOrFalse: 'Phải là true hoặc false',
|
||||
requestURL: 'URL yêu cầu',
|
||||
scanURL: 'URL quét mô hình',
|
||||
scanURLPlaceholder: 'Để trống để sử dụng URL yêu cầu + /models',
|
||||
scanURLDescription:
|
||||
'Điền điểm cuối danh sách mô hình thực tế khi quét mô hình không sử dụng cùng địa chỉ với việc gọi mô hình.',
|
||||
apiKey: 'API Key',
|
||||
abilities: 'Khả năng',
|
||||
selectModelAbilities: 'Chọn khả năng mô hình',
|
||||
@@ -222,6 +227,20 @@ const viVN = {
|
||||
noLocalModels: 'Không có mô hình cục bộ. Nhấn Tạo để thêm mô hình.',
|
||||
providerCount: '{{count}} nhà cung cấp',
|
||||
addModel: 'Thêm mô hình',
|
||||
manualAdd: 'Thủ công',
|
||||
scanAdd: 'Quét',
|
||||
scanModels: 'Quét mô hình',
|
||||
scanModelsHint:
|
||||
'Đọc danh sách mô hình khả dụng từ nhà cung cấp hiện tại rồi chọn mô hình cần thêm.',
|
||||
scannedModels: 'Kết quả quét',
|
||||
scanDebug: 'Thông tin gỡ lỗi',
|
||||
searchScannedModels: 'Tìm trong kết quả quét',
|
||||
noScannedModels: 'Chưa có kết quả quét. Nhấn nút phía trên để bắt đầu.',
|
||||
noScannedModelsMatch: 'Không có mô hình phù hợp',
|
||||
addSelectedModels: 'Thêm mục đã chọn',
|
||||
addSelectedModelsSuccess: 'Đã thêm {{count}} mô hình',
|
||||
selectAll: 'Chọn tất cả',
|
||||
alreadyAdded: 'Đã thêm',
|
||||
addLLMModel: 'Thêm mô hình LLM',
|
||||
addEmbeddingModel: 'Thêm mô hình Embedding',
|
||||
provider: 'Nhà cung cấp',
|
||||
@@ -257,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',
|
||||
@@ -598,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',
|
||||
@@ -871,6 +908,7 @@ const viVN = {
|
||||
builtInParser: 'Được cung cấp bởi Công cụ tri thức',
|
||||
noParserAvailable:
|
||||
'Không có trình phân tích hỗ trợ loại tệp này. Vui lòng cài đặt plugin trình phân tích có thể xử lý định dạng này.',
|
||||
installParserHint: 'Duyệt plugin trình phân tích trong Marketplace →',
|
||||
confirmUpload: 'Tải lên',
|
||||
cancelUpload: 'Hủy',
|
||||
},
|
||||
@@ -1162,7 +1200,7 @@ const viVN = {
|
||||
contextInfo: 'Thông tin ngữ cảnh',
|
||||
userId: 'ID người dùng',
|
||||
messageId: 'ID tin nhắn',
|
||||
streamId: 'ID luồng',
|
||||
streamId: 'ID câu hỏi liên quan',
|
||||
inaccurateReasons: 'Lý do không chính xác',
|
||||
platform: 'Nền tảng',
|
||||
exportFeedback: 'Xuất phản hồi',
|
||||
@@ -1193,6 +1231,7 @@ const viVN = {
|
||||
embeddingCalls: 'Cuộc gọi Embedding',
|
||||
errors: 'Nhật ký lỗi',
|
||||
sessions: 'Phiên',
|
||||
feedback: 'Phản hồi người dùng',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
|
||||
@@ -35,6 +35,7 @@ const zhHans = {
|
||||
delete: '删除',
|
||||
add: '添加',
|
||||
select: '请选择',
|
||||
skill: '技能',
|
||||
cancel: '取消',
|
||||
submit: '提交',
|
||||
error: '错误',
|
||||
@@ -173,6 +174,10 @@ const zhHans = {
|
||||
mustBeValidNumber: '必须是有效的数字',
|
||||
mustBeTrueOrFalse: '必须是 true 或 false',
|
||||
requestURL: '请求URL',
|
||||
scanURL: '扫描模型地址',
|
||||
scanURLPlaceholder: '留空则默认使用请求URL + /models',
|
||||
scanURLDescription:
|
||||
'当模型扫描接口与模型调用接口不是同一个地址时,在这里填写实际的模型列表接口。',
|
||||
apiKey: 'API Key',
|
||||
abilities: '能力',
|
||||
selectModelAbilities: '选择模型能力',
|
||||
@@ -209,6 +214,19 @@ const zhHans = {
|
||||
providerCount: '共 {{count}} 个自定义供应商',
|
||||
// 供应商结构新增键
|
||||
addModel: '添加模型',
|
||||
manualAdd: '手动添加',
|
||||
scanAdd: '扫描添加',
|
||||
scanModels: '扫描模型',
|
||||
scanModelsHint: '从当前供应商接口读取可用模型,然后勾选要添加的模型。',
|
||||
scannedModels: '扫描结果',
|
||||
scanDebug: '调试信息',
|
||||
searchScannedModels: '搜索扫描结果',
|
||||
noScannedModels: '还没有扫描结果,点击上方按钮开始扫描。',
|
||||
noScannedModelsMatch: '没有匹配的模型',
|
||||
addSelectedModels: '添加所选模型',
|
||||
addSelectedModelsSuccess: '已添加 {{count}} 个模型',
|
||||
selectAll: '全选模型',
|
||||
alreadyAdded: '已添加',
|
||||
addLLMModel: '添加对话模型',
|
||||
addEmbeddingModel: '添加嵌入模型',
|
||||
provider: '供应商',
|
||||
@@ -243,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: '收起',
|
||||
@@ -557,6 +579,7 @@ const zhHans = {
|
||||
description: '描述',
|
||||
tagLabel: '标签',
|
||||
submissionTitle: '您有插件提交正在审核中: {{name}}',
|
||||
submissionPending: '您的插件提交正在审核中: {{name}}',
|
||||
submissionApproved: '您的插件提交已通过审核: {{name}}',
|
||||
submissionRejected: '您的插件提交已被拒绝: {{name}}',
|
||||
clickToRevoke: '撤回',
|
||||
@@ -568,6 +591,11 @@ const zhHans = {
|
||||
markAsReadFailed: '标记为已读失败',
|
||||
filterByComponent: '组件',
|
||||
allComponents: '全部组件',
|
||||
filterByType: '类型',
|
||||
allTypes: '全部类型',
|
||||
typePlugin: '插件',
|
||||
typeMCP: 'MCP',
|
||||
typeSkill: '技能',
|
||||
requestPlugin: '请求插件',
|
||||
tags: {
|
||||
filterByTags: '按标签筛选',
|
||||
@@ -576,6 +604,14 @@ const zhHans = {
|
||||
clearAll: '清空',
|
||||
noTags: '暂无标签',
|
||||
},
|
||||
filters: {
|
||||
allFormats: '全部格式',
|
||||
more: '更多',
|
||||
advancedTitle: '高级筛选',
|
||||
advancedDescription: '按扩展类型筛选',
|
||||
technicalType: '技术类型',
|
||||
},
|
||||
allExtensions: '全部扩展',
|
||||
viewDetails: '查看详情',
|
||||
deprecated: '已弃用',
|
||||
deprecatedTooltip: '请安装对应「知识引擎」插件',
|
||||
@@ -1109,7 +1145,7 @@ const zhHans = {
|
||||
contextInfo: '上下文信息',
|
||||
userId: '用户ID',
|
||||
messageId: '消息ID',
|
||||
streamId: '流ID',
|
||||
streamId: '关联提问ID',
|
||||
inaccurateReasons: '不准确原因',
|
||||
platform: '平台',
|
||||
exportFeedback: '导出反馈',
|
||||
@@ -1140,6 +1176,7 @@ const zhHans = {
|
||||
embeddingCalls: 'Embedding 调用',
|
||||
errors: '错误日志',
|
||||
sessions: '会话记录',
|
||||
feedback: '用户反馈',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
|
||||
@@ -35,6 +35,7 @@ const zhHant = {
|
||||
delete: '刪除',
|
||||
add: '新增',
|
||||
select: '請選擇',
|
||||
skill: '技能',
|
||||
cancel: '取消',
|
||||
submit: '提交',
|
||||
error: '錯誤',
|
||||
@@ -173,6 +174,10 @@ const zhHant = {
|
||||
mustBeValidNumber: '必須是有效的數字',
|
||||
mustBeTrueOrFalse: '必須是 true 或 false',
|
||||
requestURL: '請求URL',
|
||||
scanURL: '掃描模型 URL',
|
||||
scanURLPlaceholder: '留空則使用請求 URL + /models',
|
||||
scanURLDescription:
|
||||
'當模型掃描與模型調用不使用相同地址時,請填寫實際的模型列表端點。',
|
||||
apiKey: 'API Key',
|
||||
abilities: '能力',
|
||||
selectModelAbilities: '選擇模型能力',
|
||||
@@ -208,6 +213,19 @@ const zhHant = {
|
||||
noLocalModels: '暫無本地模型。點擊建立按鈕新增模型。',
|
||||
providerCount: '共 {{count}} 個供應商',
|
||||
addModel: '新增模型',
|
||||
manualAdd: '手動添加',
|
||||
scanAdd: '掃描添加',
|
||||
scanModels: '掃描模型',
|
||||
scanModelsHint: '從目前供應商介面讀取可用模型,然後勾選要添加的模型。',
|
||||
scannedModels: '掃描結果',
|
||||
scanDebug: '調試資訊',
|
||||
searchScannedModels: '搜尋掃描結果',
|
||||
noScannedModels: '尚無掃描結果,點擊上方按鈕開始掃描。',
|
||||
noScannedModelsMatch: '沒有符合的模型',
|
||||
addSelectedModels: '添加所選模型',
|
||||
addSelectedModelsSuccess: '已添加 {{count}} 個模型',
|
||||
selectAll: '全選模型',
|
||||
alreadyAdded: '已添加',
|
||||
addLLMModel: '新增對話模型',
|
||||
addEmbeddingModel: '新增嵌入模型',
|
||||
provider: '供應商',
|
||||
@@ -242,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: '收起',
|
||||
@@ -349,6 +372,10 @@ const zhHant = {
|
||||
allLevels: '全部級別',
|
||||
selectLevel: '選擇級別',
|
||||
levelsSelected: '個級別已選',
|
||||
viewDetailedLogs: '查看詳細日誌',
|
||||
viewDetails: '詳情',
|
||||
collapse: '收起',
|
||||
imagesAttached: '張圖片已附加',
|
||||
noLogs: '暫無日誌',
|
||||
sessionMonitor: {
|
||||
title: '會話監控',
|
||||
@@ -382,6 +409,7 @@ const zhHant = {
|
||||
marketplace: 'Marketplace',
|
||||
arrange: '編排',
|
||||
install: '安裝',
|
||||
installPlugin: '安裝外掛',
|
||||
installFromGithub: '來自 GitHub',
|
||||
onlySupportGithub: '目前僅支援從 GitHub 安裝',
|
||||
enterGithubLink: '請輸入外掛的Github連結',
|
||||
@@ -431,6 +459,7 @@ const zhHant = {
|
||||
deleteError: '刪除失敗:',
|
||||
close: '關閉',
|
||||
deleteConfirm: '刪除確認',
|
||||
deleteSuccess: '刪除成功',
|
||||
modifyFailed: '修改失敗:',
|
||||
componentName: {
|
||||
Tool: '工具',
|
||||
@@ -550,6 +579,7 @@ const zhHant = {
|
||||
description: '描述',
|
||||
tagLabel: '標籤',
|
||||
submissionTitle: '您有插件提交正在審核中: {{name}}',
|
||||
submissionPending: '您的插件提交正在審核中: {{name}}',
|
||||
submissionApproved: '您的插件提交已通過審核: {{name}}',
|
||||
submissionRejected: '您的插件提交已被拒絕: {{name}}',
|
||||
clickToRevoke: '撤回',
|
||||
@@ -561,6 +591,11 @@ const zhHant = {
|
||||
markAsReadFailed: '標記為已讀失敗',
|
||||
filterByComponent: '組件',
|
||||
allComponents: '全部組件',
|
||||
filterByType: '類型',
|
||||
allTypes: '全部類型',
|
||||
typePlugin: '插件',
|
||||
typeMCP: 'MCP',
|
||||
typeSkill: '技能',
|
||||
requestPlugin: '請求插件',
|
||||
tags: {
|
||||
filterByTags: '按標籤篩選',
|
||||
@@ -569,6 +604,14 @@ const zhHant = {
|
||||
clearAll: '清空',
|
||||
noTags: '暫無標籤',
|
||||
},
|
||||
filters: {
|
||||
allFormats: '全部格式',
|
||||
more: '更多',
|
||||
advancedTitle: '高級篩選',
|
||||
advancedDescription: '按擴展類型篩選',
|
||||
technicalType: '技術類型',
|
||||
},
|
||||
allExtensions: '全部擴展',
|
||||
viewDetails: '查看詳情',
|
||||
deprecated: '已棄用',
|
||||
deprecatedTooltip: '請安裝對應「知識引擎」插件',
|
||||
@@ -854,9 +897,17 @@ const zhHant = {
|
||||
noEnginesAvailable: '沒有可用的知識庫引擎',
|
||||
installEngineHint: '請先安裝「知識引擎」插件',
|
||||
unknownEngine: '未知引擎',
|
||||
knowledgeEngine: '知識引擎',
|
||||
knowledgeEngineRequired: '知識引擎為必填項',
|
||||
selectKnowledgeEngine: '選擇知識引擎',
|
||||
builtInEngine: '內建引擎',
|
||||
cannotChangeKnowledgeEngine: '建立後無法更改知識引擎',
|
||||
createKnowledgeBaseFailed: '知識庫建立失敗:',
|
||||
loadKnowledgeBaseFailed: '知識庫載入失敗:',
|
||||
deleteKnowledgeBaseFailed: '知識庫刪除失敗:',
|
||||
getKnowledgeBaseListError: '取得知識庫列表失敗:',
|
||||
embeddingModel: 'Embedding模型',
|
||||
embeddingModelRequired: '此引擎需要Embedding模型',
|
||||
addExternal: '添加外部知識庫',
|
||||
createExternalSuccess: '外部知識庫創建成功',
|
||||
updateExternalSuccess: '外部知識庫更新成功',
|
||||
@@ -1025,6 +1076,7 @@ const zhHant = {
|
||||
viewConversation: '顯示對話詳情',
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'LLM呼叫',
|
||||
model: '模型',
|
||||
tokens: '代幣數',
|
||||
duration: '持續時間',
|
||||
@@ -1033,6 +1085,8 @@ const zhHant = {
|
||||
inputTokens: '輸入代幣',
|
||||
outputTokens: '輸出代幣',
|
||||
totalTokens: '總代幣數',
|
||||
avgDuration: '平均持續時間',
|
||||
calls: '呼叫次數',
|
||||
},
|
||||
embeddingCalls: {
|
||||
title: 'Embedding調用',
|
||||
@@ -1061,6 +1115,12 @@ const zhHant = {
|
||||
lastActivity: '最後活動',
|
||||
noSessions: '未找到會話',
|
||||
startTime: '開始時間',
|
||||
messageStats: '訊息統計',
|
||||
totalMessages: '總訊息數',
|
||||
successMessages: '成功',
|
||||
errorMessages: '失敗',
|
||||
llmStats: 'LLM統計',
|
||||
noData: '未找到會話',
|
||||
},
|
||||
errors: {
|
||||
errorType: '錯誤類型',
|
||||
@@ -1085,7 +1145,7 @@ const zhHant = {
|
||||
contextInfo: '上下文資訊',
|
||||
userId: '使用者ID',
|
||||
messageId: '訊息ID',
|
||||
streamId: '串流ID',
|
||||
streamId: '關聯提問ID',
|
||||
inaccurateReasons: '不準確原因',
|
||||
platform: '平台',
|
||||
exportFeedback: '匯出反饋',
|
||||
@@ -1093,9 +1153,18 @@ const zhHant = {
|
||||
messageDetails: {
|
||||
noData: '此查詢沒有LLM調用或錯誤記錄',
|
||||
},
|
||||
queries: {
|
||||
title: '查詢',
|
||||
},
|
||||
queryVariables: {
|
||||
title: '查詢變數',
|
||||
},
|
||||
trafficChart: {
|
||||
title: '流量概覽',
|
||||
messages: '訊息',
|
||||
llmCalls: 'LLM呼叫',
|
||||
noData: '暫無流量資料',
|
||||
},
|
||||
viewMonitoring: '查看日誌監控',
|
||||
refreshData: '重新整理資料',
|
||||
exportData: '匯出資料',
|
||||
@@ -1107,6 +1176,7 @@ const zhHant = {
|
||||
embeddingCalls: 'Embedding 呼叫',
|
||||
errors: '錯誤日誌',
|
||||
sessions: '會話記錄',
|
||||
feedback: '使用者回饋',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
|
||||
Reference in New Issue
Block a user