From e0096da893e7a538585f96fecfce605b9f79642b Mon Sep 17 00:00:00 2001 From: jingrow Date: Sun, 2 Nov 2025 04:09:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=8A=82=E7=82=B9=E5=B8=82?= =?UTF-8?q?=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/src/app/layouts/AppSidebar.vue | 27 +- apps/jingrow/frontend/src/app/router/index.ts | 5 + apps/jingrow/frontend/src/locales/zh-CN.json | 18 + .../frontend/src/shared/stores/menu.ts | 1 + .../src/views/dev/NodeMarketplace.vue | 613 ++++++++++++++++++ apps/jingrow/jingrow/api/node_definitions.py | 401 +++++++++++- 6 files changed, 1054 insertions(+), 11 deletions(-) create mode 100644 apps/jingrow/frontend/src/views/dev/NodeMarketplace.vue diff --git a/apps/jingrow/frontend/src/app/layouts/AppSidebar.vue b/apps/jingrow/frontend/src/app/layouts/AppSidebar.vue index d819a9f..31b3013 100644 --- a/apps/jingrow/frontend/src/app/layouts/AppSidebar.vue +++ b/apps/jingrow/frontend/src/app/layouts/AppSidebar.vue @@ -11,14 +11,16 @@ - + @@ -150,11 +152,20 @@ const handleMenuSelect = (key: string) => { height: 100%; display: flex; flex-direction: column; + overflow: hidden; } .sidebar-header { padding: 16px; border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.menu-container { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; } .logo { diff --git a/apps/jingrow/frontend/src/app/router/index.ts b/apps/jingrow/frontend/src/app/router/index.ts index c653885..4bba04e 100644 --- a/apps/jingrow/frontend/src/app/router/index.ts +++ b/apps/jingrow/frontend/src/app/router/index.ts @@ -135,6 +135,11 @@ const router = createRouter({ name: 'AppMarketplace', component: () => import('../../views/dev/AppMarketplace.vue') }, + { + path: 'node-marketplace', + name: 'NodeMarketplace', + component: () => import('../../views/dev/NodeMarketplace.vue') + }, { path: 'app-marketplace/:name', name: 'AppDetail', diff --git a/apps/jingrow/frontend/src/locales/zh-CN.json b/apps/jingrow/frontend/src/locales/zh-CN.json index 7b71cb3..5b8ed97 100644 --- a/apps/jingrow/frontend/src/locales/zh-CN.json +++ b/apps/jingrow/frontend/src/locales/zh-CN.json @@ -869,7 +869,25 @@ "Manage your locally installed applications": "管理您本地安装的应用", "App Marketplace": "应用市场", "Browse and install applications from Jingrow App Marketplace": "浏览和安装来自 Jingrow 应用市场的应用", + "Node Marketplace": "节点市场", + "Browse and install nodes from Jingrow Node Marketplace": "浏览和安装来自 Jingrow 节点市场的节点", "Search applications...": "搜索应用...", + "Search nodes...": "搜索节点...", + "Loading nodes...": "正在加载节点...", + "No nodes found": "未找到节点", + "Failed to load nodes": "加载节点失败", + "Node file URL or repository address does not exist": "节点文件URL或仓库地址不存在", + "Node already exists": "节点已存在", + "Node \"{0}\" is already installed, do you want to overwrite?": "节点 \"{0}\" 已安装,是否覆盖安装?", + "Confirm Overwrite": "确认覆盖", + "Preparing installation...": "正在准备安装...", + "Downloading node package...": "正在下载节点包...", + "Installing node...": "正在安装节点...", + "Cloning repository...": "正在克隆仓库...", + "Unable to determine installation method": "无法确定安装方式", + "Node installed successfully!": "节点安装成功!", + "Node installed successfully": "节点安装成功", + "Installation failed": "安装失败", "Team": "开发团队", "Repository": "仓库", "Install": "安装", diff --git a/apps/jingrow/frontend/src/shared/stores/menu.ts b/apps/jingrow/frontend/src/shared/stores/menu.ts index c3114ed..fc9ae3c 100644 --- a/apps/jingrow/frontend/src/shared/stores/menu.ts +++ b/apps/jingrow/frontend/src/shared/stores/menu.ts @@ -62,6 +62,7 @@ function getDefaultMenus(): AppMenuItem[] { { id: 'app-installer', key: 'AppInstaller', label: 'App Installer', icon: 'tabler:upload', type: 'route', routeName: 'AppInstaller', parentId: 'dev-group', order: 5 }, { id: 'installed-apps', key: 'InstalledApps', label: 'Installed Apps', icon: 'tabler:apps', type: 'route', routeName: 'InstalledApps', parentId: 'dev-group', order: 6 }, { id: 'app-marketplace', key: 'AppMarketplace', label: 'App Marketplace', icon: 'tabler:shopping-cart', type: 'route', routeName: 'AppMarketplace', parentId: 'dev-group', order: 7 }, + { id: 'node-marketplace', key: 'NodeMarketplace', label: 'Node Marketplace', icon: 'carbon:add-child-node', type: 'route', routeName: 'NodeMarketplace', parentId: 'dev-group', order: 8 }, { id: 'menuManager', key: 'MenuManager', label: 'Menu Management', icon: 'tabler:menu-2', type: 'route', routeName: 'MenuManager', order: 10 }, { id: 'settings', key: 'Settings', label: 'Settings', icon: 'tabler:settings', routeName: 'Settings', order: 11, type: 'route' } ] diff --git a/apps/jingrow/frontend/src/views/dev/NodeMarketplace.vue b/apps/jingrow/frontend/src/views/dev/NodeMarketplace.vue new file mode 100644 index 0000000..3b9175d --- /dev/null +++ b/apps/jingrow/frontend/src/views/dev/NodeMarketplace.vue @@ -0,0 +1,613 @@ + + + + + diff --git a/apps/jingrow/jingrow/api/node_definitions.py b/apps/jingrow/jingrow/api/node_definitions.py index 7f94306..b5e9d3f 100644 --- a/apps/jingrow/jingrow/api/node_definitions.py +++ b/apps/jingrow/jingrow/api/node_definitions.py @@ -1,11 +1,20 @@ -from fastapi import APIRouter, HTTPException -from typing import Dict, Any, List +from fastapi import APIRouter, HTTPException, Form +from typing import Dict, Any, List, Optional from pathlib import Path import json +import os +import uuid +import shutil +import subprocess +import logging +import traceback +import requests from jingrow.utils.fs import atomic_write_json -from jingrow.utils.jingrow_api import get_record_id, create_record +from jingrow.utils.jingrow_api import get_record_id, create_record, update_record, get_record_list +from jingrow.utils.auth import get_jingrow_cloud_url, get_jingrow_cloud_api_headers +logger = logging.getLogger(__name__) router = APIRouter() @@ -187,3 +196,389 @@ async def get_node_schema(node_type: str): raise HTTPException(status_code=404, detail=f"节点类型 {node_type} 不存在") except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== 节点市场 API ==================== + +@router.get("/jingrow/node-marketplace") +async def get_node_marketplace( + search: Optional[str] = None, + page: int = 1, + page_size: int = 20, + sort_by: Optional[str] = None +): + """获取节点市场数据,支持搜索、分页和排序""" + try: + url = f"{get_jingrow_cloud_url()}/api/action/jcloud.api.local_node.get_local_nodes" + + # 构建过滤条件 + filters = {"public": 1} + if search: + filters["title"] = ["like", f"%{search}%"] + filters["node_type"] = ["like", f"%{search}%"] + + # 1. 先获取总数 + total_params = { + 'filters': json.dumps(filters, ensure_ascii=False), + 'limit_start': 0, + 'limit_page_length': 0 + } + + headers = get_jingrow_cloud_api_headers() + + try: + total_response = requests.get(url, params=total_params, headers=headers, timeout=20) + + total_count = 0 + if total_response.status_code == 200: + total_data = total_response.json() + total_count = len(total_data.get('message', [])) + + # 2. 获取分页数据 + params = { + 'filters': json.dumps(filters, ensure_ascii=False) + } + + # 排序参数 + if sort_by: + params['order_by'] = sort_by + + # 分页参数 + limit_start = (page - 1) * page_size + params['limit_start'] = limit_start + params['limit_page_length'] = page_size + + response = requests.get(url, params=params, headers=headers, timeout=20) + + if response.status_code == 200: + data = response.json() + nodes = data.get('message', []) + + return { + "items": nodes, + "total": total_count, + "page": page, + "page_size": page_size + } + else: + # 如果API不存在或失败,返回空列表 + logger.warning(f"获取节点市场数据失败: HTTP {response.status_code}, 返回空列表") + return { + "items": [], + "total": 0, + "page": page, + "page_size": page_size + } + except requests.exceptions.RequestException as e: + # 网络错误或API不存在时返回空列表 + logger.warning(f"节点市场API请求失败: {str(e)}, 返回空列表") + return { + "items": [], + "total": 0, + "page": page, + "page_size": page_size + } + + except Exception as e: + logger.error(f"获取节点市场数据异常: {str(e)}") + logger.error(f"Traceback: {traceback.format_exc()}") + # 即使出错也返回空列表,而不是抛出500错误 + return { + "items": [], + "total": 0, + "page": page, + "page_size": page_size + } + + +@router.get("/jingrow/check-node/{node_type}") +async def check_node_exists(node_type: str): + """检查节点是否已安装""" + try: + result = get_record_id( + pagetype="Local Ai Node", + field="node_type", + value=node_type, + ) + + return {"exists": result.get("success", False)} + except Exception as e: + raise HTTPException(status_code=500, detail=f"检查节点失败: {str(e)}") + + +@router.get("/jingrow/installed-node-types") +async def get_installed_node_types(): + """获取已安装的节点类型列表""" + try: + result = get_record_list( + pagetype="Local Ai Node", + fields=["node_type"], + filters=[], + limit_page_length=1000 + ) + + if result.get("success"): + records = result.get("data", {}).get("data", []) + node_types = [record.get("node_type") for record in records if record.get("node_type")] + return {"success": True, "node_types": node_types} + else: + return {"success": True, "node_types": []} + except Exception as e: + logger.error(f"获取已安装节点类型失败: {str(e)}") + return {"success": True, "node_types": []} + + +@router.post("/jingrow/install-node-from-url") +async def install_node_from_url(url: str = Form(...)): + """从URL安装节点""" + try: + # 下载文件 + current = Path(__file__).resolve() + root = current.parents[4] + tmp_dir = root / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + + # 创建临时文件 + temp_filename = f"node_download_{uuid.uuid4().hex[:8]}{Path(url).suffix}" + temp_file_path = tmp_dir / temp_filename + + # 下载文件 + response = requests.get(url, stream=True, timeout=300) + response.raise_for_status() + + with open(temp_file_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + # 安装节点 + result = _install_node_from_file(str(temp_file_path)) + + # 清理临时文件 + if temp_file_path.exists(): + os.remove(temp_file_path) + + return result + + except Exception as e: + logger.error(f"从URL安装节点失败: {str(e)}") + logger.error(f"Traceback: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"安装节点失败: {str(e)}") + + +@router.post("/jingrow/install-node-from-git") +async def install_node_from_git(repo_url: str = Form(...)): + """从git仓库克隆并安装节点""" + try: + current = Path(__file__).resolve() + root = current.parents[4] + tmp_dir = root / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + + # 创建临时目录用于克隆 + clone_dir = tmp_dir / f"node_git_clone_{uuid.uuid4().hex[:8]}" + + try: + # 使用 git clone 克隆仓库 + result = subprocess.run( + ['git', 'clone', repo_url, str(clone_dir)], + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode != 0: + raise HTTPException(status_code=400, detail=f"Git 克隆失败: {result.stderr}") + + # 查找节点目录(节点包应该包含一个或多个节点目录) + node_dirs = [] + for item in clone_dir.iterdir(): + if item.is_dir() and not item.name.startswith('.') and item.name != '__pycache__': + json_file = item / f"{item.name}.json" + if json_file.exists(): + node_dirs.append(item) + + if not node_dirs: + raise HTTPException(status_code=400, detail="仓库中没有找到节点定义文件") + + # 安装所有找到的节点 + installed_nodes = [] + errors = [] + + for node_dir in node_dirs: + try: + result = _install_single_node_directory(str(node_dir)) + if result.get('success'): + installed_nodes.append(node_dir.name) + else: + errors.append(f"{node_dir.name}: {result.get('error')}") + except Exception as e: + errors.append(f"{node_dir.name}: {str(e)}") + + # 清理临时目录 + shutil.rmtree(clone_dir, ignore_errors=True) + + if errors: + return { + 'success': len(installed_nodes) > 0, + 'installed': installed_nodes, + 'errors': errors, + 'message': f"成功安装 {len(installed_nodes)} 个节点,失败 {len(errors)} 个" + } + else: + return { + 'success': True, + 'installed': installed_nodes, + 'message': f"成功安装 {len(installed_nodes)} 个节点" + } + + finally: + # 确保清理临时目录 + if clone_dir.exists(): + shutil.rmtree(clone_dir, ignore_errors=True) + + except HTTPException: + raise + except Exception as e: + logger.error(f"从Git安装节点失败: {str(e)}") + logger.error(f"Traceback: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"安装节点失败: {str(e)}") + + +def _install_node_from_file(file_path: str) -> Dict[str, Any]: + """从文件安装节点(支持ZIP和TAR.GZ)""" + try: + from jingrow.utils.app_installer import extract_package, cleanup_temp_dir + + # 解压文件 + extract_result = extract_package(file_path) + if not extract_result.get('success'): + return extract_result + + temp_dir = extract_result['temp_dir'] + + try: + # 查找节点目录 + node_dirs = [] + for item in Path(temp_dir).iterdir(): + if item.is_dir() and not item.name.startswith('.') and item.name != '__pycache__': + json_file = item / f"{item.name}.json" + if json_file.exists(): + node_dirs.append(item) + + if not node_dirs: + return {'success': False, 'error': '压缩包中没有找到节点定义文件'} + + # 安装所有找到的节点 + installed_nodes = [] + errors = [] + + for node_dir in node_dirs: + try: + result = _install_single_node_directory(str(node_dir)) + if result.get('success'): + installed_nodes.append(node_dir.name) + else: + errors.append(f"{node_dir.name}: {result.get('error')}") + except Exception as e: + errors.append(f"{node_dir.name}: {str(e)}") + + if errors: + return { + 'success': len(installed_nodes) > 0, + 'installed': installed_nodes, + 'errors': errors, + 'message': f"成功安装 {len(installed_nodes)} 个节点,失败 {len(errors)} 个" + } + else: + return { + 'success': True, + 'installed': installed_nodes, + 'message': f"成功安装 {len(installed_nodes)} 个节点" + } + + finally: + cleanup_temp_dir(temp_dir) + + except Exception as e: + logger.error(f"从文件安装节点失败: {str(e)}") + return {'success': False, 'error': str(e)} + + +def _install_single_node_directory(node_dir: str) -> Dict[str, Any]: + """安装单个节点目录到 ai/nodes 并导入数据库""" + try: + node_dir_path = Path(node_dir) + node_name = node_dir_path.name + + # 读取节点定义文件 + json_file = node_dir_path / f"{node_name}.json" + if not json_file.exists(): + return {'success': False, 'error': f'找不到节点定义文件: {json_file.name}'} + + with open(json_file, 'r', encoding='utf-8') as f: + node_data = json.load(f) + + if not isinstance(node_data, dict): + return {'success': False, 'error': '节点定义文件格式错误'} + + metadata = node_data.get("metadata") or {} + node_type = metadata.get("type") + if not node_type: + return {'success': False, 'error': '节点定义中缺少 metadata.type'} + + # 确定目标目录:apps/jingrow/jingrow/ai/nodes + current_file = Path(__file__).resolve() + # node_definitions.py 位于 jingrow/api/ + # parents[0] = jingrow/api, parents[1] = jingrow + jingrow_root = current_file.parents[1] # jingrow + nodes_root = jingrow_root / "ai" / "nodes" + nodes_root.mkdir(parents=True, exist_ok=True) + + target_node_dir = nodes_root / node_type + + # 如果目标目录已存在,先删除 + if target_node_dir.exists(): + shutil.rmtree(target_node_dir) + + # 复制整个节点目录 + shutil.copytree(node_dir_path, target_node_dir) + + # 导入到数据库 + # 检查是否已存在 + exists_res = get_record_id( + pagetype="Local Ai Node", + field="node_type", + value=node_type, + ) + + # 生成 schema(移除 metadata) + schema = dict(node_data) + schema.pop("metadata", None) + + payload = { + "node_type": node_type, + "node_label": metadata.get("label") or node_type, + "node_icon": metadata.get("icon") or "fa-cube", + "node_color": metadata.get("color") or "#6b7280", + "node_group": metadata.get("group") or "", + "node_component": metadata.get("component_type") or "GenericNode", + "node_description": metadata.get("description") or "", + "status": "Published", + "node_schema": schema, + } + + if exists_res.get("success"): + # 更新现有记录,使用 node_type 作为 name + res = update_record("Local Ai Node", node_type, payload) + else: + # 创建新记录 + res = create_record("Local Ai Node", payload) + + if res.get("success"): + return {'success': True, 'node_type': node_type, 'message': f'节点 {node_type} 安装成功'} + else: + return {'success': False, 'error': res.get('error', '导入数据库失败')} + + except Exception as e: + logger.error(f"安装节点目录失败: {str(e)}") + return {'success': False, 'error': str(e)}