feat: trace plugin installation

This commit is contained in:
Junyan Qin
2025-08-16 15:42:49 +08:00
parent 288b294148
commit 5179b3e53a
10 changed files with 167 additions and 152 deletions

View File

@@ -13,6 +13,8 @@ class PluginSetting(Base):
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)
install_source = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, default='github')
install_info = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,

View File

@@ -0,0 +1,25 @@
import sqlalchemy
from .. import migration
@migration.migration_class(5)
class DBMigratePluginInstallSource(migration.DBMigration):
"""插件安装来源"""
async def upgrade(self):
"""升级"""
# add new column install_source, use default value 'github', via alter table
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
"ALTER TABLE plugin_settings ADD COLUMN install_source VARCHAR(255) NOT NULL DEFAULT 'github'"
)
)
# add new column install_info, use default value {}, via alter table
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("ALTER TABLE plugin_settings ADD COLUMN install_info JSON NOT NULL DEFAULT '{}'")
)
async def downgrade(self):
"""降级"""
pass

View File

@@ -105,7 +105,16 @@ class PluginRuntimeConnector:
install_info: dict[str, Any],
task_context: taskmgr.TaskContext | None = None,
):
return await self.handler.install_plugin(install_source.value, install_info)
async for ret in self.handler.install_plugin(install_source.value, install_info):
current_action = ret.get('current_action', None)
if current_action is not None:
if task_context is not None:
task_context.set_current_action(current_action)
trace = ret.get('trace', None)
if trace is not None:
if task_context is not None:
task_context.trace(trace)
async def list_plugins(self) -> list[dict[str, Any]]:
return await self.handler.list_plugins()

View File

