feat(plugins): show plugin logs on detail page via Docs/Logs tablist

Add a Logs tab beside Documentation on the plugin detail page, showing
the output a plugin prints through the standard Python logger (per the
wiki style guide). Logs are captured from the plugin's stderr by the
plugin runtime and fetched on demand.

- Bump langbot-plugin pin to 0.4.4 (adds GET_PLUGIN_LOGS action)
- plugin_connector/handler: get_plugin_logs RPC client
- HTTP route GET /api/v1/plugins/<author>/<name>/logs (limit + level)
- Frontend: wrap detail right panel in Docs/Logs Tabs; PluginLogs
  component with level filter, manual + 3s auto refresh, bottom-follow
- i18n: 7 new keys across all 8 locales
This commit is contained in:
RockChinQ
2026-06-13 08:01:18 -04:00
parent a97d2040bb
commit 5bfa38cbf2
17 changed files with 328 additions and 7 deletions

View File

@@ -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",

View File

@@ -271,6 +271,22 @@ 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(
'/<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(
'/<author>/<plugin_name>/icon',
methods=['GET'],

View File

@@ -689,6 +689,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)

View File

@@ -953,6 +953,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(

8
uv.lock generated
View File

@@ -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]]

View File

@@ -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}
</div>
<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">
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
<div className="flex min-w-0 flex-1 flex-col pb-6 md:min-h-0 md:overflow-hidden">
<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>

View File

@@ -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>
);
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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',

View File

@@ -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',

View File

@@ -582,6 +582,14 @@ const jaJP = {
viewSource: 'ソースを表示',
loadingReadme: 'ドキュメントを読み込み中...',
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
tabDocs: 'ドキュメント',
tabLogs: 'ログ',
logsLevelAll: 'すべてのレベル',
logsRefresh: '更新',
logsAutoRefreshOn: '自動更新:オン',
logsAutoRefreshOff: '自動更新:オフ',
logsEmpty:
'ログはまだありません。プラグインが logger で出力したログがここに表示されます。',
fileUpload: {
tooLarge: 'ファイルサイズが 10MB の制限を超えています',
success: 'ファイルのアップロードに成功しました',

View File

@@ -588,6 +588,14 @@ const ruRU = {
viewSource: 'Исходный код',
loadingReadme: 'Загрузка документации...',
noReadme: 'Этот плагин не предоставляет документацию README',
tabDocs: 'Документация',
tabLogs: 'Журналы',
logsLevelAll: 'Все уровни',
logsRefresh: 'Обновить',
logsAutoRefreshOn: 'Автообновление: вкл.',
logsAutoRefreshOff: 'Автообновление: выкл.',
logsEmpty:
'Журналов пока нет. Здесь появятся логи, выводимые плагином через logger.',
fileUpload: {
tooLarge: 'Размер файла превышает лимит 10 МБ',
success: 'Файл успешно загружен',

View File

@@ -569,6 +569,13 @@ const thTH = {
viewSource: 'ดูซอร์สโค้ด',
loadingReadme: 'กำลังโหลดเอกสาร...',
noReadme: 'ปลั๊กอินนี้ไม่มีเอกสาร README',
tabDocs: 'เอกสาร',
tabLogs: 'บันทึก',
logsLevelAll: 'ทุกระดับ',
logsRefresh: 'รีเฟรช',
logsAutoRefreshOn: 'รีเฟรชอัตโนมัติ: เปิด',
logsAutoRefreshOff: 'รีเฟรชอัตโนมัติ: ปิด',
logsEmpty: 'ยังไม่มีบันทึก บันทึกที่ปลั๊กอินพิมพ์ผ่าน logger จะแสดงที่นี่',
fileUpload: {
tooLarge: 'ขนาดไฟล์เกินขีดจำกัด 10MB',
success: 'อัปโหลดไฟล์สำเร็จ',

View File

@@ -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',

View File

@@ -552,6 +552,13 @@ const zhHans = {
viewSource: '查看来源',
loadingReadme: '正在加载文档...',
noReadme: '该插件没有提供 README 文档',
tabDocs: '文档',
tabLogs: '日志',
logsLevelAll: '全部级别',
logsRefresh: '刷新',
logsAutoRefreshOn: '自动刷新:开',
logsAutoRefreshOff: '自动刷新:关',
logsEmpty: '暂无日志。插件通过 logger 打印的日志会显示在这里。',
fileUpload: {
tooLarge: '文件大小超过 10MB 限制',
success: '文件上传成功',

View File

@@ -552,6 +552,13 @@ const zhHant = {
viewSource: '查看來源',
loadingReadme: '正在載入文件...',
noReadme: '該插件沒有提供 README 文件',
tabDocs: '文件',
tabLogs: '日誌',
logsLevelAll: '全部級別',
logsRefresh: '重新整理',
logsAutoRefreshOn: '自動重新整理:開',
logsAutoRefreshOff: '自動重新整理:關',
logsEmpty: '暫無日誌。外掛透過 logger 列印的日誌會顯示在這裡。',
fileUpload: {
tooLarge: '檔案大小超過 10MB 限制',
success: '檔案上傳成功',