Compare commits

..

3 Commits

Author SHA1 Message Date
WangCham
3b3deec080 feat: modify frontend 2026-05-04 17:50:19 +08:00
WangCham
58ec377413 feat: add filter 2026-05-02 23:02:56 +08:00
WangCham
7c50aabe65 feat: add mcp and skills 2026-05-02 17:38:18 +08:00
27 changed files with 673 additions and 1365 deletions

View File

@@ -77,7 +77,6 @@ dependencies = [
"pymilvus>=2.6.4",
"pgvector>=0.4.1",
"botocore>=1.42.39",
"litellm>=1.0.0",
]
keywords = [
"bot",

View File

@@ -179,7 +179,7 @@ class SpaceService:
space_url = space_config['url']
session = httpclient.get_session()
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
async with session.get(f'{space_url}/api/v1/models') as response:
if response.status != 200:
raise ValueError(f'Failed to get models: {await response.text()}')
data = await response.json()

View File

@@ -4,7 +4,6 @@ import sqlalchemy
import traceback
from . import requester
from .requesters import litellmchat
from ...core import app
from ...discover import engine
from . import token
@@ -43,13 +42,6 @@ class ModelManager:
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
for component in self.requester_components:
# Skip components that use litellm_provider (they will use litellmchat.py instead)
if component.spec.get('litellm_provider'):
self.ap.logger.debug(
f'Skipping Python class loading for {component.metadata.name} '
f'(uses litellm_provider={component.spec.get("litellm_provider")})'
)
continue
requester_dict[component.metadata.name] = component.get_python_component_class()
self.requester_dict = requester_dict
@@ -268,34 +260,13 @@ class ModelManager:
else:
provider_entity = provider_info
# Get requester manifest to check for litellm_provider
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
# Build config from base_url
config = {'base_url': provider_entity.base_url}
# Check if requester manifest specifies litellm_provider
if requester_manifest and requester_manifest.spec.get('litellm_provider'):
# Use unified LiteLLMRequester with provider prefix
# Map litellm_provider (YAML spec) to custom_llm_provider (config)
config['custom_llm_provider'] = requester_manifest.spec['litellm_provider']
requester_inst = litellmchat.LiteLLMRequester(
ap=self.ap,
config=config,
)
self.ap.logger.debug(
f'Using LiteLLMRequester for {provider_entity.requester} '
f'with custom_llm_provider={config["custom_llm_provider"]}'
)
else:
# Use original requester class (for backward compatibility)
if provider_entity.requester not in self.requester_dict:
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
requester_inst = self.requester_dict[provider_entity.requester](
ap=self.ap,
config=config,
)
if provider_entity.requester not in self.requester_dict:
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},
)
await requester_inst.initialize()
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])

View File

@@ -67,8 +67,8 @@ class RuntimeProvider:
if isinstance(result, tuple):
msg, usage_info = result
if usage_info:
input_tokens = usage_info.get('prompt_tokens', 0)
output_tokens = usage_info.get('completion_tokens', 0)
input_tokens = usage_info.get('input_tokens', 0)
output_tokens = usage_info.get('output_tokens', 0)
return msg
else:
return result
@@ -128,6 +128,7 @@ class RuntimeProvider:
start_time = time.time()
status = 'success'
error_message = None
# Note: Stream doesn't easily provide token counts, set to 0
input_tokens = 0
output_tokens = 0
@@ -142,15 +143,6 @@ class RuntimeProvider:
remove_think=remove_think,
):
yield chunk
# Extract usage from stream if available (stored by LiteLLM requester)
if query:
if query.variables is None:
query.variables = {}
if '_stream_usage' in query.variables:
usage_info = query.variables['_stream_usage']
input_tokens = usage_info.get('prompt_tokens', 0)
output_tokens = usage_info.get('completion_tokens', 0)
del query.variables['_stream_usage']
except Exception as e:
status = 'error'
error_message = str(e)

View File