@@ -39,6 +39,43 @@ class RuntimeConnectionHandler(handler.Handler):
super().__init__(connection, disconnect_callback)
self.ap = ap
@self.action(RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS)
async def initialize_plugin_settings(data: dict[str, Any]) -> handler.ActionResponse:
"""Initialize plugin settings"""
# check if exists plugin setting
plugin_author = data['plugin_author']
plugin_name = data['plugin_name']
install_source = data['install_source']
install_info = data['install_info']
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
if result.first() is not None:
# delete plugin setting
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_plugin.PluginSetting)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
# create plugin setting
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_plugin.PluginSetting).values(
plugin_author=plugin_author,
plugin_name=plugin_name,
install_source=install_source,
install_info=install_info,
)
)
return handler.ActionResponse.success(
data={},
)
@self.action(RuntimeToLangBotAction.GET_PLUGIN_SETTINGS)
async def get_plugin_settings(data: dict[str, Any]) -> handler.ActionResponse:
"""Get plugin settings"""
@@ -56,6 +93,8 @@ class RuntimeConnectionHandler(handler.Handler):
'enabled': False,
'priority': 0,
'plugin_config': {},
'install_source': 'local',
'install_info': {},
}
setting = result.first()
@@ -64,6 +103,8 @@ class RuntimeConnectionHandler(handler.Handler):
data['enabled'] = setting.enabled
data['priority'] = setting.priority
data['plugin_config'] = setting.config
data['install_source'] = setting.install_source
data['install_info'] = setting.install_info
return handler.ActionResponse.success(
data=data,
@@ -373,17 +414,22 @@ class RuntimeConnectionHandler(handler.Handler):
timeout=10,
)
async def install_plugin(self, install_source: str, install_info: dict[str, Any]) -> dict[str, Any]:
async def install_plugin(
self, install_source: str, install_info: dict[str, Any]
) -> typing.AsyncGenerator[dict[str, Any], None]:
"""Install plugin"""
return await self.call_action(
gen = self.call_action_generator(
LangBotToRuntimeAction.INSTALL_PLUGIN,
{
'install_source': install_source,
'install_info': install_info,
},
timeout=10,
timeout=120,
)
async for ret in gen:
yield ret
async def list_plugins(self) -> list[dict[str, Any]]:
"""List plugins"""
result = await self.call_action(

View File

@@ -1,6 +1,6 @@
semantic_version = 'v4.0.7'
required_database_version = 4
required_database_version = 5
"""标记本版本所需要的数据库结构版本,用于判断数据库迁移"""
debug_mode = False

View File

@@ -4,13 +4,16 @@ import PluginInstalledComponent, {
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent';
import PluginMarketComponent from '@/app/home/plugins/plugin-market/PluginMarketComponent';
import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
import PluginUploadDialog, {
UploadModalStatus,
} from '@/app/home/plugins/plugin-upload-dialog/PluginUploadDialog';
import styles from './plugins.module.css';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { PlusIcon, ChevronDownIcon, UploadIcon, StoreIcon } from 'lucide-react';
import {
PlusIcon,
ChevronDownIcon,
UploadIcon,
StoreIcon,
Download,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -25,7 +28,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { GithubIcon } from 'lucide-react';
import { Upload } from 'lucide-react';
import { useState, useRef, useCallback } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
@@ -33,6 +36,7 @@ import { useTranslation } from 'react-i18next';
enum PluginInstallStatus {
WAIT_INPUT = 'wait_input',
ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing',
ERROR = 'error',
}
@@ -46,56 +50,72 @@ export default function PluginConfigPage() {
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [installError, setInstallError] = useState<string | null>(null);
const [githubURL, setGithubURL] = useState('');
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadStatus, setUploadStatus] = useState<UploadModalStatus>(
UploadModalStatus.UPLOADING,
);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
function handleModalConfirm() {
installPlugin(githubURL);
}
function installPlugin(url: string) {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
httpClient
.installPluginFromGithub(url)
.then((resp) => {
const taskId = resp.task_id;
function watchTask(taskId: number) {
let alreadySuccess = false;
console.log('taskId:', taskId);
let alreadySuccess = false;
console.log('taskId:', taskId);
// 每秒拉取一次任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((resp) => {
console.log('task status:', resp);
if (resp.runtime.done) {
clearInterval(interval);
if (resp.runtime.exception) {
setInstallError(resp.runtime.exception);
setPluginInstallStatus(PluginInstallStatus.ERROR);
} else {
// success
if (!alreadySuccess) {
toast.success(t('plugins.installSuccess'));
alreadySuccess = true;
}
setGithubURL('');
setModalOpen(false);
pluginInstalledRef.current?.refreshPluginList();
}
// 每秒拉取一次任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((resp) => {
console.log('task status:', resp);
if (resp.runtime.done) {
clearInterval(interval);
if (resp.runtime.exception) {
setInstallError(resp.runtime.exception);
setPluginInstallStatus(PluginInstallStatus.ERROR);
} else {
// success
if (!alreadySuccess) {
toast.success(t('plugins.installSuccess'));
alreadySuccess = true;
}
});
}, 1000);
})
.catch((err) => {
console.log('error when install plugin:', err);
setInstallError(err.message);
setPluginInstallStatus(PluginInstallStatus.ERROR);
setGithubURL('');
setModalOpen(false);
pluginInstalledRef.current?.refreshPluginList();
}
}
});
}, 1000);
}
function handleModalConfirm() {
installPlugin('github', { url: githubURL });
}
function installPlugin(
installSource: string,
installInfo: Record<string, any>,
) {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
if (installSource === 'github') {
httpClient
.installPluginFromGithub(installInfo.url)
.then((resp) => {
const taskId = resp.task_id;
watchTask(taskId);
})
.catch((err) => {
console.log('error when install plugin:', err);
setInstallError(err.message);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
} else if (installSource === 'local') {
httpClient
.installPluginFromLocal(installInfo.file)
.then((resp) => {
const taskId = resp.task_id;
watchTask(taskId);
})
.catch((err) => {
console.log('error when install plugin:', err);
setInstallError(err.message);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
}
}
const validateFileType = (file: File): boolean => {
@@ -111,21 +131,10 @@ export default function PluginConfigPage() {
return;
}
setUploadModalOpen(true);
setUploadStatus(UploadModalStatus.UPLOADING);
setUploadError(null);
try {
// 暂时直接显示成功,等后续实现进度显示
setTimeout(() => {
setUploadStatus(UploadModalStatus.SUCCESS);
toast.success(t('plugins.uploadSuccess'));
pluginInstalledRef.current?.refreshPluginList();
}, 1000);
} catch (err: unknown) {
setUploadError((err as Error)?.message || t('plugins.uploadFailed'));
setUploadStatus(UploadModalStatus.ERROR);
}
setModalOpen(true);
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
setInstallError(null);
installPlugin('local', { file });
},
[t],
);
@@ -250,8 +259,8 @@ export default function PluginConfigPage() {
<DialogContent className="w-[500px] p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<GithubIcon className="size-6" />
<span>{t('plugins.installFromGithub')}</span>
<Download className="size-6" />
<span>{t('plugins.installPlugin')}</span>
</DialogTitle>
</DialogHeader>
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
@@ -277,12 +286,13 @@ export default function PluginConfigPage() {
</div>
)}
<DialogFooter>
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
{(pluginInstallStatus === PluginInstallStatus.WAIT_INPUT ||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM) && (
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleModalConfirm}>
<Button onClick={() => handleModalConfirm()}>
{t('common.confirm')}
</Button>
</>
@@ -296,14 +306,6 @@ export default function PluginConfigPage() {
</DialogContent>
</Dialog>
{/* 上传状态弹窗 */}
<PluginUploadDialog
open={uploadModalOpen}
onOpenChange={setUploadModalOpen}
status={uploadStatus}
error={uploadError}
/>
{/* 拖拽提示覆盖层 */}
{isDragOver && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50 pointer-events-none">

View File

@@ -1,69 +0,0 @@
'use client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { UploadIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export enum UploadModalStatus {
UPLOADING = 'uploading',
SUCCESS = 'success',
ERROR = 'error',
}
interface PluginUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
status: UploadModalStatus;
error?: string | null;
}
export default function PluginUploadDialog({
open,
onOpenChange,
status,
error,
}: PluginUploadDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[400px] p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<UploadIcon className="size-6" />
<span>{t('plugins.uploadLocalPlugin')}</span>
</DialogTitle>
</DialogHeader>
<div className="mt-4">
{status === UploadModalStatus.UPLOADING && (
<p className="mb-2">{t('plugins.uploadingPlugin')}</p>
)}
{status === UploadModalStatus.SUCCESS && (
<p className="mb-2 text-green-600">{t('plugins.uploadSuccess')}</p>
)}
{status === UploadModalStatus.ERROR && (
<>
<p className="mb-2">{t('plugins.uploadFailed')}</p>
<p className="mb-2 text-red-500">{error}</p>
</>
)}
</div>
<DialogFooter>
{(status === UploadModalStatus.SUCCESS ||
status === UploadModalStatus.ERROR) && (
<Button variant="default" onClick={() => onOpenChange(false)}>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -142,7 +142,7 @@ const enUS = {
marketplace: 'Marketplace',
arrange: 'Sort Plugins',
install: 'Install',
installFromGithub: 'Install Plugin from GitHub',
installPlugin: 'Install Plugin',
onlySupportGithub: 'Currently only supports installation from GitHub',
enterGithubLink: 'Enter GitHub link of the plugin',
installing: 'Installing plugin...',

View File

@@ -142,7 +142,7 @@ const jaJP = {
marketplace: 'プラグインマーケット',
arrange: '並び替え',
install: 'インストール',
installFromGithub: 'GitHubからプラグインをインストール',
installPlugin: 'プラグインをインストール',
onlySupportGithub: '現在はGitHubからのインストールのみサポートしています',
enterGithubLink: 'プラグインのGitHubリンクを入力してください',
installing: 'プラグインをインストール中...',

View File

@@ -139,7 +139,7 @@ const zhHans = {
marketplace: '插件市场',
arrange: '编排',
install: '安装',
installFromGithub: '从 GitHub 安装插件',
installPlugin: '安装插件',
onlySupportGithub: '目前仅支持从 GitHub 安装',
enterGithubLink: '请输入插件的Github链接',
installing: '正在安装插件...',