diff --git a/pyproject.toml b/pyproject.toml index 0ec0d418..96e95f7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.10.1" +version = "4.10.2" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] @@ -70,7 +70,7 @@ dependencies = [ "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", "pyseekdb==1.1.0.post3", - "langbot-plugin==0.4.3", + "langbot-plugin==0.4.4", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/src/langbot/__init__.py b/src/langbot/__init__.py index e2cdf58d..f6a11354 100644 --- a/src/langbot/__init__.py +++ b/src/langbot/__init__.py @@ -1,3 +1,3 @@ """LangBot - Production-grade platform for building agentic IM bots""" -__version__ = '4.10.1' +__version__ = '4.10.2' diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 05a8a271..c291c123 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -271,6 +271,20 @@ class PluginsRouterGroup(group.RouterGroup): readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language) return self.success(data={'readme': readme}) + @self.route( + '///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( '///icon', methods=['GET'], diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index 26f4a9e1..6fc890f1 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -202,6 +202,16 @@ class LoadConfigStage(stage.BootingStage): constants.instance_id = new_id 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 edition: {constants.edition}') diff --git a/src/langbot/pkg/platform/sources/web_page_bot_adapter.py b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py index d424debd..fa7f8174 100644 --- a/src/langbot/pkg/platform/sources/web_page_bot_adapter.py +++ b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py @@ -84,6 +84,18 @@ class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter ): 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: return False diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 9b285041..ae9875e6 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -708,6 +708,16 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): 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) + 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) 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) diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 6043aa2b..a9a5178b 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -2515,6 +2515,31 @@ class RuntimeConnectionHandler(handler.Handler): 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]: """Get plugin assets""" result = await self.call_action( diff --git a/src/langbot/pkg/provider/modelmgr/requester.py b/src/langbot/pkg/provider/modelmgr/requester.py index b673c758..377f7d4a 100644 --- a/src/langbot/pkg/provider/modelmgr/requester.py +++ b/src/langbot/pkg/provider/modelmgr/requester.py @@ -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 +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: """运行时模型提供商""" @@ -67,6 +80,7 @@ class RuntimeProvider: if isinstance(result, tuple): msg, usage_info = result if usage_info: + _store_llm_usage(query, usage_info) input_tokens = usage_info.get('prompt_tokens', 0) output_tokens = usage_info.get('completion_tokens', 0) return msg @@ -146,11 +160,12 @@ class RuntimeProvider: if query: if query.variables is None: query.variables = {} - if '_stream_usage' in query.variables: - usage_info = query.variables['_stream_usage'] + if STREAM_USAGE_QUERY_VARIABLE in query.variables: + usage_info = query.variables[STREAM_USAGE_QUERY_VARIABLE] + _store_llm_usage(query, usage_info) input_tokens = usage_info.get('prompt_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: status = 'error' error_message = str(e) diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index 6b087916..8c750bd7 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -75,22 +75,33 @@ class LiteLLMRequester(requester.ProviderAPIRequester): continue return False + @staticmethod + def _positive_int(value: typing.Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int) and value > 0: + return value + if isinstance(value, str) and value.isdigit(): + parsed_value = int(value) + if parsed_value > 0: + return parsed_value + 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'): - value = model_payload.get(field_name) - if isinstance(value, bool): - continue - if isinstance(value, int) and value > 0: - return value - if isinstance(value, str) and value.isdigit(): - parsed_value = int(value) - if parsed_value > 0: - return parsed_value + 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]: normalized_model_name = (model_name or '').lower() candidates = [] @@ -126,7 +137,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester): return 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): return self._known_context_length_fallback(model_name) @@ -143,11 +154,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester): continue tried_candidates.append(candidate) try: - max_tokens = helper(candidate) + model_info = helper(candidate) except Exception: continue - if isinstance(max_tokens, int) and max_tokens > 0: - return max_tokens + context_length = self._context_length_from_litellm_model_info(model_info) + if context_length is not None: + return context_length return self._known_context_length_fallback(model_name) def _supports_function_calling(self, model_name: str) -> bool: @@ -250,32 +262,82 @@ class LiteLLMRequester(requester.ProviderAPIRequester): - dict with the same keys - missing ``total_tokens`` (derived from prompt + completion) - ``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: - if isinstance(usage, dict): - return usage.get(key) - return getattr(usage, key, None) + def _plain_value(value: typing.Any) -> typing.Any: + if value is None: + return 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 - completion_tokens = _get('completion_tokens') or 0 - total_tokens = _get('total_tokens') or 0 + model_dump = getattr(value, 'model_dump', None) + if callable(model_dump): + 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. if not total_tokens: total_tokens = prompt_tokens + completion_tokens - return { - 'prompt_tokens': int(prompt_tokens), - 'completion_tokens': int(completion_tokens), - 'total_tokens': int(total_tokens), - } + normalized['prompt_tokens'] = prompt_tokens + normalized['completion_tokens'] = completion_tokens + normalized['total_tokens'] = 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.""" - 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 def _as_dict(value: typing.Any) -> dict: @@ -474,7 +536,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester): if query is not None: if query.variables is None: 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: continue diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index cde4314b..e6c5ecef 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -117,6 +117,7 @@ class SkillToolLoader(loader.ToolLoader): 'activated': True, 'skill_name': skill_name, 'mount_path': mount_path, + 'activated_skill_names': skill_loader.get_activated_skill_names(query), 'content': result_content, } diff --git a/src/langbot/pkg/telemetry/heartbeat.py b/src/langbot/pkg/telemetry/heartbeat.py index dd2a58d3..34b61673 100644 --- a/src/langbot/pkg/telemetry/heartbeat.py +++ b/src/langbot/pkg/telemetry/heartbeat.py @@ -109,6 +109,7 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict: 'query_id': '', 'version': constants.semantic_version, 'instance_id': constants.instance_id, + 'instance_create_ts': constants.instance_create_ts, 'edition': constants.edition, 'features': features, 'timestamp': datetime.now(timezone.utc).isoformat(), diff --git a/src/langbot/pkg/utils/constants.py b/src/langbot/pkg/utils/constants.py index fb520da9..2af16486 100644 --- a/src/langbot/pkg/utils/constants.py +++ b/src/langbot/pkg/utils/constants.py @@ -16,3 +16,11 @@ debug_mode = False edition = 'community' 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. +""" diff --git a/tests/unit_tests/provider/test_litellmchat.py b/tests/unit_tests/provider/test_litellmchat.py index 1ec12d82..abe0cf49 100644 --- a/tests/unit_tests/provider/test_litellmchat.py +++ b/tests/unit_tests/provider/test_litellmchat.py @@ -115,6 +115,15 @@ class TestExtractUsage: assert result['prompt_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: """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} + 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): """When total_tokens is absent/zero it is derived from prompt + completion""" usage = Mock() @@ -166,9 +191,7 @@ class TestInvokeLLMStreamUsage: if has_choice: choice = Mock() delta = Mock() - delta.model_dump = Mock( - return_value={'role': 'assistant', 'content': content, 'tool_calls': tool_calls} - ) + delta.model_dump = Mock(return_value={'role': 'assistant', 'content': content, 'tool_calls': tool_calls}) choice.delta = delta choice.finish_reason = finish_reason chunk.choices = [choice] @@ -313,7 +336,8 @@ class TestInvokeLLMStreamUsage: with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())): collected = [ - chunk async for chunk in requester.invoke_llm_stream( + chunk + async for chunk in requester.invoke_llm_stream( query=query, model=model, messages=messages, @@ -788,7 +812,9 @@ class TestInvokeRerank: with patch('httpx.AsyncClient', return_value=mock_client): # arerank must NOT be called on the openai-compatible path 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'), ): results = await requester.invoke_rerank( @@ -1034,11 +1060,28 @@ class TestScanModels: }, ) - with patch.object(litellmchat.litellm, 'get_max_tokens') as mock_get_max_tokens: - mock_get_max_tokens.side_effect = lambda model: 131072 if model == 'moonshot/moonshot-v1-128k' else None + with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info: + 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 + 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): """OpenAI-compatible Moonshot endpoints still use Moonshot metadata for abilities.""" requester = litellmchat.LiteLLMRequester( @@ -1051,8 +1094,7 @@ class TestScanModels: with patch.object(litellmchat.litellm, 'supports_function_calling') as mock_supports_function_calling: mock_supports_function_calling.side_effect = ( - lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6' - and custom_llm_provider is None + lambda model, custom_llm_provider=None: model == 'moonshot/kimi-k2.6' and custom_llm_provider is None ) 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-flash') == 1_000_000 diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index 7a9cde66..9db7b945 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -193,6 +193,29 @@ class TestSkillPathHelpers: assert list(result.keys()) == ['visible'] + def test_restore_activated_skills_uses_caller_provided_names_and_visibility(self): + from langbot.pkg.provider.tools.loaders.skill import ( + ACTIVATED_SKILLS_KEY, + PIPELINE_BOUND_SKILLS_KEY, + get_activated_skill_names, + restore_activated_skills, + ) + + ap = _make_ap() + ap.skill_mgr = SimpleNamespace( + skills={ + 'visible': _make_skill_data(name='visible'), + 'hidden': _make_skill_data(name='hidden'), + } + ) + query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']}) + + restored = restore_activated_skills(ap, query, ['visible', 'hidden', 'visible', '']) + + assert restored == ['visible'] + assert list(query.variables[ACTIVATED_SKILLS_KEY].keys()) == ['visible'] + assert get_activated_skill_names(query) == ['visible'] + def test_resolve_virtual_skill_path_allows_visible_skill_reads(self): from langbot.pkg.provider.tools.loaders.skill import ( PIPELINE_BOUND_SKILLS_KEY, @@ -282,6 +305,7 @@ class TestSkillToolLoader: assert result['activated'] is True assert result['skill_name'] == 'demo' assert result['mount_path'] == '/workspace/.skills/demo' + assert result['activated_skill_names'] == ['demo'] assert 'Step 1' in result['content'] assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'} diff --git a/tests/unit_tests/telemetry/test_heartbeat.py b/tests/unit_tests/telemetry/test_heartbeat.py index a5bd2e32..18d61f2d 100644 --- a/tests/unit_tests/telemetry/test_heartbeat.py +++ b/tests/unit_tests/telemetry/test_heartbeat.py @@ -62,6 +62,7 @@ class TestBuildHeartbeatPayload: assert payload['event_type'] == 'instance_heartbeat' assert payload['query_id'] == '' + assert 'instance_create_ts' in payload assert 'timestamp' in payload f = payload['features'] assert f['database'] == 'postgresql' diff --git a/uv.lock b/uv.lock index 450328f5..bca1495a 100644 --- a/uv.lock +++ b/uv.lock @@ -1967,7 +1967,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.10.1" +version = "4.10.2" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, @@ -2082,7 +2082,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { 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-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2146,7 +2146,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.4.3" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2167,9 +2167,9 @@ dependencies = [ { name = "watchdog" }, { 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 = [ - { 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]] diff --git a/web/src/app/home/plugins/PluginDetailContent.tsx b/web/src/app/home/plugins/PluginDetailContent.tsx index 829e6868..0d1e56e5 100644 --- a/web/src/app/home/plugins/PluginDetailContent.tsx +++ b/web/src/app/home/plugins/PluginDetailContent.tsx @@ -2,10 +2,12 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; 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 PluginLogs from '@/app/home/plugins/components/plugin-installed/plugin-logs/PluginLogs'; import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { @@ -217,8 +219,35 @@ export default function PluginDetailContent({ id }: { id: string }) { {dangerZone}
-
- +
+ + + + {t('plugins.tabDocs')} + + + {t('plugins.tabLogs')} + + + + + + + + +
diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-logs/PluginLogs.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-logs/PluginLogs.tsx new file mode 100644 index 00000000..08643aad --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-logs/PluginLogs.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [level, setLevel] = useState('ALL'); + const [autoRefresh, setAutoRefresh] = useState(true); + const scrollRef = useRef(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 ( +
+
+ + + +
+ +
+ {logs.length === 0 ? ( +
+ {t('plugins.logsEmpty')} +
+ ) : ( + logs.map((entry, idx) => ( +
+ {entry.text} +
+ )) + )} +
+
+ ); +} diff --git a/web/src/app/infra/entities/plugin/index.ts b/web/src/app/infra/entities/plugin/index.ts index ac9b9563..ad661211 100644 --- a/web/src/app/infra/entities/plugin/index.ts +++ b/web/src/app/infra/entities/plugin/index.ts @@ -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 export enum PluginV4Status { Any = 'any', diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index e9cdb51c..b2f3f7b5 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -55,6 +55,7 @@ import { ApiRespSkill, } from '@/app/infra/entities/api'; import { Plugin } from '@/app/infra/entities/plugin'; +import type { PluginLogEntry } from '@/app/infra/entities/plugin'; import type { I18nObject } from '@/app/infra/entities/common'; import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest'; 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( author: string, name: string, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index ddf1ab43..91f0b6f3 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -577,6 +577,14 @@ const enUS = { viewSource: 'View Source', loadingReadme: 'Loading 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: { tooLarge: 'File size exceeds 10MB limit', success: 'File uploaded successfully', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 1e85f504..5f802815 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -589,6 +589,14 @@ const esES = { viewSource: 'Ver código fuente', loadingReadme: 'Cargando documentación...', 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: { tooLarge: 'El tamaño del archivo supera el límite de 10MB', success: 'Archivo subido correctamente', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index d87113c0..c8c65152 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -582,6 +582,14 @@ const jaJP = { viewSource: 'ソースを表示', loadingReadme: 'ドキュメントを読み込み中...', noReadme: 'このプラグインはREADMEドキュメントを提供していません', + tabDocs: 'ドキュメント', + tabLogs: 'ログ', + logsLevelAll: 'すべてのレベル', + logsRefresh: '更新', + logsAutoRefreshOn: '自動更新:オン', + logsAutoRefreshOff: '自動更新:オフ', + logsEmpty: + 'ログはまだありません。プラグインが logger で出力したログがここに表示されます。', fileUpload: { tooLarge: 'ファイルサイズが 10MB の制限を超えています', success: 'ファイルのアップロードに成功しました', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 846e16f7..8ebb4fa2 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -588,6 +588,14 @@ const ruRU = { viewSource: 'Исходный код', loadingReadme: 'Загрузка документации...', noReadme: 'Этот плагин не предоставляет документацию README', + tabDocs: 'Документация', + tabLogs: 'Журналы', + logsLevelAll: 'Все уровни', + logsRefresh: 'Обновить', + logsAutoRefreshOn: 'Автообновление: вкл.', + logsAutoRefreshOff: 'Автообновление: выкл.', + logsEmpty: + 'Журналов пока нет. Здесь появятся логи, выводимые плагином через logger.', fileUpload: { tooLarge: 'Размер файла превышает лимит 10 МБ', success: 'Файл успешно загружен', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 0922b31a..ac976402 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -569,6 +569,13 @@ const thTH = { viewSource: 'ดูซอร์สโค้ด', loadingReadme: 'กำลังโหลดเอกสาร...', noReadme: 'ปลั๊กอินนี้ไม่มีเอกสาร README', + tabDocs: 'เอกสาร', + tabLogs: 'บันทึก', + logsLevelAll: 'ทุกระดับ', + logsRefresh: 'รีเฟรช', + logsAutoRefreshOn: 'รีเฟรชอัตโนมัติ: เปิด', + logsAutoRefreshOff: 'รีเฟรชอัตโนมัติ: ปิด', + logsEmpty: 'ยังไม่มีบันทึก บันทึกที่ปลั๊กอินพิมพ์ผ่าน logger จะแสดงที่นี่', fileUpload: { tooLarge: 'ขนาดไฟล์เกินขีดจำกัด 10MB', success: 'อัปโหลดไฟล์สำเร็จ', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 47d51f2a..be1e7754 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -583,6 +583,14 @@ const viVN = { viewSource: 'Xem mã nguồn', loadingReadme: 'Đang tải tài liệu...', 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: { 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', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index a7ef0a53..f32f039a 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -552,6 +552,13 @@ const zhHans = { viewSource: '查看来源', loadingReadme: '正在加载文档...', noReadme: '该插件没有提供 README 文档', + tabDocs: '文档', + tabLogs: '日志', + logsLevelAll: '全部级别', + logsRefresh: '刷新', + logsAutoRefreshOn: '自动刷新:开', + logsAutoRefreshOff: '自动刷新:关', + logsEmpty: '暂无日志。插件通过 logger 打印的日志会显示在这里。', fileUpload: { tooLarge: '文件大小超过 10MB 限制', success: '文件上传成功', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 1d0d9da2..539b34c4 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -552,6 +552,13 @@ const zhHant = { viewSource: '查看來源', loadingReadme: '正在載入文件...', noReadme: '該插件沒有提供 README 文件', + tabDocs: '文件', + tabLogs: '日誌', + logsLevelAll: '全部級別', + logsRefresh: '重新整理', + logsAutoRefreshOn: '自動重新整理:開', + logsAutoRefreshOff: '自動重新整理:關', + logsEmpty: '暫無日誌。外掛透過 logger 列印的日誌會顯示在這裡。', fileUpload: { tooLarge: '檔案大小超過 10MB 限制', success: '檔案上傳成功',