diff --git a/apps/jingrow/frontend/src/views/dev/AppInstaller.vue b/apps/jingrow/frontend/src/views/dev/AppInstaller.vue index fc5e23e..ff61f12 100644 --- a/apps/jingrow/frontend/src/views/dev/AppInstaller.vue +++ b/apps/jingrow/frontend/src/views/dev/AppInstaller.vue @@ -19,7 +19,7 @@ ref="uploadRef" :file-list="fileList" :max="1" - accept=".zip" + accept=".zip,.tar.gz,.tgz,.gz" :show-file-list="false" :on-before-upload="beforeUpload" :on-change="handleFileChange" @@ -32,17 +32,17 @@ - {{ uploading ? t('Uploading...') : t('Click or drag ZIP file to this area to upload') }} + {{ uploading ? t('Uploading...') : t('Click or drag package file to this area to upload') }} - {{ t('Support for ZIP format only') }} + {{ t('Support for ZIP, TAR.GZ, and GZ format') }} - -
+ +
('info') const showProgressModal = ref(false) +const isExtensionFile = ref(false) // 是否是扩展包 // 本地App相关 const localApps = ref([]) @@ -180,8 +181,12 @@ const beforeUpload = (data: { file: UploadFileInfo }) => { const file = data.file.file if (!file) return false - if (!file.name.toLowerCase().endsWith('.zip')) { - message.error(t('Only ZIP files are supported')) + const fileName = file.name.toLowerCase() + const allowedExtensions = ['.zip', '.tar.gz', '.tgz', '.gz'] + const isValidFile = allowedExtensions.some(ext => fileName.endsWith(ext)) + + if (!isValidFile) { + message.error(t('Only ZIP and TAR.GZ files are supported')) return false } @@ -190,6 +195,19 @@ const beforeUpload = (data: { file: UploadFileInfo }) => { const handleFileChange = (options: { fileList: UploadFileInfo[] }) => { fileList.value = options.fileList + + // 检测是否是扩展包 + if (fileList.value.length > 0) { + const fileName = fileList.value[0].file?.name || '' + isExtensionFile.value = fileName.toLowerCase().endsWith('.tar.gz') || + fileName.toLowerCase().endsWith('.tgz') || + fileName.toLowerCase().endsWith('.gz') + + // 如果是扩展包,清空应用名称(因为不需要) + if (isExtensionFile.value) { + appName.value = '' + } + } } const customUpload = async (_options: any) => { @@ -209,6 +227,92 @@ const startUpload = async () => { return } + // 判断文件类型,选择对应的 API endpoint + const fileName = file.name.toLowerCase() + const isExtensionPackage = fileName.endsWith('.tar.gz') || fileName.endsWith('.tgz') || fileName.endsWith('.gz') + + // 如果是一键安装的扩展包,直接调用 install_package + if (isExtensionPackage) { + try { + uploading.value = true + installing.value = true + showProgressModal.value = true + installProgress.value = 0 + installMessage.value = t('Uploading file...') + installStatus.value = 'info' + + // 先上传文件保存到 public/files + const formData = new FormData() + formData.append('file', file) + + installProgress.value = 20 + installMessage.value = t('Saving package...') + + // 第一步:保存文件 + const saveResponse = await axios.post('/jingrow/install-extension', formData, { + headers: { + ...get_session_api_headers(), + 'Content-Type': 'multipart/form-data' + }, + timeout: 60000 // 60秒超时 + }) + + console.log('Save response:', saveResponse.data) + + if (!saveResponse.data.success) { + throw new Error(saveResponse.data.error || t('Failed to save package')) + } + + const fileUrl = saveResponse.data.file_url + console.log('File URL:', fileUrl) + + installProgress.value = 50 + installMessage.value = t('Installing package...') + + // 第二步:调用 install_package 安装 + const installResponse = await axios.post('/api/action/jingrow.ai.utils.jlocal.install_package', { + package_file_url: fileUrl + }, { + headers: get_session_api_headers() + }) + + installProgress.value = 100 + installMessage.value = t('Installation completed!') + installStatus.value = 'success' + + if (installResponse.data.message && installResponse.data.message.success) { + clearFiles() + const result = installResponse.data.message + message.success(t('Package \'{0}\' installed successfully').replace('{0}', result.package_name)) + } else { + throw new Error(installResponse.data.message?.error || t('Installation failed')) + } + + } catch (error: any) { + installProgress.value = 100 + installMessage.value = t('Installation failed!') + installStatus.value = 'error' + + console.error('Upload error:', error) + console.error('Error response data:', error.response?.data) + + const errorMsg = error.response?.data?.detail || + error.response?.data?.error || + error.message || + t('Upload failed') + + console.error('Final error message:', errorMsg) + message.error(errorMsg) + } finally { + uploading.value = false + installing.value = false + } + + return // 扩展包使用特殊流程,提前返回 + } + + const apiEndpoint = '/jingrow/install/upload' + try { uploading.value = true installing.value = true @@ -220,15 +324,16 @@ const startUpload = async () => { // 创建FormData const formData = new FormData() formData.append('file', file) - if (appName.value.trim()) { + if (appName.value.trim() && !isExtensionPackage) { + // 只有普通应用包才需要 app_name 参数 formData.append('app_name', appName.value.trim()) } installProgress.value = 30 - installMessage.value = t('Uploading file...') + installMessage.value = isExtensionPackage ? t('Installing extension package...') : t('Uploading file...') // 上传文件 - const response = await axios.post('/jingrow/install/upload', formData, { + const response = await axios.post(apiEndpoint, formData, { headers: { ...get_session_api_headers(), 'Content-Type': 'multipart/form-data' @@ -249,7 +354,8 @@ const startUpload = async () => { // 清空文件列表 clearFiles() - message.success(t('App \'{0}\' installed successfully').replace('{0}', response.data.app_name)) + const resultAppName = isExtensionPackage ? response.data.package_name : response.data.app_name + message.success(t('Package \'{0}\' installed successfully').replace('{0}', resultAppName)) } else { throw new Error(response.data.error || t('Installation failed')) } @@ -260,7 +366,17 @@ const startUpload = async () => { installStatus.value = 'error' console.error('Upload error:', error) - message.error(error.response?.data?.detail || error.message || t('Upload failed')) + console.error('Error response:', error.response) + console.error('Error details:', error.response?.data) + + // 更详细的错误信息 + const errorDetail = error.response?.data?.detail || + error.response?.data?.message || + error.message || + t('Upload failed') + + console.error('Final error detail:', errorDetail) + message.error(errorDetail) } finally { uploading.value = false installing.value = false @@ -270,6 +386,7 @@ const startUpload = async () => { const clearFiles = () => { fileList.value = [] appName.value = '' + isExtensionFile.value = false uploadRef.value?.clear() } diff --git a/apps/jingrow/jingrow/api/local_app_installer.py b/apps/jingrow/jingrow/api/local_app_installer.py index b28eda0..a654d6f 100644 --- a/apps/jingrow/jingrow/api/local_app_installer.py +++ b/apps/jingrow/jingrow/api/local_app_installer.py @@ -14,7 +14,7 @@ import json import shutil import re -from jingrow.utils.app_installer import install_app, get_installed_apps as get_apps, get_app_directories +from jingrow.utils.app_installer import install_app, get_installed_apps as get_apps, get_app_directories, install_extension_package from jingrow.utils.jingrow_api import log_info, log_error from jingrow.utils.auth import get_jingrow_cloud_url, get_jingrow_cloud_api_headers, get_jingrow_cloud_api_url, get_jingrow_api_headers from jingrow.config import Config @@ -419,6 +419,92 @@ async def get_app_info(request: Request, app_name: str): raise HTTPException(status_code=500, detail=str(e)) +async def _install_extension_package_in_api(package_path: str, original_filename: str) -> Dict[str, Any]: + """保存扩展包并立即安装""" + import shutil + import requests + + try: + log_info(f"保存扩展包文件: {original_filename}") + + # 复制文件到 public/files 目录 + # 从 jingrow-framework/apps/jingrow/jingrow/api/local_app_installer.py + # 到 /home/jingrow/jingrow-bench/sites/test001/public/files/ + current = Path(__file__).resolve() + + # current: /home/dev/jingrow-framework/apps/jingrow/jingrow/api/local_app_installer.py + # 需要回到 /home/jingrow/jingrow-bench + + # 如果当前在 framework 目录,则使用相对路径找到 jingrow-bench + if 'jingrow-framework' in str(current): + # 从 framework 目录回到 jingrow-bench + jingrow_bench_path = Path('/home/jingrow/jingrow-bench') + else: + # 从 apps/jingrow/... 到 jingrow-bench + project_root = current.parents[6] if current.parts.count('apps') > 1 else current.parents[5] + jingrow_bench_path = project_root + + target_dir = jingrow_bench_path / "sites" / "test001" / "public" / "files" + target_dir.mkdir(parents=True, exist_ok=True) + + target_path = target_dir / original_filename + + # 复制文件 + shutil.copy2(package_path, target_path) + + log_info(f"文件已保存到: {target_path}") + + # 立即调用 jlocal API 安装扩展包 + try: + from jingrow.utils.jingrow_api import get_jingrow_api_headers + headers = get_jingrow_api_headers() + + # 调用 jlocal.install_package + api_url = f"{Config.jingrow_server_url}/api/action/jingrow.ai.utils.jlocal.install_package" + + file_url = f"/files/{original_filename}" + response = requests.post( + api_url, + json={'package_file_url': file_url}, + headers=headers, + timeout=60 + ) + + if response.status_code == 200: + result = response.json() + if result.get('message', {}).get('success'): + log_info(f"扩展包安装成功: {result.get('message', {}).get('package_name')}") + return { + 'success': True, + 'message': f'扩展包安装成功', + 'package_name': result.get('message', {}).get('package_name'), + 'file_url': file_url, + 'imported_files': result.get('message', {}).get('imported_files', []), + 'file_count': result.get('message', {}).get('file_count', 0) + } + else: + error_msg = result.get('message', {}).get('error', '未知错误') + log_error(f"安装失败: {error_msg}") + return {'success': False, 'error': error_msg} + else: + log_error(f"API调用失败: HTTP {response.status_code}") + return {'success': False, 'error': f'API调用失败: HTTP {response.status_code}'} + + except Exception as api_error: + log_error(f"调用安装API失败: {str(api_error)}") + return { + 'success': True, + 'message': f'扩展包已保存到 public/files 目录', + 'file_url': f'/files/{original_filename}', + 'file_path': str(target_path), + 'note': '文件已上传,请手动在 jingrow 应用中使用 Package Import 功能导入' + } + + except Exception as e: + log_error(f"保存扩展包失败: {str(e)}") + return {'success': False, 'error': f'保存文件失败: {str(e)}'} + + async def _remove_from_database(app_name: str) -> Dict[str, Any]: """从数据库中删除应用记录""" try: @@ -555,6 +641,105 @@ async def get_app_meta(): raise HTTPException(status_code=500, detail=f"获取元数据失败: {str(e)}") +@router.post("/jingrow/install-extension") +async def install_extension_package_api(request: Request, file: UploadFile = File(...)): + """安装扩展包到数据库""" + temp_file_path = None + try: + log_info(f"开始处理上传的扩展包: {file.filename}") + + # 验证文件类型 + if not file.filename: + raise HTTPException(status_code=400, detail="文件名不能为空") + + filename_lower = file.filename.lower() + if not filename_lower.endswith(('.tar.gz', '.tgz', '.gz')): + raise HTTPException(status_code=400, detail=f"只支持TAR.GZ格式的扩展包,当前文件: {filename_lower}") + + # 保存上传文件到临时目录 + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.tar.gz') + temp_file_path = temp_file.name + try: + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="文件内容为空") + + temp_file.write(content) + temp_file.close() + + log_info(f"文件已保存到临时位置: {temp_file_path}, 大小: {len(content)} 字节") + + # 保存文件到 jingrow-bench/sites/test001/public/files/ + target_dir = Path('/home/jingrow/jingrow-bench/sites/test001/public/files') + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / file.filename + + # 复制文件 + shutil.copy2(temp_file_path, target_path) + + # 修复文件权限和所有者 + import pwd + try: + jingrow_user = pwd.getpwnam('jingrow') + os.chown(target_path, jingrow_user.pw_uid, jingrow_user.pw_gid) + os.chmod(target_path, 0o644) + log_info(f"文件权限已修复: owner=jingrow, permissions=644") + except Exception as perm_error: + log_error(f"修复文件权限失败: {perm_error}") + + log_info(f"文件已保存到: {target_path}") + + # 调用 install_package API 安装 + from jingrow.utils.jingrow_api import get_jingrow_api_headers + import requests + + api_url = f"{Config.jingrow_server_url}/api/action/jingrow.ai.utils.jlocal.install_package" + headers = get_jingrow_api_headers() + + file_url = f"/files/{file.filename}" + response = requests.post( + api_url, + json={'package_file_url': file_url}, + headers=headers, + timeout=60 + ) + + if response.status_code == 200: + result_data = response.json() + if result_data.get('message', {}).get('success'): + log_info(f"扩展包安装成功: {result_data['message'].get('package_name')}") + return { + 'success': True, + 'package_name': result_data['message']['package_name'], + 'file_url': file_url, + 'file_count': result_data['message'].get('file_count', 0) + } + else: + error_msg = result_data.get('message', {}).get('error', '未知错误') + log_error(f"安装失败: {error_msg}") + raise HTTPException(status_code=400, detail=error_msg) + else: + log_error(f"API调用失败: HTTP {response.status_code}") + raise HTTPException(status_code=500, detail=f'安装API调用失败: HTTP {response.status_code}') + + finally: + # 清理临时文件 + if temp_file_path: + try: + os.unlink(temp_file_path) + log_info(f"临时文件已删除: {temp_file_path}") + except Exception as cleanup_error: + log_error(f"清理临时文件失败: {cleanup_error}") + + except HTTPException: + raise + except Exception as e: + import traceback + error_detail = traceback.format_exc() + log_error(f"安装扩展包失败: {str(e)}\n{error_detail}") + raise HTTPException(status_code=500, detail=f"安装扩展包失败: {str(e)}") + + @router.post("/jingrow/upload-image") async def upload_image(file: UploadFile = File(...)): """上传应用图片""" diff --git a/apps/jingrow/jingrow/utils/app_installer.py b/apps/jingrow/jingrow/utils/app_installer.py index 1d522f9..384802e 100644 --- a/apps/jingrow/jingrow/utils/app_installer.py +++ b/apps/jingrow/jingrow/utils/app_installer.py @@ -7,6 +7,7 @@ import json import shutil import tempfile import zipfile +import tarfile from pathlib import Path from typing import Dict, List, Any from datetime import datetime @@ -43,11 +44,18 @@ def get_app_directories(): @handle_errors def extract_package(zip_path: str) -> Dict[str, Any]: - """解压安装包""" + """解压安装包 - 支持 ZIP 和 TAR.GZ""" temp_dir = tempfile.mkdtemp(prefix="jingrow_app_install_") - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - zip_ref.extractall(temp_dir) + # 判断文件类型 + if zip_path.endswith('.tar.gz') or zip_path.endswith('.tgz') or zip_path.endswith('.gz'): + with tarfile.open(zip_path, 'r:gz') as tar_ref: + tar_ref.extractall(temp_dir) + elif zip_path.endswith('.zip'): + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + else: + return {'success': False, 'error': '不支持的文件格式'} return {'success': True, 'temp_dir': temp_dir} @@ -303,3 +311,125 @@ def install_app(uploaded_file_path: str, app_name: str = None) -> Dict[str, Any] except Exception as e: log_error(f"安装应用失败: {str(e)}") return {'success': False, 'error': str(e)} + + +def install_extension_package(package_path: str) -> Dict[str, Any]: + """ + 直接安装扩展包到数据库(不走文件系统复制) + + Args: + package_path: 扩展包文件路径(tar.gz) + + Returns: + Dict: 安装结果 + """ + try: + log_info(f"开始安装扩展包: {package_path}") + + # 验证文件 + if not os.path.exists(package_path): + return {'success': False, 'error': '文件不存在'} + + # 解压文件 + extract_result = extract_package(package_path) + if not extract_result.get('success'): + return extract_result + + temp_dir = extract_result['temp_dir'] + + # 查找根目录(通常是包名) + root_items = os.listdir(temp_dir) + if not root_items: + cleanup_temp_dir(temp_dir) + return {'success': False, 'error': '压缩包为空'} + + # 找到顶层目录 + root_dir = os.path.join(temp_dir, root_items[0]) if len(root_items) == 1 and os.path.isdir(os.path.join(temp_dir, root_items[0])) else temp_dir + package_name = os.path.basename(root_dir) + + # 查找 package.json 文件 + package_json_path = os.path.join(root_dir, f"{package_name}.json") + if not os.path.exists(package_json_path): + # 尝试查找所有json文件,找到pagetype为Package的 + for root, dirs, files in os.walk(root_dir): + for file in files: + if file.endswith('.json'): + try: + with open(os.path.join(root, file), 'r') as f: + data = json.load(f) + if data.get('pagetype') == 'Package': + package_json_path = os.path.join(root, file) + break + except: + pass + if os.path.exists(package_json_path): + break + + if not os.path.exists(package_json_path): + cleanup_temp_dir(temp_dir) + return {'success': False, 'error': '找不到 Package.json 文件'} + + # 导入 Package + try: + import jingrow + from jingrow.modules.import_file import import_pg, import_file_by_path + from jingrow.model.sync import get_pg_files + + # 确保 jingrow 环境已初始化 + if not hasattr(jingrow, 'db') or jingrow.db is None: + cleanup_temp_dir(temp_dir) + return {'success': False, 'error': 'jingrow 环境未初始化,请在 API 层调用'} + + with open(package_json_path, 'r', encoding='utf-8') as f: + pg_dict = json.load(f) + + # 验证 Package 数据 + if not pg_dict.get('pagetype') == 'Package': + cleanup_temp_dir(temp_dir) + return {'success': False, 'error': f'无效的 Package 文件,pagetype 为: {pg_dict.get("pagetype")}'} + + # 导入 Package 到数据库 + package_doc = import_pg(pg_dict, ignore_version=True) + jingrow.flags.package = package_doc + + # 收集所有 pagetype 文件 + files = [] + for module in os.listdir(root_dir): + module_path = os.path.join(root_dir, module) + if os.path.isdir(module_path): + files = get_pg_files(files, module_path) + + log_info(f"找到 {len(files)} 个 pagetype 文件待导入") + + # 导入所有文件 + imported_files = [] + for file in files: + try: + import_file_by_path(file, force=True, ignore_version=True) + imported_files.append(file) + except Exception as e: + log_error(f"导入文件失败 {file}: {str(e)}") + # 继续导入其他文件,不中断 + + # 清理临时文件 + cleanup_temp_dir(temp_dir) + + log_info(f"扩展包 {package_name} 安装成功,导入了 {len(imported_files)} 个文件") + return { + 'success': True, + 'message': f'扩展包 {package_name} 安装成功', + 'package_name': package_name, + 'imported_files': imported_files, + 'file_count': len(imported_files) + } + + except Exception as e: + cleanup_temp_dir(temp_dir) + import traceback + error_detail = traceback.format_exc() + log_error(f"导入失败: {str(e)}") + return {'success': False, 'error': f'导入失败: {str(e)}', 'detail': error_detail} + + except Exception as e: + log_error(f"安装扩展包失败: {str(e)}") + return {'success': False, 'error': str(e)}