mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
Compare commits
3 Commits
feat/litel
...
feat/add-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b3deec080 | ||
|
|
58ec377413 | ||
|
|
7c50aabe65 |
@@ -77,7 +77,6 @@ dependencies = [
|
||||
"pymilvus>=2.6.4",
|
||||
"pgvector>=0.4.1",
|
||||
"botocore>=1.42.39",
|
||||
"litellm>=1.0.0",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}')
|
||||
@@ -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
|
||||
@@ -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:
|
||||
"""执行函数调用"""
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Provider requester tests"""
|
||||
@@ -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'])
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,49 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
sort_order?: string,
|
||||
component_filter?: string,
|
||||
tags_filter?: string[],
|
||||
type_filter?: string,
|
||||
): Promise<ApiRespMarketplacePlugins> {
|
||||
// Use different endpoints based on type_filter
|
||||
if (type_filter === 'mcp') {
|
||||
return this.post<{ mcps: PluginV4[]; total: number }>(
|
||||
'/api/v1/marketplace/mcps/search',
|
||||
{
|
||||
query,
|
||||
page,
|
||||
page_size,
|
||||
sort_by,
|
||||
sort_order,
|
||||
tags_filter,
|
||||
},
|
||||
).then((resp) => ({
|
||||
plugins: (resp?.mcps || []).map((mcp) => ({
|
||||
...mcp,
|
||||
plugin_id: mcp.mcp_id || mcp.plugin_id,
|
||||
type: 'mcp' as const,
|
||||
})),
|
||||
total: resp?.total || 0,
|
||||
}));
|
||||
} else if (type_filter === 'skill') {
|
||||
return this.post<{ skills: PluginV4[]; total: number }>(
|
||||
'/api/v1/marketplace/skills/search',
|
||||
{
|
||||
query,
|
||||
page,
|
||||
page_size,
|
||||
sort_by,
|
||||
sort_order,
|
||||
tags_filter,
|
||||
},
|
||||
).then((resp) => ({
|
||||
plugins: (resp?.skills || []).map((skill) => ({
|
||||
...skill,
|
||||
plugin_id: skill.skill_id || skill.plugin_id,
|
||||
type: 'skill' as const,
|
||||
})),
|
||||
total: resp?.total || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return this.post<ApiRespMarketplacePlugins>(
|
||||
'/api/v1/marketplace/plugins/search',
|
||||
{
|
||||
@@ -49,6 +91,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
sort_order,
|
||||
component_filter,
|
||||
tags_filter,
|
||||
type_filter,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const enUS = {
|
||||
delete: 'Delete',
|
||||
add: 'Add',
|
||||
select: 'Select',
|
||||
skill: 'Skill',
|
||||
cancel: 'Cancel',
|
||||
submit: 'Submit',
|
||||
error: 'Error',
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: 'выбрано',
|
||||
|
||||
@@ -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: 'เลือกแล้ว',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '请安装对应「知识引擎」插件',
|
||||
|
||||
@@ -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: '請安裝對應「知識引擎」插件',
|
||||
|
||||
Reference in New Issue
Block a user