@@ -1,397 +0,0 @@
"""LiteLLM unified requester for chat, embedding, and rerank."""
from __future__ import annotations
import typing
import litellm
from litellm import acompletion, aembedding, arerank
from .. import errors, requester
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
class LiteLLMRequester(requester.ProviderAPIRequester):
"""LiteLLM unified API requester supporting chat, embedding, and rerank."""
default_config: dict[str, typing.Any] = {
'base_url': '',
'timeout': 120,
'custom_llm_provider': '',
'drop_params': False,
'num_retries': 0,
'api_version': '',
}
async def initialize(self):
"""Initialize LiteLLM client settings."""
# LiteLLM doesn't require explicit client initialization
# Configuration is passed per-request via litellm params
pass
def _build_litellm_model_name(self, model_name: str, custom_llm_provider: str | None = None) -> str:
"""Build LiteLLM model name with provider prefix if needed."""
provider = custom_llm_provider or self.requester_cfg.get('custom_llm_provider', '')
if provider:
# LiteLLM format: provider/model_name
return f'{provider}/{model_name}'
# If no custom provider, assume model_name already includes prefix or is OpenAI-compatible
return model_name
def _convert_messages(self, messages: typing.List[provider_message.Message]) -> list[dict]:
"""Convert LangBot messages to LiteLLM/OpenAI format."""
req_messages = []
for m in messages:
msg_dict = m.dict(exclude_none=True)
content = msg_dict.get('content')
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get('type') == 'image_base64':
part['image_url'] = {'url': part['image_base64']}
part['type'] = 'image_url'
del part['image_base64']
req_messages.append(msg_dict)
return req_messages
def _process_thinking_content(self, content: str, reasoning_content: str | None, remove_think: bool) -> str:
"""Process thinking/reasoning content.
Args:
content: The main content from response
reasoning_content: Separate reasoning content from model
remove_think: If True, remove thinking markers; if False, preserve them
Returns:
Processed content string
"""
# Extract and handle thinking tags
if content and 'CRETIRE_REASONING_BEGINk' in content and 'CRETIRE_REASONING_ENDk' in content:
import re
think_pattern = r'CRETIRE_REASONING_BEGINk(.*?)CRETIRE_REASONING_ENDk'
if remove_think:
# Remove thinking tags and their content from output
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
# else: preserve thinking content as-is
# Handle separate reasoning_content field
# Currently we don't include reasoning_content in user-facing output regardless of remove_think
# because it's typically internal model reasoning, not user-visible thinking
return content or ''
def _extract_usage(self, response) -> dict:
"""Extract usage info from LiteLLM response."""
usage = response.usage
return {
'prompt_tokens': usage.prompt_tokens or 0,
'completion_tokens': usage.completion_tokens or 0,
'total_tokens': usage.total_tokens or 0,
}
def _build_common_args(self, args: dict, include_retry_params: bool = True) -> dict:
"""Apply common requester config to args dict."""
if self.requester_cfg.get('base_url'):
args['api_base'] = self.requester_cfg['base_url']
if self.requester_cfg.get('timeout'):
args['timeout'] = self.requester_cfg['timeout']
if include_retry_params:
if self.requester_cfg.get('drop_params'):
args['drop_params'] = self.requester_cfg['drop_params']
if self.requester_cfg.get('num_retries'):
args['num_retries'] = self.requester_cfg['num_retries']
if self.requester_cfg.get('api_version'):
args['api_version'] = self.requester_cfg['api_version']
return args
def _handle_litellm_error(self, e: Exception) -> None:
"""Convert LiteLLM exceptions to RequesterError. Never returns, always raises."""
# Check more specific exceptions first (they inherit from base exceptions)
if isinstance(e, litellm.ContextWindowExceededError):
raise errors.RequesterError(f'上下文长度超限: {str(e)}')
if isinstance(e, litellm.BadRequestError):
raise errors.RequesterError(f'请求参数错误: {str(e)}')
if isinstance(e, litellm.AuthenticationError):
raise errors.RequesterError(f'API key 无效: {str(e)}')
if isinstance(e, litellm.NotFoundError):
raise errors.RequesterError(f'模型或路径无效: {str(e)}')
if isinstance(e, litellm.RateLimitError):
raise errors.RequesterError(f'请求过于频繁或余额不足: {str(e)}')
if isinstance(e, litellm.Timeout):
raise errors.RequesterError(f'请求超时: {str(e)}')
if isinstance(e, litellm.APIConnectionError):
raise errors.RequesterError(f'连接错误: {str(e)}')
if isinstance(e, litellm.APIError):
raise errors.RequesterError(f'API 错误: {str(e)}')
raise errors.RequesterError(f'未知错误: {str(e)}')
async def _build_completion_args(
self,
model: requester.RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
stream: bool = False,
) -> dict:
"""Build common completion arguments for invoke_llm and invoke_llm_stream."""
req_messages = self._convert_messages(messages)
model_name = self._build_litellm_model_name(model.model_entity.name)
api_key = model.provider.token_mgr.get_token()
args = {
'model': model_name,
'messages': req_messages,
'api_key': api_key,
}
if stream:
args['stream'] = True
args['stream_options'] = {'include_usage': True}
self._build_common_args(args)
args.update(extra_args)
if funcs:
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
if tools:
args['tools'] = tools
return args
async def invoke_llm(
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,
) -> tuple[provider_message.Message, dict]:
"""Invoke LLM and return message with usage info."""
args = await self._build_completion_args(model, messages, funcs, extra_args, stream=False)
try:
response = await acompletion(**args)
message_data = response.choices[0].message.model_dump()
if 'role' not in message_data or message_data['role'] is None:
message_data['role'] = 'assistant'
content = message_data.get('content', '')
reasoning_content = message_data.get('reasoning_content', None)
message_data['content'] = self._process_thinking_content(content, reasoning_content, remove_think)
if 'reasoning_content' in message_data:
del message_data['reasoning_content']
message = provider_message.Message(**message_data)
usage_info = self._extract_usage(response)
return message, usage_info
except Exception as e:
self._handle_litellm_error(e)
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:
"""Invoke LLM streaming and yield chunks."""
args = await self._build_completion_args(model, messages, funcs, extra_args, stream=True)
chunk_idx = 0
role = 'assistant'
try:
response = await acompletion(**args)
async for chunk in response:
# Check for usage chunk (final chunk with stream_options include_usage)
if hasattr(chunk, 'usage') and chunk.usage and (not hasattr(chunk, 'choices') or not chunk.choices):
usage_info = {
'prompt_tokens': chunk.usage.prompt_tokens or 0,
'completion_tokens': chunk.usage.completion_tokens or 0,
'total_tokens': chunk.usage.total_tokens or 0,
}
if query:
if query.variables is None:
query.variables = {}
query.variables['_stream_usage'] = usage_info
continue
if not hasattr(chunk, 'choices') or not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
finish_reason = getattr(choice, 'finish_reason', None)
if 'role' in delta and delta['role']:
role = delta['role']
delta_content = delta.get('content', '')
reasoning_content = delta.get('reasoning_content', '')
if reasoning_content:
chunk_idx += 1
continue
if chunk_idx == 0 and not delta_content and not delta.get('tool_calls'):
chunk_idx += 1
continue
chunk_data = {
'role': role,
'content': delta_content if delta_content else None,
'tool_calls': delta.get('tool_calls'),
'is_final': bool(finish_reason),
}
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 Exception as e:
self._handle_litellm_error(e)
async def invoke_embedding(
self,
model: requester.RuntimeEmbeddingModel,
input_text: list[str],
extra_args: dict[str, typing.Any] = {},
) -> tuple[list[list[float]], dict]:
"""Invoke embedding and return vectors with usage info."""
model_name = self._build_litellm_model_name(model.model_entity.name)
api_key = model.provider.token_mgr.get_token()
args = {
'model': model_name,
'input': input_text,
'api_key': api_key,
}
self._build_common_args(args, include_retry_params=False)
if model.model_entity.extra_args:
args.update(model.model_entity.extra_args)
args.update(extra_args)
try:
response = await aembedding(**args)
embeddings = [d.embedding for d in response.data]
usage_info = self._extract_usage(response)
return embeddings, usage_info
except Exception as e:
self._handle_litellm_error(e)
async def invoke_rerank(
self,
model: requester.RuntimeRerankModel,
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[dict]:
"""Invoke rerank and return relevance scores."""
model_name = self._build_litellm_model_name(model.model_entity.name)
api_key = model.provider.token_mgr.get_token()
args = {
'model': model_name,
'query': query,
'documents': documents,
'api_key': api_key,
'top_n': min(len(documents), 64),
}
self._build_common_args(args, include_retry_params=False)
if model.model_entity.extra_args:
args.update(model.model_entity.extra_args)
args.update(extra_args)
try:
response = await arerank(**args)
results = []
for r in response.results:
results.append(
{
'index': r.get('index', 0),
'relevance_score': r.get('relevance_score', 0.0),
}
)
if results:
scores = [r['relevance_score'] 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 Exception as e:
self._handle_litellm_error(e)
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
"""Scan models supported by the provider."""
import httpx
base_url = self.requester_cfg.get('base_url', '').rstrip('/')
timeout = self.requester_cfg.get('timeout', 120)
if not base_url:
raise errors.RequesterError('Base URL required for model scanning')
headers = {}
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
models_url = f'{base_url}/models'
try:
async with httpx.AsyncClient(trust_env=True, timeout=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
# Infer model type
normalized_id = (model_id or '').lower()
embedding_keywords = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
model_type = 'embedding' if any(kw in normalized_id for kw in embedding_keywords) else 'llm'
models.append(
{
'id': model_id,
'name': model_id,
'type': model_type,
}
)
models.sort(key=lambda x: (x['type'] != 'llm', x['name'].lower()))
return {'models': models}
except httpx.HTTPStatusError as e:
raise errors.RequesterError(f'Model scan failed: {e.response.status_code}')
except httpx.TimeoutException:
raise errors.RequesterError('Model scan timeout')
except Exception as e:
raise errors.RequesterError(f'Model scan error: {str(e)}')

View File

@@ -1,64 +0,0 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: litellm-chat
label:
en_US: LiteLLM (Unified)
zh_Hans: LiteLLM (统一请求器)
icon: litellm.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: false
default: ''
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
- name: custom_llm_provider
label:
en_US: Custom Provider
zh_Hans: 自定义 Provider
type: string
required: false
default: ''
description:
en_US: Force provider type (e.g., anthropic, openai, gemini)
zh_Hans: 强制指定 provider 类型(如 anthropic, openai, gemini
- name: drop_params
label:
en_US: Drop Unsupported Params
zh_Hans: 丢弃不支持参数
type: boolean
required: false
default: false
- name: num_retries
label:
en_US: Number of Retries
zh_Hans: 重试次数
type: integer
required: false
default: 0
- name: api_version
label:
en_US: API Version
zh_Hans: API 版本
type: string
required: false
default: ''
support_type:
- llm
- text-embedding
- rerank
provider_category: unified
execution:
python:
path: ./litellmchat.py
attr: LiteLLMRequester

View File

@@ -57,6 +57,41 @@ class ToolManager:
return tools
async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list:
"""为anthropic生成函数列表
e.g.
[
{
"name": "get_stock_price",
"description": "Get the current stock price for a given ticker symbol.",
"input_schema": {
"type": "object",
"properties": {
"ticker": {
"type": "string",
"description": "The stock ticker symbol, e.g. AAPL for Apple Inc."
}
},
"required": ["ticker"]
}
}
]
"""
tools = []
for function in use_funcs:
function_schema = {
'name': function.name,
'description': function.description,
'input_schema': function.parameters,
}
tools.append(function_schema)
return tools
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
"""执行函数调用"""

View File

@@ -1 +0,0 @@
"""Provider requester tests"""

View File

@@ -1,633 +0,0 @@
"""
Tests for LiteLLMRequester - unified requester for chat, embedding, and rerank.
These tests verify:
- Parameter building and LiteLLM API calls
- Response processing and usage extraction
- Error handling and exception translation
- Model name building with provider prefix
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
import litellm
from langbot.pkg.provider.modelmgr.requesters import litellmchat
from langbot.pkg.provider.modelmgr import errors
class MockRuntimeModel:
"""Mock RuntimeLLMModel for testing"""
def __init__(self, model_name: str = 'gpt-4o', api_key: str = 'test-key'):
self.model_entity = Mock()
self.model_entity.name = model_name
self.model_entity.extra_args = {}
self.provider = Mock()
self.provider.token_mgr = Mock()
self.provider.token_mgr.get_token = Mock(return_value=api_key)
class MockRuntimeEmbeddingModel:
"""Mock RuntimeEmbeddingModel for testing"""
def __init__(self, model_name: str = 'text-embedding-3-small', api_key: str = 'test-key'):
self.model_entity = Mock()
self.model_entity.name = model_name
self.model_entity.extra_args = {}
self.provider = Mock()
self.provider.token_mgr = Mock()
self.provider.token_mgr.get_token = Mock(return_value=api_key)
class MockRuntimeRerankModel:
"""Mock RuntimeRerankModel for testing"""
def __init__(self, model_name: str = 'cohere/rerank-english-v3.0', api_key: str = 'test-key'):
self.model_entity = Mock()
self.model_entity.name = model_name
self.model_entity.extra_args = {}
self.provider = Mock()
self.provider.token_mgr = Mock()
self.provider.token_mgr.get_token = Mock(return_value=api_key)
class TestBuildLiteLLMModelName:
"""Test _build_litellm_model_name method"""
def test_no_provider_prefix(self):
"""Test model name without provider prefix"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': ''})
result = requester._build_litellm_model_name('gpt-4o')
assert result == 'gpt-4o'
def test_with_provider_prefix(self):
"""Test model name with provider prefix"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'openai'})
result = requester._build_litellm_model_name('gpt-4o')
assert result == 'openai/gpt-4o'
def test_override_provider(self):
"""Test override provider via parameter"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'openai'})
result = requester._build_litellm_model_name('claude-3', custom_llm_provider='anthropic')
assert result == 'anthropic/claude-3'
class TestExtractUsage:
"""Test _extract_usage method"""
def test_extract_usage_with_data(self):
"""Test extraction with valid usage data"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
response = Mock()
response.usage = Mock()
response.usage.prompt_tokens = 100
response.usage.completion_tokens = 50
response.usage.total_tokens = 150
result = requester._extract_usage(response)
assert result['prompt_tokens'] == 100
assert result['completion_tokens'] == 50
assert result['total_tokens'] == 150
def test_extract_usage_with_zero_values(self):
"""Test extraction when values are 0"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
response = Mock()
response.usage = Mock()
response.usage.prompt_tokens = 0
response.usage.completion_tokens = 0
response.usage.total_tokens = 0
result = requester._extract_usage(response)
assert result['prompt_tokens'] == 0
assert result['completion_tokens'] == 0
class TestProcessThinkingContent:
"""Test _process_thinking_content method"""
def test_no_thinking_markers(self):
"""Test content without thinking markers"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
result = requester._process_thinking_content('Hello world', None, remove_think=True)
assert result == 'Hello world'
def test_remove_thinking_markers(self):
"""Test removing thinking markers when remove_think=True"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = 'CRETIRE_REASONING_BEGINkLet me think...CRETIRE_REASONING_ENDk The answer is 42.'
result = requester._process_thinking_content(content, None, remove_think=True)
assert result == 'The answer is 42.'
def test_preserve_thinking_markers(self):
"""Test preserving thinking markers when remove_think=False"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
content = 'CRETIRE_REASONING_BEGINkLet me think...CRETIRE_REASONING_ENDk The answer is 42.'
result = requester._process_thinking_content(content, None, remove_think=False)
assert 'CRETIRE_REASONING_BEGINk' in result
assert 'The answer is 42.' in result
def test_empty_content(self):
"""Test empty content"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
result = requester._process_thinking_content('', None, remove_think=True)
assert result == ''
class TestBuildCommonArgs:
"""Test _build_common_args method"""
def test_build_args_with_all_params(self):
"""Test building args with all config params"""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.openai.com/v1',
'timeout': 60,
'drop_params': True,
'num_retries': 3,
'api_version': '2024-01-01',
},
)
args = {}
requester._build_common_args(args)
assert args['api_base'] == 'https://api.openai.com/v1'
assert args['timeout'] == 60
assert args['drop_params'] == True
assert args['num_retries'] == 3
assert args['api_version'] == '2024-01-01'
def test_build_args_without_retry_params(self):
"""Test building args without retry params for embedding/rerank"""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.openai.com/v1',
'timeout': 60,
'num_retries': 3,
},
)
args = {}
requester._build_common_args(args, include_retry_params=False)
assert args['api_base'] == 'https://api.openai.com/v1'
assert args['timeout'] == 60
assert 'num_retries' not in args
class TestHandleLiteLLMError:
"""Test _handle_litellm_error method"""
def test_bad_request_error(self):
"""Test BadRequestError translation"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
# Create proper LiteLLM exception with required args
error = litellm.BadRequestError(message='test error', model='gpt-4o', llm_provider='openai')
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert '请求参数错误' in str(exc_info.value)
def test_authentication_error(self):
"""Test AuthenticationError translation"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
error = litellm.AuthenticationError(message='invalid key', model='gpt-4o', llm_provider='openai')
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert 'API key 无效' in str(exc_info.value)
def test_rate_limit_error(self):
"""Test RateLimitError translation"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
error = litellm.RateLimitError(message='rate limited', model='gpt-4o', llm_provider='openai')
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert '请求过于频繁' in str(exc_info.value)
def test_timeout_error(self):
"""Test Timeout translation"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
error = litellm.Timeout(message='timeout', model='gpt-4o', llm_provider='openai')
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert '请求超时' in str(exc_info.value)
def test_context_window_error(self):
"""Test ContextWindowExceededError translation"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
error = litellm.ContextWindowExceededError(message='context too long', model='gpt-4o', llm_provider='openai')
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert '上下文长度超限' in str(exc_info.value)
def test_unknown_error(self):
"""Test unknown error translation"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(Exception('unknown'))
assert '未知错误' in str(exc_info.value)
class TestInvokeLLM:
"""Test invoke_llm method"""
@pytest.mark.asyncio
async def test_invoke_llm_basic(self):
"""Test basic LLM invocation"""
mock_ap = Mock()
mock_ap.tool_mgr = Mock()
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
requester = litellmchat.LiteLLMRequester(
ap=mock_ap,
config={
'base_url': 'https://api.openai.com/v1',
'timeout': 60,
},
)
model = MockRuntimeModel('gpt-4o', 'test-api-key')
# Mock LiteLLM response
mock_response = Mock()
mock_response.choices = [Mock()]
mock_response.choices[0].message = Mock()
mock_response.choices[0].message.model_dump = Mock(
return_value={
'role': 'assistant',
'content': 'Hello! How can I help you?',
}
)
mock_response.usage = Mock()
mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 20
mock_response.usage.total_tokens = 30
import langbot_plugin.api.entities.builtin.provider.message as provider_message
messages = [provider_message.Message(role='user', content='Hello')]
# Patch acompletion at the import location
with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, return_value=mock_response):
result_msg, usage = await requester.invoke_llm(
query=None,
model=model,
messages=messages,
)
assert result_msg.role == 'assistant'
assert result_msg.content == 'Hello! How can I help you?'
assert usage['prompt_tokens'] == 10
assert usage['completion_tokens'] == 20
@pytest.mark.asyncio
async def test_invoke_llm_with_tools(self):
"""Test LLM invocation with function calling"""
mock_ap = Mock()
mock_ap.tool_mgr = Mock()
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(
return_value=[{'type': 'function', 'function': {'name': 'get_weather'}}]
)
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
model = MockRuntimeModel('gpt-4o', 'test-api-key')
mock_response = Mock()
mock_response.choices = [Mock()]
mock_response.choices[0].message = Mock()
mock_response.choices[0].message.model_dump = Mock(
return_value={
'role': 'assistant',
'content': None,
'tool_calls': [
{'id': 'call_123', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{}'}}
],
}
)
mock_response.usage = Mock()
mock_response.usage.prompt_tokens = 15
mock_response.usage.completion_tokens = 10
mock_response.usage.total_tokens = 25
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
messages = [provider_message.Message(role='user', content='What is the weather?')]
# Create proper LLMTool with all required fields
funcs = [Mock(spec=resource_tool.LLMTool)]
funcs[0].name = 'get_weather'
funcs[0].description = 'Get weather'
with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, return_value=mock_response):
result_msg, usage = await requester.invoke_llm(
query=None,
model=model,
messages=messages,
funcs=funcs,
)
assert result_msg.tool_calls is not None
@pytest.mark.asyncio
async def test_invoke_llm_error_handling(self):
"""Test LLM invocation error handling"""
mock_ap = Mock()
mock_ap.tool_mgr = Mock()
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
model = MockRuntimeModel('gpt-4o', 'test-api-key')
import langbot_plugin.api.entities.builtin.provider.message as provider_message
messages = [provider_message.Message(role='user', content='Hello')]
error = litellm.AuthenticationError(message='invalid key', model='gpt-4o', llm_provider='openai')
with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, side_effect=error):
with pytest.raises(errors.RequesterError) as exc_info:
await requester.invoke_llm(
query=None,
model=model,
messages=messages,
)
assert 'API key 无效' in str(exc_info.value)
class TestInvokeEmbedding:
"""Test invoke_embedding method"""
@pytest.mark.asyncio
async def test_invoke_embedding_basic(self):
"""Test basic embedding invocation"""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.openai.com/v1',
},
)
model = MockRuntimeEmbeddingModel('text-embedding-3-small', 'test-api-key')
# Mock LiteLLM embedding response
mock_response = Mock()
mock_response.data = [
Mock(embedding=[0.1, 0.2, 0.3]),
Mock(embedding=[0.4, 0.5, 0.6]),
]
mock_response.usage = Mock()
mock_response.usage.prompt_tokens = 20
mock_response.usage.completion_tokens = 0
mock_response.usage.total_tokens = 20
with patch.object(litellmchat, 'aembedding', new_callable=AsyncMock, return_value=mock_response):
embeddings, usage = await requester.invoke_embedding(
model=model,
input_text=['Hello', 'World'],
)
assert len(embeddings) == 2
assert embeddings[0] == [0.1, 0.2, 0.3]
assert embeddings[1] == [0.4, 0.5, 0.6]
assert usage['prompt_tokens'] == 20
class TestInvokeRerank:
"""Test invoke_rerank method"""
@pytest.mark.asyncio
async def test_invoke_rerank_basic(self):
"""Test basic rerank invocation"""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.cohere.ai',
},
)
model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key')
# Mock LiteLLM rerank response
mock_response = Mock()
mock_response.results = [
{'index': 0, 'relevance_score': 0.95},
{'index': 1, 'relevance_score': 0.3},
{'index': 2, 'relevance_score': 0.8},
]
with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response):
results = await requester.invoke_rerank(
model=model,
query='What is the capital of France?',
documents=['Paris is the capital.', 'London is a city.', 'France is in Europe.'],
)
assert len(results) == 3
# Scores should be normalized
assert results[0]['index'] == 0
assert results[0]['relevance_score'] >= 0 and results[0]['relevance_score'] <= 1
@pytest.mark.asyncio
async def test_invoke_rerank_normalization(self):
"""Test rerank score normalization"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key')
# Mock response with varying scores
mock_response = Mock()
mock_response.results = [
{'index': 0, 'relevance_score': 0.9},
{'index': 1, 'relevance_score': 0.1},
]
with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response):
results = await requester.invoke_rerank(
model=model,
query='test query',
documents=['doc1', 'doc2'],
)
# After normalization: 0.9 -> 1.0, 0.1 -> 0.0
assert results[0]['relevance_score'] == 1.0
assert results[1]['relevance_score'] == 0.0
@pytest.mark.asyncio
async def test_invoke_rerank_single_document(self):
"""Test rerank with single document (no normalization needed)"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key')
mock_response = Mock()
mock_response.results = [
{'index': 0, 'relevance_score': 0.5},
]
with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response):
results = await requester.invoke_rerank(
model=model,
query='test query',
documents=['doc1'],
)
assert len(results) == 1
# Single score stays as is (min==max, no normalization)
assert results[0]['relevance_score'] == 0.5
class TestConvertMessages:
"""Test _convert_messages method"""
def test_convert_simple_message(self):
"""Test converting simple text message"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
import langbot_plugin.api.entities.builtin.provider.message as provider_message
messages = [provider_message.Message(role='user', content='Hello')]
result = requester._convert_messages(messages)
assert len(result) == 1
assert result[0]['role'] == 'user'
assert result[0]['content'] == 'Hello'
def test_convert_message_with_image_base64(self):
"""Test converting message with image_base64 content"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
import langbot_plugin.api.entities.builtin.provider.message as provider_message
messages = [
provider_message.Message(
role='user',
content=[
{'type': 'text', 'text': 'What is in this image?'},
{'type': 'image_base64', 'image_base64': 'data:image/png;base64,abc123'},
],
)
]
result = requester._convert_messages(messages)
assert len(result) == 1
content = result[0]['content']
assert isinstance(content, list)
# Check image_base64 converted to image_url
image_part = [p for p in content if p.get('type') == 'image_url'][0]
assert 'image_url' in image_part
assert image_part['image_url']['url'] == 'data:image/png;base64,abc123'
def test_convert_message_with_multiple_text_parts(self):
"""Test converting message with multiple text parts (LiteLLM handles this)"""
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
import langbot_plugin.api.entities.builtin.provider.message as provider_message
messages = [
provider_message.Message(
role='user',
content=[
{'type': 'text', 'text': 'Hello'},
{'type': 'text', 'text': 'World'},
],
)
]
result = requester._convert_messages(messages)
assert len(result) == 1
# LiteLLM handles multiple text parts, we pass them through
assert isinstance(result[0]['content'], list)
class TestScanModels:
"""Test scan_models method"""
@pytest.mark.asyncio
async def test_scan_models_basic(self):
"""Test basic model scanning"""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://api.openai.com/v1',
'timeout': 60,
},
)
# Mock httpx response
mock_response = Mock()
mock_response.json = Mock(
return_value={
'data': [
{'id': 'gpt-4o'},
{'id': 'text-embedding-3-small'},
{'id': 'gpt-3.5-turbo'},
]
}
)
mock_response.raise_for_status = Mock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=Mock())
mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
result = await requester.scan_models(api_key='test-key')
assert 'models' in result
assert len(result['models']) == 3
# Check LLM models are first
assert result['models'][0]['type'] == 'llm'
# Check embedding model is detected
embedding_models = [m for m in result['models'] if m['type'] == 'embedding']
assert len(embedding_models) == 1
@pytest.mark.asyncio
async def test_scan_models_no_base_url(self):
"""Test scan_models without base_url raises error"""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': '',
},
)
with pytest.raises(errors.RequesterError) as exc_info:
await requester.scan_models()
assert 'Base URL required' in str(exc_info.value)
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ const esES = {
delete: 'Eliminar',
add: 'Añadir',
select: 'Seleccionar',
skill: 'Habilidad',
cancel: 'Cancelar',
submit: 'Enviar',
error: 'Error',
@@ -630,11 +631,24 @@ const esES = {
markAsReadFailed: 'Error al marcar como leído',
filterByComponent: 'Componente',
allComponents: 'Todos los componentes',
filterByType: 'Tipo',
allTypes: 'Todos los tipos',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Habilidad',
requestPlugin: 'Solicitar plugin',
viewDetails: 'Ver detalles',
deprecated: 'Obsoleto',
deprecatedTooltip:
'Por favor, instala el plugin de motor de conocimiento correspondiente.',
filters: {
allFormats: 'Todos los formatos',
more: 'Más',
advancedTitle: 'Filtros avanzados',
advancedDescription: 'Filtrar por tipo de extensión',
technicalType: 'Tipo técnico',
},
allExtensions: 'Todas las extensiones',
tags: {
filterByTags: 'Filtrar por etiquetas',
selected: 'seleccionadas',

View File

@@ -1,4 +1,4 @@
const jaJP = {
const jaJP = {
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
@@ -37,6 +37,7 @@
delete: '削除',
add: '追加',
select: '選択してください',
skill: 'スキル',
cancel: 'キャンセル',
submit: '送信',
error: 'エラー',
@@ -622,6 +623,11 @@
markAsReadFailed: '既読に設定に失敗しました',
filterByComponent: 'コンポーネント',
allComponents: '全部コンポーネント',
filterByType: 'タイプ',
allTypes: '全部',
typePlugin: 'プラグイン',
typeMCP: 'MCP',
typeSkill: 'スキル',
requestPlugin: 'プラグインをリクエスト',
tags: {
filterByTags: 'タグで絞り込み',
@@ -630,6 +636,14 @@
clearAll: 'クリア',
noTags: 'タグがありません',
},
filters: {
allFormats: 'すべての形式',
more: 'もっと',
advancedTitle: '高度なフィルター',
advancedDescription: '拡張子タイプでフィルター',
technicalType: '技術タイプ',
},
allExtensions: 'すべての拡張機能',
viewDetails: '詳細を表示',
deprecated: '非推奨',
deprecatedTooltip:

View File

@@ -36,6 +36,7 @@ const ruRU = {
delete: 'Удалить',
add: 'Добавить',
select: 'Выбрать',
skill: 'Навык',
cancel: 'Отмена',
submit: 'Отправить',
error: 'Ошибка',
@@ -627,11 +628,24 @@ const ruRU = {
markAsReadFailed: 'Не удалось отметить как прочитанное',
filterByComponent: 'Компонент',
allComponents: 'Все компоненты',
filterByType: 'Тип',
allTypes: 'Все типы',
typePlugin: 'Плагин',
typeMCP: 'MCP',
typeSkill: 'Навык',
requestPlugin: 'Запросить плагин',
viewDetails: 'Подробнее',
deprecated: 'Устаревший',
deprecatedTooltip:
'Пожалуйста, установите соответствующий плагин движка знаний.',
filters: {
allFormats: 'Все форматы',
more: 'Ещё',
advancedTitle: 'Расширенные фильтры',
advancedDescription: 'Фильтр по типу расширения',
technicalType: 'Технический тип',
},
allExtensions: 'Все расширения',
tags: {
filterByTags: 'Фильтр по тегам',
selected: 'выбрано',

View File

@@ -36,6 +36,7 @@ const thTH = {
delete: 'ลบ',
add: 'เพิ่ม',
select: 'เลือก',
skill: 'สกิล',
cancel: 'ยกเลิก',
submit: 'ส่ง',
error: 'ข้อผิดพลาด',
@@ -609,10 +610,23 @@ const thTH = {
markAsReadFailed: 'ทำเครื่องหมายว่าอ่านแล้วล้มเหลว',
filterByComponent: 'ส่วนประกอบ',
allComponents: 'ส่วนประกอบทั้งหมด',
filterByType: 'ประเภท',
allTypes: 'ทุกประเภท',
typePlugin: 'ปลั๊กอิน',
typeMCP: 'MCP',
typeSkill: 'สกิล',
requestPlugin: 'ขอปลั๊กอิน',
viewDetails: 'ดูรายละเอียด',
deprecated: 'เลิกใช้แล้ว',
deprecatedTooltip: 'กรุณาติดตั้งปลั๊กอินเครื่องมือความรู้ที่เกี่ยวข้อง',
filters: {
allFormats: 'ทุกรูปแบบ',
more: 'เพิ่มเติม',
advancedTitle: 'ตัวกรองขั้นสูง',
advancedDescription: 'กรองตามประเภทส่วนขยาย',
technicalType: 'ประเภทเทคนิค',
},
allExtensions: 'ส่วนขยายทั้งหมด',
tags: {
filterByTags: 'กรองตามแท็ก',
selected: 'เลือกแล้ว',

View File

@@ -36,6 +36,7 @@ const viVN = {
delete: 'Xóa',
add: 'Thêm',
select: 'Chọn',
skill: 'Kỹ năng',
cancel: 'Hủy',
submit: 'Gửi',
error: 'Lỗi',
@@ -621,10 +622,23 @@ const viVN = {
markAsReadFailed: 'Đánh dấu đã đọc thất bại',
filterByComponent: 'Thành phần',
allComponents: 'Tất cả thành phần',
filterByType: 'Loại',
allTypes: 'Tất cả loại',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Kỹ năng',
requestPlugin: 'Yêu cầu Plugin',
viewDetails: 'Xem chi tiết',
deprecated: 'Không còn hỗ trợ',
deprecatedTooltip: 'Vui lòng cài đặt plugin Công cụ tri thức tương ứng.',
filters: {
allFormats: 'Tất cả định dạng',
more: 'Thêm',
advancedTitle: 'Bộ lọc nâng cao',
advancedDescription: 'Lọc theo loại phần mở rộng',
technicalType: 'Loại kỹ thuật',
},
allExtensions: 'Tất cả phần mở rộng',
tags: {
filterByTags: 'Lọc theo thẻ',
selected: 'đã chọn',

View File

@@ -35,6 +35,7 @@ const zhHans = {
delete: '删除',
add: '添加',
select: '请选择',
skill: '技能',
cancel: '取消',
submit: '提交',
error: '错误',
@@ -590,6 +591,11 @@ const zhHans = {
markAsReadFailed: '标记为已读失败',
filterByComponent: '组件',
allComponents: '全部组件',
filterByType: '类型',
allTypes: '全部类型',
typePlugin: '插件',
typeMCP: 'MCP',
typeSkill: '技能',
requestPlugin: '请求插件',
tags: {
filterByTags: '按标签筛选',
@@ -598,6 +604,14 @@ const zhHans = {
clearAll: '清空',
noTags: '暂无标签',
},
filters: {
allFormats: '全部格式',
more: '更多',
advancedTitle: '高级筛选',
advancedDescription: '按扩展类型筛选',
technicalType: '技术类型',
},
allExtensions: '全部扩展',
viewDetails: '查看详情',
deprecated: '已弃用',
deprecatedTooltip: '请安装对应「知识引擎」插件',

View File

@@ -35,6 +35,7 @@ const zhHant = {
delete: '刪除',
add: '新增',
select: '請選擇',
skill: '技能',
cancel: '取消',
submit: '提交',
error: '錯誤',
@@ -590,6 +591,11 @@ const zhHant = {
markAsReadFailed: '標記為已讀失敗',
filterByComponent: '組件',
allComponents: '全部組件',
filterByType: '類型',
allTypes: '全部類型',
typePlugin: '插件',
typeMCP: 'MCP',
typeSkill: '技能',
requestPlugin: '請求插件',
tags: {
filterByTags: '按標籤篩選',
@@ -598,6 +604,14 @@ const zhHant = {
clearAll: '清空',
noTags: '暫無標籤',
},
filters: {
allFormats: '全部格式',
more: '更多',
advancedTitle: '高級篩選',
advancedDescription: '按擴展類型篩選',
technicalType: '技術類型',
},
allExtensions: '全部擴展',
viewDetails: '查看詳情',
deprecated: '已棄用',
deprecatedTooltip: '請安裝對應「知識引擎」插件',