mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-15 18:26:02 +00:00
Compare commits
9 Commits
codex/agen
...
codex/surv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d71597f1 | ||
|
|
e9fe2f2d43 | ||
|
|
27be09ab15 | ||
|
|
1ef4507d9a | ||
|
|
2e7978317c | ||
|
|
b7d8332cb0 | ||
|
|
7fe3eedeea | ||
|
|
b6fde30aa7 | ||
|
|
5bfa38cbf2 |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.1"
|
version = "4.10.2"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.4.3",
|
"langbot-plugin==0.4.4",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.10.1'
|
__version__ = '4.10.2'
|
||||||
|
|||||||
@@ -271,6 +271,20 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
||||||
return self.success(data={'readme': readme})
|
return self.success(data={'readme': readme})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<author>/<plugin_name>/logs',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||||
|
)
|
||||||
|
async def _(author: str, plugin_name: str) -> quart.Response:
|
||||||
|
try:
|
||||||
|
limit = int(quart.request.args.get('limit', 200))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 200
|
||||||
|
level = quart.request.args.get('level') or None
|
||||||
|
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
|
||||||
|
return self.success(data={'logs': logs})
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<author>/<plugin_name>/icon',
|
'/<author>/<plugin_name>/icon',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ class LoadConfigStage(stage.BootingStage):
|
|||||||
constants.instance_id = new_id
|
constants.instance_id = new_id
|
||||||
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
||||||
|
|
||||||
|
# Instance creation timestamp: sourced from data/labels/instance_id.json.
|
||||||
|
# Instances created before this field existed (or supplied via
|
||||||
|
# system.instance_id) won't have it, so backfill with the current time
|
||||||
|
# and persist it via the dump below — from then on it stays stable.
|
||||||
|
instance_create_ts = ap.instance_id.data.get('instance_create_ts', 0)
|
||||||
|
if not isinstance(instance_create_ts, int) or instance_create_ts <= 0:
|
||||||
|
instance_create_ts = int(time.time())
|
||||||
|
ap.instance_id.data['instance_create_ts'] = instance_create_ts
|
||||||
|
constants.instance_create_ts = instance_create_ts
|
||||||
|
|
||||||
print(f'LangBot instance id: {constants.instance_id}')
|
print(f'LangBot instance id: {constants.instance_id}')
|
||||||
print(f'LangBot edition: {constants.edition}')
|
print(f'LangBot edition: {constants.edition}')
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,18 @@ class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
):
|
):
|
||||||
self.listeners.pop(event_type, None)
|
self.listeners.pop(event_type, None)
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
"""Delegate stream output check to ws_adapter."""
|
||||||
|
if self._ws_adapter is not None:
|
||||||
|
return await self._ws_adapter.is_stream_output_supported()
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def create_message_card(self, message_id: str | int, event: platform_events.MessageEvent) -> bool:
|
||||||
|
"""Delegate create_message_card to ws_adapter."""
|
||||||
|
if self._ws_adapter is not None:
|
||||||
|
return await self._ws_adapter.create_message_card(message_id, event)
|
||||||
|
return False
|
||||||
|
|
||||||
async def is_muted(self, group_id: int) -> bool:
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -689,6 +689,16 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
||||||
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
|
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
|
||||||
|
|
||||||
|
async def get_plugin_logs(
|
||||||
|
self,
|
||||||
|
plugin_author: str,
|
||||||
|
plugin_name: str,
|
||||||
|
limit: int = 200,
|
||||||
|
level: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
# Not cached: logs are live and change constantly.
|
||||||
|
return await self.handler.get_plugin_logs(plugin_author, plugin_name, limit, level)
|
||||||
|
|
||||||
@alru_cache(ttl=5 * 60)
|
@alru_cache(ttl=5 * 60)
|
||||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||||
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||||
|
|||||||
@@ -953,6 +953,31 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
return readme_bytes.decode('utf-8')
|
return readme_bytes.decode('utf-8')
|
||||||
|
|
||||||
|
async def get_plugin_logs(
|
||||||
|
self,
|
||||||
|
plugin_author: str,
|
||||||
|
plugin_name: str,
|
||||||
|
limit: int = 200,
|
||||||
|
level: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get recent log lines captured from the plugin's stderr."""
|
||||||
|
try:
|
||||||
|
result = await self.call_action(
|
||||||
|
LangBotToRuntimeAction.GET_PLUGIN_LOGS,
|
||||||
|
{
|
||||||
|
'plugin_author': plugin_author,
|
||||||
|
'plugin_name': plugin_name,
|
||||||
|
'limit': limit,
|
||||||
|
'level': level,
|
||||||
|
},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
return []
|
||||||
|
|
||||||
|
return result.get('logs', [])
|
||||||
|
|
||||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||||
"""Get plugin assets"""
|
"""Get plugin assets"""
|
||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
|
||||||
|
|
||||||
|
LLM_USAGE_QUERY_VARIABLE = '_llm_usage'
|
||||||
|
STREAM_USAGE_QUERY_VARIABLE = '_stream_usage'
|
||||||
|
|
||||||
|
|
||||||
|
def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None:
|
||||||
|
"""Store the latest provider usage on the query for upstream action handlers."""
|
||||||
|
if query is None or not usage_info:
|
||||||
|
return
|
||||||
|
if query.variables is None:
|
||||||
|
query.variables = {}
|
||||||
|
query.variables[LLM_USAGE_QUERY_VARIABLE] = dict(usage_info)
|
||||||
|
|
||||||
|
|
||||||
class RuntimeProvider:
|
class RuntimeProvider:
|
||||||
"""运行时模型提供商"""
|
"""运行时模型提供商"""
|
||||||
|
|
||||||
@@ -67,6 +80,7 @@ class RuntimeProvider:
|
|||||||
if isinstance(result, tuple):
|
if isinstance(result, tuple):
|
||||||
msg, usage_info = result
|
msg, usage_info = result
|
||||||
if usage_info:
|
if usage_info:
|
||||||
|
_store_llm_usage(query, usage_info)
|
||||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||||
output_tokens = usage_info.get('completion_tokens', 0)
|
output_tokens = usage_info.get('completion_tokens', 0)
|
||||||
return msg
|
return msg
|
||||||
@@ -146,11 +160,12 @@ class RuntimeProvider:
|
|||||||
if query:
|
if query:
|
||||||
if query.variables is None:
|
if query.variables is None:
|
||||||
query.variables = {}
|
query.variables = {}
|
||||||
if '_stream_usage' in query.variables:
|
if STREAM_USAGE_QUERY_VARIABLE in query.variables:
|
||||||
usage_info = query.variables['_stream_usage']
|
usage_info = query.variables[STREAM_USAGE_QUERY_VARIABLE]
|
||||||
|
_store_llm_usage(query, usage_info)
|
||||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||||
output_tokens = usage_info.get('completion_tokens', 0)
|
output_tokens = usage_info.get('completion_tokens', 0)
|
||||||
del query.variables['_stream_usage']
|
del query.variables[STREAM_USAGE_QUERY_VARIABLE]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status = 'error'
|
status = 'error'
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
|
|||||||
@@ -75,14 +75,10 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
continue
|
continue
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _context_length_from_scan_payload(self, model_payload: dict[str, typing.Any] | None) -> int | None:
|
@staticmethod
|
||||||
if not model_payload:
|
def _positive_int(value: typing.Any) -> int | None:
|
||||||
return None
|
|
||||||
|
|
||||||
for field_name in ('context_length', 'context_window', 'max_context_length'):
|
|
||||||
value = model_payload.get(field_name)
|
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
continue
|
return None
|
||||||
if isinstance(value, int) and value > 0:
|
if isinstance(value, int) and value > 0:
|
||||||
return value
|
return value
|
||||||
if isinstance(value, str) and value.isdigit():
|
if isinstance(value, str) and value.isdigit():
|
||||||
@@ -91,6 +87,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
return parsed_value
|
return parsed_value
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _context_length_from_scan_payload(self, model_payload: dict[str, typing.Any] | None) -> int | None:
|
||||||
|
if not model_payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for field_name in ('context_length', 'context_window', 'max_context_length'):
|
||||||
|
context_length = self._positive_int(model_payload.get(field_name))
|
||||||
|
if context_length is not None:
|
||||||
|
return context_length
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _context_length_from_litellm_model_info(self, model_info: typing.Any) -> int | None:
|
||||||
|
if isinstance(model_info, dict):
|
||||||
|
return self._positive_int(model_info.get('max_input_tokens'))
|
||||||
|
return self._positive_int(getattr(model_info, 'max_input_tokens', None))
|
||||||
|
|
||||||
def _metadata_provider_candidates(self, model_name: str) -> list[str]:
|
def _metadata_provider_candidates(self, model_name: str) -> list[str]:
|
||||||
normalized_model_name = (model_name or '').lower()
|
normalized_model_name = (model_name or '').lower()
|
||||||
candidates = []
|
candidates = []
|
||||||
@@ -126,7 +137,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _safe_context_length(self, model_name: str) -> int | None:
|
def _safe_context_length(self, model_name: str) -> int | None:
|
||||||
helper = getattr(litellm, 'get_max_tokens', None)
|
helper = getattr(litellm, 'get_model_info', None)
|
||||||
if not callable(helper):
|
if not callable(helper):
|
||||||
return self._known_context_length_fallback(model_name)
|
return self._known_context_length_fallback(model_name)
|
||||||
|
|
||||||
@@ -143,11 +154,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
continue
|
continue
|
||||||
tried_candidates.append(candidate)
|
tried_candidates.append(candidate)
|
||||||
try:
|
try:
|
||||||
max_tokens = helper(candidate)
|
model_info = helper(candidate)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if isinstance(max_tokens, int) and max_tokens > 0:
|
context_length = self._context_length_from_litellm_model_info(model_info)
|
||||||
return max_tokens
|
if context_length is not None:
|
||||||
|
return context_length
|
||||||
return self._known_context_length_fallback(model_name)
|
return self._known_context_length_fallback(model_name)
|
||||||
|
|
||||||
def _supports_function_calling(self, model_name: str) -> bool:
|
def _supports_function_calling(self, model_name: str) -> bool:
|
||||||
@@ -250,32 +262,82 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
- dict with the same keys
|
- dict with the same keys
|
||||||
- missing ``total_tokens`` (derived from prompt + completion)
|
- missing ``total_tokens`` (derived from prompt + completion)
|
||||||
- ``None`` / partially-populated usage (defaults to 0)
|
- ``None`` / partially-populated usage (defaults to 0)
|
||||||
|
- provider-specific token details, including cache token counters
|
||||||
"""
|
"""
|
||||||
if usage is None:
|
|
||||||
return {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0}
|
|
||||||
|
|
||||||
def _get(key: str) -> typing.Any:
|
def _plain_value(value: typing.Any) -> typing.Any:
|
||||||
if isinstance(usage, dict):
|
if value is None:
|
||||||
return usage.get(key)
|
return None
|
||||||
return getattr(usage, key, None)
|
if isinstance(value, dict):
|
||||||
|
return {k: _plain_value(v) for k, v in value.items() if v is not None}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_plain_value(v) for v in value]
|
||||||
|
|
||||||
prompt_tokens = _get('prompt_tokens') or 0
|
model_dump = getattr(value, 'model_dump', None)
|
||||||
completion_tokens = _get('completion_tokens') or 0
|
if callable(model_dump):
|
||||||
total_tokens = _get('total_tokens') or 0
|
try:
|
||||||
|
dumped = model_dump()
|
||||||
|
if isinstance(dumped, dict):
|
||||||
|
return _plain_value(dumped)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _usage_dict(value: typing.Any) -> dict[str, typing.Any]:
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
plain = _plain_value(value)
|
||||||
|
if isinstance(plain, dict):
|
||||||
|
return plain
|
||||||
|
|
||||||
|
def _is_mock_attr(attr: typing.Any) -> bool:
|
||||||
|
return type(attr).__module__.startswith('unittest.mock')
|
||||||
|
|
||||||
|
data: dict[str, typing.Any] = {}
|
||||||
|
for key in (
|
||||||
|
'prompt_tokens',
|
||||||
|
'completion_tokens',
|
||||||
|
'total_tokens',
|
||||||
|
'prompt_tokens_details',
|
||||||
|
'completion_tokens_details',
|
||||||
|
'cache_creation_input_tokens',
|
||||||
|
'cache_read_input_tokens',
|
||||||
|
'input_token_details',
|
||||||
|
'output_token_details',
|
||||||
|
):
|
||||||
|
attr_value = getattr(value, key, None)
|
||||||
|
if attr_value is not None and not _is_mock_attr(attr_value):
|
||||||
|
data[key] = _plain_value(attr_value)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _to_int(value: typing.Any) -> int:
|
||||||
|
try:
|
||||||
|
return int(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
normalized = _usage_dict(usage)
|
||||||
|
|
||||||
|
prompt_tokens = _to_int(normalized.get('prompt_tokens'))
|
||||||
|
completion_tokens = _to_int(normalized.get('completion_tokens'))
|
||||||
|
total_tokens = _to_int(normalized.get('total_tokens'))
|
||||||
|
|
||||||
# Some providers omit total_tokens in streaming usage; derive it.
|
# Some providers omit total_tokens in streaming usage; derive it.
|
||||||
if not total_tokens:
|
if not total_tokens:
|
||||||
total_tokens = prompt_tokens + completion_tokens
|
total_tokens = prompt_tokens + completion_tokens
|
||||||
|
|
||||||
return {
|
normalized['prompt_tokens'] = prompt_tokens
|
||||||
'prompt_tokens': int(prompt_tokens),
|
normalized['completion_tokens'] = completion_tokens
|
||||||
'completion_tokens': int(completion_tokens),
|
normalized['total_tokens'] = total_tokens
|
||||||
'total_tokens': int(total_tokens),
|
return normalized
|
||||||
}
|
|
||||||
|
|
||||||
def _extract_usage(self, response) -> dict:
|
def _extract_usage(self, response) -> dict | None:
|
||||||
"""Extract usage info from a non-streaming LiteLLM response."""
|
"""Extract usage info from a non-streaming LiteLLM response."""
|
||||||
return self._normalize_usage(getattr(response, 'usage', None))
|
usage = getattr(response, 'usage', None)
|
||||||
|
if usage is None:
|
||||||
|
return None
|
||||||
|
return self._normalize_usage(usage)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _as_dict(value: typing.Any) -> dict:
|
def _as_dict(value: typing.Any) -> dict:
|
||||||
@@ -474,7 +536,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
if query is not None:
|
if query is not None:
|
||||||
if query.variables is None:
|
if query.variables is None:
|
||||||
query.variables = {}
|
query.variables = {}
|
||||||
query.variables['_stream_usage'] = usage_info
|
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = usage_info
|
||||||
|
|
||||||
if not hasattr(chunk, 'choices') or not chunk.choices:
|
if not hasattr(chunk, 'choices') or not chunk.choices:
|
||||||
continue
|
continue
|
||||||
|
|||||||
6
src/langbot/pkg/provider/tools/errors.py
Normal file
6
src/langbot/pkg/provider/tools/errors.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class ToolNotFoundError(ValueError):
|
||||||
|
"""Raised when a requested tool cannot be found in any active loader."""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f'Tool not found: {name}')
|
||||||
@@ -4,12 +4,15 @@ import abc
|
|||||||
import typing
|
import typing
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from langbot_plugin.api.definition.components.manifest import ComponentManifest
|
||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ...core import app
|
from ...core import app
|
||||||
|
|
||||||
|
ToolLookupResult = resource_tool.LLMTool | ComponentManifest
|
||||||
|
|
||||||
|
|
||||||
preregistered_loaders: list[typing.Type[ToolLoader]] = []
|
preregistered_loaders: list[typing.Type[ToolLoader]] = []
|
||||||
|
|
||||||
@@ -43,6 +46,13 @@ class ToolLoader(abc.ABC):
|
|||||||
"""获取所有工具"""
|
"""获取所有工具"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def get_tool(self, name: str) -> ToolLookupResult | None:
|
||||||
|
"""Get one tool by name."""
|
||||||
|
for tool in await self.get_tools():
|
||||||
|
if tool.name == name:
|
||||||
|
return tool
|
||||||
|
return None
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def has_tool(self, name: str) -> bool:
|
async def has_tool(self, name: str) -> bool:
|
||||||
"""检查工具是否存在"""
|
"""检查工具是否存在"""
|
||||||
|
|||||||
@@ -567,6 +567,13 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def get_tool(self, name: str) -> resource_tool.LLMTool | None:
|
||||||
|
for session in self.sessions.values():
|
||||||
|
for function in session.get_tools():
|
||||||
|
if function.name == name:
|
||||||
|
return function
|
||||||
|
return None
|
||||||
|
|
||||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||||
"""执行工具调用"""
|
"""执行工具调用"""
|
||||||
for session in self.sessions.values():
|
for session in self.sessions.values():
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
|
|
||||||
from .. import loader
|
from .. import loader
|
||||||
|
from ..errors import ToolNotFoundError
|
||||||
from . import skill as skill_loader
|
from . import skill as skill_loader
|
||||||
|
|
||||||
EXEC_TOOL_NAME = 'exec'
|
EXEC_TOOL_NAME = 'exec'
|
||||||
@@ -90,7 +91,7 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
return await self._invoke_glob(parameters, query)
|
return await self._invoke_glob(parameters, query)
|
||||||
if name == GREP_TOOL_NAME:
|
if name == GREP_TOOL_NAME:
|
||||||
return await self._invoke_grep(parameters, query)
|
return await self._invoke_grep(parameters, query)
|
||||||
raise ValueError(f'未找到工具: {name}')
|
raise ToolNotFoundError(name)
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
from langbot_plugin.api.definition.components.manifest import ComponentManifest
|
||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
|
|
||||||
from .. import loader
|
from .. import loader
|
||||||
@@ -39,7 +40,7 @@ class PluginToolLoader(loader.ToolLoader):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _get_tool(self, name: str) -> resource_tool.LLMTool:
|
async def get_tool(self, name: str) -> ComponentManifest | None:
|
||||||
for tool in await self.ap.plugin_connector.list_tools():
|
for tool in await self.ap.plugin_connector.list_tools():
|
||||||
if tool.metadata.name == name:
|
if tool.metadata.name == name:
|
||||||
return tool
|
return tool
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ from typing import TYPE_CHECKING
|
|||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
from langbot_plugin.api.entities.events import pipeline_query
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
|
|
||||||
|
from . import loader as tool_loader
|
||||||
|
from .errors import ToolNotFoundError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ...core import app
|
from ...core import app
|
||||||
from langbot.pkg.provider.tools.loaders import (
|
from langbot.pkg.provider.tools.loaders import (
|
||||||
@@ -67,6 +70,20 @@ class ToolManager:
|
|||||||
|
|
||||||
return all_functions
|
return all_functions
|
||||||
|
|
||||||
|
async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None:
|
||||||
|
"""Get tool by name from any active loader."""
|
||||||
|
for active_loader in (
|
||||||
|
self.native_tool_loader,
|
||||||
|
self.plugin_tool_loader,
|
||||||
|
self.mcp_tool_loader,
|
||||||
|
self.skill_tool_loader,
|
||||||
|
):
|
||||||
|
tool = await active_loader.get_tool(name)
|
||||||
|
if tool:
|
||||||
|
return tool
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||||
tools = []
|
tools = []
|
||||||
|
|
||||||
@@ -98,7 +115,7 @@ class ToolManager:
|
|||||||
if await self.skill_tool_loader.has_tool(name):
|
if await self.skill_tool_loader.has_tool(name):
|
||||||
telemetry_features.increment(query, 'tool_calls', 'skill')
|
telemetry_features.increment(query, 'tool_calls', 'skill')
|
||||||
return await self.skill_tool_loader.invoke_tool(name, parameters, query)
|
return await self.skill_tool_loader.invoke_tool(name, parameters, query)
|
||||||
raise ValueError(f'未找到工具: {name}')
|
raise ToolNotFoundError(name)
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
await self.native_tool_loader.shutdown()
|
await self.native_tool_loader.shutdown()
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
|||||||
'query_id': '',
|
'query_id': '',
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
|
'instance_create_ts': constants.instance_create_ts,
|
||||||
'edition': constants.edition,
|
'edition': constants.edition,
|
||||||
'features': features,
|
'features': features,
|
||||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
@@ -16,3 +16,11 @@ debug_mode = False
|
|||||||
edition = 'community'
|
edition = 'community'
|
||||||
|
|
||||||
instance_id = ''
|
instance_id = ''
|
||||||
|
|
||||||
|
instance_create_ts = 0
|
||||||
|
"""Unix timestamp (seconds) of when this instance was first created.
|
||||||
|
|
||||||
|
Sourced from ``data/labels/instance_id.json``. Backfilled to the current
|
||||||
|
time for instances created before this field existed, so it is always a
|
||||||
|
positive value once load_config has run.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ class TestExtractUsage:
|
|||||||
assert result['prompt_tokens'] == 0
|
assert result['prompt_tokens'] == 0
|
||||||
assert result['completion_tokens'] == 0
|
assert result['completion_tokens'] == 0
|
||||||
|
|
||||||
|
def test_extract_usage_without_provider_usage(self):
|
||||||
|
"""Missing provider usage is not treated as authoritative zero usage."""
|
||||||
|
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||||
|
|
||||||
|
response = Mock()
|
||||||
|
response.usage = None
|
||||||
|
|
||||||
|
assert requester._extract_usage(response) is None
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeUsage:
|
class TestNormalizeUsage:
|
||||||
"""Test _normalize_usage helper covering real-world usage shapes"""
|
"""Test _normalize_usage helper covering real-world usage shapes"""
|
||||||
@@ -131,6 +140,22 @@ class TestNormalizeUsage:
|
|||||||
)
|
)
|
||||||
assert result == {'prompt_tokens': 12, 'completion_tokens': 8, 'total_tokens': 20}
|
assert result == {'prompt_tokens': 12, 'completion_tokens': 8, 'total_tokens': 20}
|
||||||
|
|
||||||
|
def test_preserves_token_details(self):
|
||||||
|
"""Provider token details such as cache counters are preserved."""
|
||||||
|
result = litellmchat.LiteLLMRequester._normalize_usage(
|
||||||
|
{
|
||||||
|
'prompt_tokens': 12,
|
||||||
|
'completion_tokens': 8,
|
||||||
|
'total_tokens': 20,
|
||||||
|
'prompt_tokens_details': {'cached_tokens': 7},
|
||||||
|
'completion_tokens_details': {'reasoning_tokens': 3},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['prompt_tokens'] == 12
|
||||||
|
assert result['prompt_tokens_details'] == {'cached_tokens': 7}
|
||||||
|
assert result['completion_tokens_details'] == {'reasoning_tokens': 3}
|
||||||
|
|
||||||
def test_missing_total_is_derived(self):
|
def test_missing_total_is_derived(self):
|
||||||
"""When total_tokens is absent/zero it is derived from prompt + completion"""
|
"""When total_tokens is absent/zero it is derived from prompt + completion"""
|
||||||
usage = Mock()
|
usage = Mock()
|
||||||
@@ -166,9 +191,7 @@ class TestInvokeLLMStreamUsage:
|
|||||||
if has_choice:
|
if has_choice:
|
||||||
choice = Mock()
|
choice = Mock()
|
||||||
delta = Mock()
|
delta = Mock()
|
||||||
delta.model_dump = Mock(
|
delta.model_dump = Mock(return_value={'role': 'assistant', 'content': content, 'tool_calls': tool_calls})
|
||||||
return_value={'role': 'assistant', 'content': content, 'tool_calls': tool_calls}
|
|
||||||
)
|
|
||||||
choice.delta = delta
|
choice.delta = delta
|
||||||
choice.finish_reason = finish_reason
|
choice.finish_reason = finish_reason
|
||||||
chunk.choices = [choice]
|
chunk.choices = [choice]
|
||||||
@@ -313,7 +336,8 @@ class TestInvokeLLMStreamUsage:
|
|||||||
|
|
||||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
||||||
collected = [
|
collected = [
|
||||||
chunk async for chunk in requester.invoke_llm_stream(
|
chunk
|
||||||
|
async for chunk in requester.invoke_llm_stream(
|
||||||
query=query,
|
query=query,
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -788,7 +812,9 @@ class TestInvokeRerank:
|
|||||||
with patch('httpx.AsyncClient', return_value=mock_client):
|
with patch('httpx.AsyncClient', return_value=mock_client):
|
||||||
# arerank must NOT be called on the openai-compatible path
|
# arerank must NOT be called on the openai-compatible path
|
||||||
with patch.object(
|
with patch.object(
|
||||||
litellmchat, 'arerank', new_callable=AsyncMock,
|
litellmchat,
|
||||||
|
'arerank',
|
||||||
|
new_callable=AsyncMock,
|
||||||
side_effect=AssertionError('arerank must not be used for openai-compatible provider'),
|
side_effect=AssertionError('arerank must not be used for openai-compatible provider'),
|
||||||
):
|
):
|
||||||
results = await requester.invoke_rerank(
|
results = await requester.invoke_rerank(
|
||||||
@@ -1034,11 +1060,28 @@ class TestScanModels:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(litellmchat.litellm, 'get_max_tokens') as mock_get_max_tokens:
|
with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info:
|
||||||
mock_get_max_tokens.side_effect = lambda model: 131072 if model == 'moonshot/moonshot-v1-128k' else None
|
mock_get_model_info.side_effect = (
|
||||||
|
lambda model: {'max_input_tokens': 131072}
|
||||||
|
if model == 'moonshot/moonshot-v1-128k'
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
assert requester._safe_context_length('moonshot-v1-128k') == 131072
|
assert requester._safe_context_length('moonshot-v1-128k') == 131072
|
||||||
|
|
||||||
|
def test_safe_context_length_uses_litellm_max_input_tokens(self):
|
||||||
|
"""LiteLLM max_output_tokens must not be treated as the context window."""
|
||||||
|
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||||
|
|
||||||
|
with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info:
|
||||||
|
mock_get_model_info.return_value = {
|
||||||
|
'max_input_tokens': 128000,
|
||||||
|
'max_output_tokens': 16384,
|
||||||
|
'max_tokens': 16384,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert requester._safe_context_length('gpt-4o') == 128000
|
||||||
|
|
||||||
def test_litellm_bool_helper_tries_moonshot_metadata_alias(self):
|
def test_litellm_bool_helper_tries_moonshot_metadata_alias(self):
|
||||||
"""OpenAI-compatible Moonshot endpoints still use Moonshot metadata for abilities."""
|
"""OpenAI-compatible Moonshot endpoints still use Moonshot metadata for abilities."""
|
||||||
requester = litellmchat.LiteLLMRequester(
|
requester = litellmchat.LiteLLMRequester(
|
||||||
@@ -1051,8 +1094,7 @@ class TestScanModels:
|
|||||||
|
|
||||||
with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling:
|
with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling:
|
||||||
mock_supports_function_calling.side_effect = (
|
mock_supports_function_calling.side_effect = (
|
||||||
lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6'
|
lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6' and custom_llm_provider is None
|
||||||
and custom_llm_provider is None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert requester._supports_function_calling('kimi-k2.6') is True
|
assert requester._supports_function_calling('kimi-k2.6') is True
|
||||||
@@ -1102,7 +1144,7 @@ class TestScanModels:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(litellmchat.litellm, 'get_max_tokens', side_effect=Exception('not mapped')):
|
with patch.object(litellmchat.litellm, 'get_model_info', side_effect=Exception('not mapped')):
|
||||||
assert requester._safe_context_length('deepseek-v4-pro') == 1_000_000
|
assert requester._safe_context_length('deepseek-v4-pro') == 1_000_000
|
||||||
assert requester._safe_context_length('deepseek-v4-flash') == 1_000_000
|
assert requester._safe_context_length('deepseek-v4-flash') == 1_000_000
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_raises_when_tool_not_found(self, mock_app_with_loaders, sample_query):
|
async def test_execute_raises_when_tool_not_found(self, mock_app_with_loaders, sample_query):
|
||||||
"""Test that execute_func_call raises ValueError when tool not found."""
|
"""Test that execute_func_call raises ToolNotFoundError when tool not found."""
|
||||||
toolmgr = get_toolmgr_module()
|
toolmgr = get_toolmgr_module()
|
||||||
|
|
||||||
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
|
mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders
|
||||||
@@ -236,7 +236,7 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
manager = toolmgr.ToolManager(mock_app)
|
manager = toolmgr.ToolManager(mock_app)
|
||||||
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='未找到工具'):
|
with pytest.raises(toolmgr.ToolNotFoundError, match='Tool not found: unknown_tool'):
|
||||||
await manager.execute_func_call('unknown_tool', {}, sample_query)
|
await manager.execute_func_call('unknown_tool', {}, sample_query)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class TestBuildHeartbeatPayload:
|
|||||||
|
|
||||||
assert payload['event_type'] == 'instance_heartbeat'
|
assert payload['event_type'] == 'instance_heartbeat'
|
||||||
assert payload['query_id'] == ''
|
assert payload['query_id'] == ''
|
||||||
|
assert 'instance_create_ts' in payload
|
||||||
assert 'timestamp' in payload
|
assert 'timestamp' in payload
|
||||||
f = payload['features']
|
f = payload['features']
|
||||||
assert f['database'] == 'postgresql'
|
assert f['database'] == 'postgresql'
|
||||||
|
|||||||
10
uv.lock
generated
10
uv.lock
generated
@@ -1967,7 +1967,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.1"
|
version = "4.10.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -2082,7 +2082,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.4.3" },
|
{ name = "langbot-plugin", specifier = "==0.4.4" },
|
||||||
{ name = "langchain", specifier = ">=0.2.0" },
|
{ name = "langchain", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2146,7 +2146,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.4.3"
|
version = "0.4.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2167,9 +2167,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/f1/32ec67e8b8eb91159d2b9703f466cc2a763c8cea380dd56561efe793a55b/langbot_plugin-0.4.3.tar.gz", hash = "sha256:747fb78bc666cfac3842cb35130fa8323759dd8768fdaa1975099157a3749c6e", size = 309655, upload-time = "2026-06-13T04:58:10.279Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/68/1a/636c057f6e07a0c87dc7b9c1a373d73df82787b7706ba3ba1a95f633ce7c/langbot_plugin-0.4.4.tar.gz", hash = "sha256:8fdad2d22fe8360d2911557fac17f258f57e85f1a36bd50cd488cb44f61225a4", size = 312741, upload-time = "2026-06-13T11:59:36.772Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/05/84bd7537efd45fc02044ca9509973160a7d6d10520ff73e31424141a3a6c/langbot_plugin-0.4.3-py3-none-any.whl", hash = "sha256:46aca36e2193c18f9cf332460760dd7b9340ee2e96a57f2e4ae621c4d4c4b61c", size = 211384, upload-time = "2026-06-13T04:58:11.668Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/c6/3c313e4ec431fca68326f348bd2c7a61777d43c940bb46ae6c8ebfb66973/langbot_plugin-0.4.4-py3-none-any.whl", hash = "sha256:c91f082ca431539f34790e497e2f056f4e7030e46e0d2bf01a6114b055dd2feb", size = 214164, upload-time = "2026-06-13T11:59:38.053Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ function SingleSelectField({
|
|||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<div key={opt.id}>
|
<div key={opt.id}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => onChange(opt.id)}
|
onClick={() => onChange(opt.id)}
|
||||||
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors ${
|
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors ${
|
||||||
value === opt.id
|
value === opt.id
|
||||||
@@ -361,8 +362,16 @@ function MultiSelectField({
|
|||||||
const selected = value.includes(opt.id);
|
const selected = value.includes(opt.id);
|
||||||
return (
|
return (
|
||||||
<div key={opt.id}>
|
<div key={opt.id}>
|
||||||
<button
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => toggle(opt.id)}
|
onClick={() => toggle(opt.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle(opt.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
||||||
selected
|
selected
|
||||||
? 'border-primary bg-primary/5 text-primary'
|
? 'border-primary bg-primary/5 text-primary'
|
||||||
@@ -371,7 +380,7 @@ function MultiSelectField({
|
|||||||
>
|
>
|
||||||
<Checkbox checked={selected} className="pointer-events-none" />
|
<Checkbox checked={selected} className="pointer-events-none" />
|
||||||
{getI18nText(opt.label)}
|
{getI18nText(opt.label)}
|
||||||
</button>
|
</div>
|
||||||
{opt.has_input && selected && (
|
{opt.has_input && selected && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
|
||||||
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
||||||
|
import PluginLogs from '@/app/home/plugins/components/plugin-installed/plugin-logs/PluginLogs';
|
||||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
@@ -217,8 +219,35 @@ export default function PluginDetailContent({ id }: { id: string }) {
|
|||||||
{dangerZone}
|
{dangerZone}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden w-px shrink-0 bg-border md:block" />
|
<div className="hidden w-px shrink-0 bg-border md:block" />
|
||||||
<div className="min-w-0 flex-1 pb-6 md:min-h-0 md:overflow-y-auto md:overflow-x-hidden">
|
<div className="flex min-w-0 flex-1 flex-col pb-6 md:min-h-0 md:overflow-hidden">
|
||||||
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
|
<Tabs defaultValue="docs" className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<TabsList className="mb-2 shrink-0">
|
||||||
|
<TabsTrigger value="docs" className="flex-none px-4">
|
||||||
|
{t('plugins.tabDocs')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="logs" className="flex-none px-4">
|
||||||
|
{t('plugins.tabLogs')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent
|
||||||
|
value="docs"
|
||||||
|
className="min-h-0 flex-1 md:overflow-y-auto md:overflow-x-hidden"
|
||||||
|
>
|
||||||
|
<PluginReadme
|
||||||
|
pluginAuthor={pluginAuthor}
|
||||||
|
pluginName={pluginName}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="logs"
|
||||||
|
className="min-h-0 flex-1 md:overflow-hidden"
|
||||||
|
>
|
||||||
|
<PluginLogs
|
||||||
|
pluginAuthor={pluginAuthor}
|
||||||
|
pluginName={pluginName}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
|
||||||
|
|
||||||
|
function levelClassName(level: string): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'ERROR':
|
||||||
|
case 'CRITICAL':
|
||||||
|
return 'text-red-500';
|
||||||
|
case 'WARNING':
|
||||||
|
return 'text-amber-500';
|
||||||
|
case 'DEBUG':
|
||||||
|
return 'text-gray-400 dark:text-gray-500';
|
||||||
|
default:
|
||||||
|
return 'text-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginLogs({
|
||||||
|
pluginAuthor,
|
||||||
|
pluginName,
|
||||||
|
}: {
|
||||||
|
pluginAuthor: string;
|
||||||
|
pluginName: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [level, setLevel] = useState<string>('ALL');
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const atBottomRef = useRef(true);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
httpClient
|
||||||
|
.getPluginLogs(
|
||||||
|
pluginAuthor,
|
||||||
|
pluginName,
|
||||||
|
500,
|
||||||
|
level === 'ALL' ? undefined : level,
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
setLogs(res.logs ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLogs([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [pluginAuthor, pluginName, level]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
// Auto-refresh poll loop.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const timer = setInterval(fetchLogs, 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [autoRefresh, fetchLogs]);
|
||||||
|
|
||||||
|
// Keep view pinned to bottom when the user is already at the bottom.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el && atBottomRef.current) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 px-6 pb-3">
|
||||||
|
<Select value={level} onValueChange={setLevel}>
|
||||||
|
<SelectTrigger className="h-8 w-[130px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LEVEL_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{opt === 'ALL' ? t('plugins.logsLevelAll') : opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={fetchLogs}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
{t('plugins.logsRefresh')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={autoRefresh ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => setAutoRefresh((v) => !v)}
|
||||||
|
>
|
||||||
|
{autoRefresh
|
||||||
|
? t('plugins.logsAutoRefreshOn')
|
||||||
|
: t('plugins.logsAutoRefreshOff')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-6 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40"
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('plugins.logsEmpty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
logs.map((entry, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.ts}-${idx}`}
|
||||||
|
className={`whitespace-pre-wrap break-all ${levelClassName(
|
||||||
|
entry.level,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{entry.text}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,13 @@ export interface PluginComponent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A single log line captured from a running plugin's stderr.
|
||||||
|
export interface PluginLogEntry {
|
||||||
|
ts: number;
|
||||||
|
level: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
// marketplace plugin v4
|
// marketplace plugin v4
|
||||||
export enum PluginV4Status {
|
export enum PluginV4Status {
|
||||||
Any = 'any',
|
Any = 'any',
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
ApiRespSkill,
|
ApiRespSkill,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
|
import type { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||||
import type { I18nObject } from '@/app/infra/entities/common';
|
import type { I18nObject } from '@/app/infra/entities/common';
|
||||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||||
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||||
@@ -604,6 +605,22 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPluginLogs(
|
||||||
|
author: string,
|
||||||
|
name: string,
|
||||||
|
limit: number = 200,
|
||||||
|
level?: string,
|
||||||
|
): Promise<{ logs: PluginLogEntry[] }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
if (level) {
|
||||||
|
params.set('level', level);
|
||||||
|
}
|
||||||
|
return this.get(
|
||||||
|
`/api/v1/plugins/${author}/${name}/logs?${params.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public getPluginAssetURL(
|
public getPluginAssetURL(
|
||||||
author: string,
|
author: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -577,6 +577,14 @@ const enUS = {
|
|||||||
viewSource: 'View Source',
|
viewSource: 'View Source',
|
||||||
loadingReadme: 'Loading documentation...',
|
loadingReadme: 'Loading documentation...',
|
||||||
noReadme: 'This plugin does not provide README documentation',
|
noReadme: 'This plugin does not provide README documentation',
|
||||||
|
tabDocs: 'Documentation',
|
||||||
|
tabLogs: 'Logs',
|
||||||
|
logsLevelAll: 'All levels',
|
||||||
|
logsRefresh: 'Refresh',
|
||||||
|
logsAutoRefreshOn: 'Auto-refresh: On',
|
||||||
|
logsAutoRefreshOff: 'Auto-refresh: Off',
|
||||||
|
logsEmpty:
|
||||||
|
'No logs yet. Logs printed by the plugin via logger will appear here.',
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
tooLarge: 'File size exceeds 10MB limit',
|
tooLarge: 'File size exceeds 10MB limit',
|
||||||
success: 'File uploaded successfully',
|
success: 'File uploaded successfully',
|
||||||
|
|||||||
@@ -589,6 +589,14 @@ const esES = {
|
|||||||
viewSource: 'Ver código fuente',
|
viewSource: 'Ver código fuente',
|
||||||
loadingReadme: 'Cargando documentación...',
|
loadingReadme: 'Cargando documentación...',
|
||||||
noReadme: 'Este plugin no proporciona documentación README',
|
noReadme: 'Este plugin no proporciona documentación README',
|
||||||
|
tabDocs: 'Documentación',
|
||||||
|
tabLogs: 'Registros',
|
||||||
|
logsLevelAll: 'Todos los niveles',
|
||||||
|
logsRefresh: 'Actualizar',
|
||||||
|
logsAutoRefreshOn: 'Auto-actualizar: Activado',
|
||||||
|
logsAutoRefreshOff: 'Auto-actualizar: Desactivado',
|
||||||
|
logsEmpty:
|
||||||
|
'Aún no hay registros. Los registros que el plugin imprima mediante logger aparecerán aquí.',
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
tooLarge: 'El tamaño del archivo supera el límite de 10MB',
|
tooLarge: 'El tamaño del archivo supera el límite de 10MB',
|
||||||
success: 'Archivo subido correctamente',
|
success: 'Archivo subido correctamente',
|
||||||
|
|||||||
@@ -582,6 +582,14 @@ const jaJP = {
|
|||||||
viewSource: 'ソースを表示',
|
viewSource: 'ソースを表示',
|
||||||
loadingReadme: 'ドキュメントを読み込み中...',
|
loadingReadme: 'ドキュメントを読み込み中...',
|
||||||
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
|
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
|
||||||
|
tabDocs: 'ドキュメント',
|
||||||
|
tabLogs: 'ログ',
|
||||||
|
logsLevelAll: 'すべてのレベル',
|
||||||
|
logsRefresh: '更新',
|
||||||
|
logsAutoRefreshOn: '自動更新:オン',
|
||||||
|
logsAutoRefreshOff: '自動更新:オフ',
|
||||||
|
logsEmpty:
|
||||||
|
'ログはまだありません。プラグインが logger で出力したログがここに表示されます。',
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
|
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
|
||||||
success: 'ファイルのアップロードに成功しました',
|
success: 'ファイルのアップロードに成功しました',
|
||||||
|
|||||||
@@ -588,6 +588,14 @@ const ruRU = {
|
|||||||
viewSource: 'Исходный код',
|
viewSource: 'Исходный код',
|
||||||
loadingReadme: 'Загрузка документации...',
|
loadingReadme: 'Загрузка документации...',
|
||||||
noReadme: 'Этот плагин не предоставляет документацию README',
|
noReadme: 'Этот плагин не предоставляет документацию README',
|
||||||
|
tabDocs: 'Документация',
|
||||||
|
tabLogs: 'Журналы',
|
||||||
|
logsLevelAll: 'Все уровни',
|
||||||
|
logsRefresh: 'Обновить',
|
||||||
|
logsAutoRefreshOn: 'Автообновление: вкл.',
|
||||||
|
logsAutoRefreshOff: 'Автообновление: выкл.',
|
||||||
|
logsEmpty:
|
||||||
|
'Журналов пока нет. Здесь появятся логи, выводимые плагином через logger.',
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
tooLarge: 'Размер файла превышает лимит 10 МБ',
|
tooLarge: 'Размер файла превышает лимит 10 МБ',
|
||||||
success: 'Файл успешно загружен',
|
success: 'Файл успешно загружен',
|
||||||
|
|||||||
@@ -569,6 +569,13 @@ const thTH = {
|
|||||||
viewSource: 'ดูซอร์สโค้ด',
|
viewSource: 'ดูซอร์สโค้ด',
|
||||||
loadingReadme: 'กำลังโหลดเอกสาร...',
|
loadingReadme: 'กำลังโหลดเอกสาร...',
|
||||||
noReadme: 'ปลั๊กอินนี้ไม่มีเอกสาร README',
|
noReadme: 'ปลั๊กอินนี้ไม่มีเอกสาร README',
|
||||||
|
tabDocs: 'เอกสาร',
|
||||||
|
tabLogs: 'บันทึก',
|
||||||
|
logsLevelAll: 'ทุกระดับ',
|
||||||
|
logsRefresh: 'รีเฟรช',
|
||||||
|
logsAutoRefreshOn: 'รีเฟรชอัตโนมัติ: เปิด',
|
||||||
|
logsAutoRefreshOff: 'รีเฟรชอัตโนมัติ: ปิด',
|
||||||
|
logsEmpty: 'ยังไม่มีบันทึก บันทึกที่ปลั๊กอินพิมพ์ผ่าน logger จะแสดงที่นี่',
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
tooLarge: 'ขนาดไฟล์เกินขีดจำกัด 10MB',
|
tooLarge: 'ขนาดไฟล์เกินขีดจำกัด 10MB',
|
||||||
success: 'อัปโหลดไฟล์สำเร็จ',
|
success: 'อัปโหลดไฟล์สำเร็จ',
|
||||||
|
|||||||
@@ -583,6 +583,14 @@ const viVN = {
|
|||||||
viewSource: 'Xem mã nguồn',
|
viewSource: 'Xem mã nguồn',
|
||||||
loadingReadme: 'Đang tải tài liệu...',
|
loadingReadme: 'Đang tải tài liệu...',
|
||||||
noReadme: 'Plugin này không cung cấp tài liệu README',
|
noReadme: 'Plugin này không cung cấp tài liệu README',
|
||||||
|
tabDocs: 'Tài liệu',
|
||||||
|
tabLogs: 'Nhật ký',
|
||||||
|
logsLevelAll: 'Tất cả cấp độ',
|
||||||
|
logsRefresh: 'Làm mới',
|
||||||
|
logsAutoRefreshOn: 'Tự động làm mới: Bật',
|
||||||
|
logsAutoRefreshOff: 'Tự động làm mới: Tắt',
|
||||||
|
logsEmpty:
|
||||||
|
'Chưa có nhật ký. Nhật ký do plugin in qua logger sẽ hiển thị ở đây.',
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
tooLarge: 'Kích thước tệp vượt quá giới hạn 10MB',
|
tooLarge: 'Kích thước tệp vượt quá giới hạn 10MB',
|
||||||
success: 'Tải tệp lên thành công',
|
success: 'Tải tệp lên thành công',
|
||||||
|
|||||||
@@ -552,6 +552,13 @@ const zhHans = {
|
|||||||
viewSource: '查看来源',
|
viewSource: '查看来源',
|
||||||
loadingReadme: '正在加载文档...',
|
loadingReadme: '正在加载文档...',
|
||||||
noReadme: '该插件没有提供 README 文档',
|
noReadme: '该插件没有提供 README 文档',
|
||||||
|
tabDocs: '文档',
|
||||||
|
tabLogs: '日志',
|
||||||
|
logsLevelAll: '全部级别',
|
||||||
|
logsRefresh: '刷新',
|
||||||
|
logsAutoRefreshOn: '自动刷新:开',
|
||||||
|
logsAutoRefreshOff: '自动刷新:关',
|
||||||
|
logsEmpty: '暂无日志。插件通过 logger 打印的日志会显示在这里。',
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
tooLarge: '文件大小超过 10MB 限制',
|
tooLarge: '文件大小超过 10MB 限制',
|
||||||
success: '文件上传成功',
|
success: '文件上传成功',
|
||||||
|
|||||||
@@ -552,6 +552,13 @@ const zhHant = {
|
|||||||
viewSource: '查看來源',
|
viewSource: '查看來源',
|
||||||
loadingReadme: '正在載入文件...',
|
loadingReadme: '正在載入文件...',
|
||||||
noReadme: '該插件沒有提供 README 文件',
|
noReadme: '該插件沒有提供 README 文件',
|
||||||
|
tabDocs: '文件',
|
||||||
|
tabLogs: '日誌',
|
||||||
|
logsLevelAll: '全部級別',
|
||||||
|
logsRefresh: '重新整理',
|
||||||
|
logsAutoRefreshOn: '自動重新整理:開',
|
||||||
|
logsAutoRefreshOff: '自動重新整理:關',
|
||||||
|
logsEmpty: '暫無日誌。外掛透過 logger 列印的日誌會顯示在這裡。',
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
tooLarge: '檔案大小超過 10MB 限制',
|
tooLarge: '檔案大小超過 10MB 限制',
|
||||||
success: '檔案上傳成功',
|
success: '檔案上傳成功',
|
||||||
|
|||||||
Reference in New Issue
Block a user