增加节点市场

This commit is contained in:
jingrow 2025-11-02 04:09:42 +08:00
parent 1bad5017a1
commit e0096da893
6 changed files with 1054 additions and 11 deletions

View File

@ -11,14 +11,16 @@
</div> </div>
<!-- 导航菜单 --> <!-- 导航菜单 -->
<n-menu <div class="menu-container">
:collapsed="collapsed" <n-menu
:collapsed-width="64" :collapsed="collapsed"
:collapsed-icon-size="24" :collapsed-width="64"
:options="menuOptions" :collapsed-icon-size="24"
:value="currentRoute" :options="menuOptions"
@update:value="handleMenuSelect" :value="currentRoute"
/> @update:value="handleMenuSelect"
/>
</div>
</div> </div>
</template> </template>
@ -150,11 +152,20 @@ const handleMenuSelect = (key: string) => {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
.sidebar-header { .sidebar-header {
padding: 16px; padding: 16px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.menu-container {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
} }
.logo { .logo {

View File

@ -135,6 +135,11 @@ const router = createRouter({
name: 'AppMarketplace', name: 'AppMarketplace',
component: () => import('../../views/dev/AppMarketplace.vue') component: () => import('../../views/dev/AppMarketplace.vue')
}, },
{
path: 'node-marketplace',
name: 'NodeMarketplace',
component: () => import('../../views/dev/NodeMarketplace.vue')
},
{ {
path: 'app-marketplace/:name', path: 'app-marketplace/:name',
name: 'AppDetail', name: 'AppDetail',

View File

@ -869,7 +869,25 @@
"Manage your locally installed applications": "管理您本地安装的应用", "Manage your locally installed applications": "管理您本地安装的应用",
"App Marketplace": "应用市场", "App Marketplace": "应用市场",
"Browse and install applications from Jingrow App Marketplace": "浏览和安装来自 Jingrow 应用市场的应用", "Browse and install applications from Jingrow App Marketplace": "浏览和安装来自 Jingrow 应用市场的应用",
"Node Marketplace": "节点市场",
"Browse and install nodes from Jingrow Node Marketplace": "浏览和安装来自 Jingrow 节点市场的节点",
"Search applications...": "搜索应用...", "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": "开发团队", "Team": "开发团队",
"Repository": "仓库", "Repository": "仓库",
"Install": "安装", "Install": "安装",

View File

@ -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: '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: '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: '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: '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' } { id: 'settings', key: 'Settings', label: 'Settings', icon: 'tabler:settings', routeName: 'Settings', order: 11, type: 'route' }
] ]

View File

@ -0,0 +1,613 @@
<template>
<div class="node-marketplace">
<div class="page-header">
<div class="header-content">
<div class="header-text">
<h1>{{ t('Node Marketplace') }}</h1>
<p>{{ t('Browse and install nodes from Jingrow Node Marketplace') }}</p>
</div>
</div>
</div>
<div class="content">
<div class="search-container">
<div class="search-bar">
<n-input
v-model:value="searchQuery"
:placeholder="t('Search nodes...')"
clearable
size="large"
@keyup.enter="loadNodes"
class="search-input"
>
<template #prefix>
<n-icon><Icon icon="tabler:search" /></n-icon>
</template>
</n-input>
<n-button type="primary" size="large" @click="loadNodes" class="search-button">
<template #icon>
<n-icon><Icon icon="tabler:search" /></n-icon>
</template>
{{ t('Search') }}
</n-button>
</div>
</div>
<div class="nodes-section" v-if="!loading && nodes.length > 0">
<!-- 排序控件 -->
<div class="nodes-header">
<div class="nodes-title">
</div>
<div class="sort-controls">
<n-select
v-model:value="sortBy"
:options="sortOptions"
:placeholder="t('Sort by')"
style="width: 150px"
@update:value="loadNodes"
/>
</div>
</div>
<div class="nodes-grid">
<div v-for="node in nodes" :key="node.name" class="node-card">
<!-- 节点图标 -->
<div class="node-icon" @click="viewNodeDetail(node)">
<Icon
v-if="node.icon"
:icon="node.icon"
:width="48"
:height="48"
:style="{ color: node.color || '#6b7280' }"
/>
<div v-else class="node-icon-placeholder">
<n-icon size="48"><Icon icon="tabler:cube" /></n-icon>
</div>
</div>
<!-- 节点信息 -->
<div class="node-content">
<div class="node-header">
<div class="node-title-section">
<h3 @click="viewNodeDetail(node)" class="clickable-title">{{ node.title || node.name }}</h3>
<div class="node-type" v-if="node.node_type">
{{ node.node_type }}
</div>
</div>
</div>
<div class="node-description" v-if="node.description">
{{ truncateText(node.description, 80) }}
</div>
<div class="node-meta" v-if="node.group">
<n-icon><Icon icon="tabler:category" /></n-icon>
<span>{{ node.group }}</span>
</div>
</div>
<div class="node-actions">
<n-button type="default" @click="viewNodeDetail(node)">
{{ t('View Details') }}
</n-button>
<n-button
v-if="isNodeInstalled(node.node_type || node.name)"
type="warning"
@click="installNode(node)"
>
{{ t('Update') }}
</n-button>
<n-button
v-else
type="primary"
@click="installNode(node)"
>
{{ t('Install') }}
</n-button>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-container">
<n-pagination
v-model:page="page"
:page-count="pageCount"
size="large"
show-size-picker
:page-sizes="[20, 50, 100]"
:page-size="pageSize"
@update:page="loadNodes"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<div v-if="loading" class="loading">
<n-spin size="large">
<template #description>{{ t('Loading nodes...') }}</template>
</n-spin>
</div>
<div v-if="!loading && nodes.length === 0" class="empty">
<n-empty :description="t('No nodes found')">
<template #icon>
<n-icon><Icon icon="tabler:cube" /></n-icon>
</template>
</n-empty>
</div>
</div>
<!-- 安装进度弹窗 -->
<InstallProgressModal
v-model="showProgressModal"
:progress="installProgress"
:message="installMessage"
:status="installStatus"
:installing="installing"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { NInput, NButton, NIcon, NSpin, NEmpty, NSelect, NPagination, useMessage, useDialog } from 'naive-ui'
import { Icon } from '@iconify/vue'
import axios from 'axios'
import { t } from '@/shared/i18n'
import { get_session_api_headers } from '@/shared/api/auth'
import InstallProgressModal from './InstallProgressModal.vue'
const message = useMessage()
const dialog = useDialog()
const router = useRouter()
const searchQuery = ref('')
const loading = ref(false)
const nodes = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(parseInt(localStorage.getItem('itemsPerPage') || '20'))
const sortBy = ref('creation desc')
//
const installing = ref(false)
const installProgress = ref(0)
const installMessage = ref('')
const installStatus = ref<'success' | 'error' | 'info'>('info')
const showProgressModal = ref(false)
//
const installedNodeTypes = ref<Set<string>>(new Set())
//
const sortOptions = computed(() => [
{ label: t('Latest'), value: 'creation desc' },
{ label: t('Oldest'), value: 'creation asc' },
{ label: t('Name A-Z'), value: 'node_type asc' },
{ label: t('Name Z-A'), value: 'node_type desc' },
{ label: t('Most Popular'), value: 'modified desc' }
])
//
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
async function loadNodes() {
loading.value = true
try {
const params = new URLSearchParams({
page: page.value.toString(),
page_size: pageSize.value.toString(),
search: searchQuery.value,
sort_by: sortBy.value
})
const response = await axios.get(`/jingrow/node-marketplace?${params}`)
const data = response.data
// API
if (data.items) {
nodes.value = data.items
total.value = data.total || 0
} else {
// API
nodes.value = data || []
total.value = nodes.value.length
}
} catch (error) {
console.error('Failed to load nodes:', error)
message.error(t('Failed to load nodes'))
nodes.value = []
total.value = 0
} finally {
loading.value = false
}
}
function handlePageSizeChange(newPageSize: number) {
pageSize.value = newPageSize
page.value = 1
localStorage.setItem('itemsPerPage', newPageSize.toString())
loadNodes()
}
function viewNodeDetail(node: any) {
//
router.push(`/node-marketplace/${node.name}`)
}
async function installNode(node: any) {
if (!node.file_url && !node.repository_url) {
message.error(t('Node file URL or repository address does not exist'))
return
}
//
try {
const nodeType = node.node_type || node.name
if (nodeType) {
const checkResponse = await axios.get(`/jingrow/check-node/${nodeType}`)
if (checkResponse.data.exists) {
//
dialog.warning({
title: t('Node already exists'),
content: t('Node "{0}" is already installed, do you want to overwrite?').replace('{0}', nodeType),
positiveText: t('Confirm Overwrite'),
negativeText: t('Cancel'),
onPositiveClick: () => {
performInstall(node)
}
})
return
}
}
} catch (error) {
console.error('Check node exists error:', error)
}
performInstall(node)
}
async function performInstall(node: any) {
try {
installing.value = true
installProgress.value = 0
installMessage.value = t('Preparing installation...')
installStatus.value = 'info'
showProgressModal.value = true
let response
// 使URL使git
if (node.file_url) {
installMessage.value = t('Downloading node package...')
setTimeout(() => {
installProgress.value = 20
}, 300)
installProgress.value = 30
installMessage.value = t('Installing node...')
response = await axios.post('/jingrow/install-node-from-url', new URLSearchParams({
url: node.file_url
}), {
headers: {
...get_session_api_headers(),
'Content-Type': 'application/x-www-form-urlencoded'
}
})
} else if (node.repository_url) {
installMessage.value = t('Cloning repository...')
setTimeout(() => {
installProgress.value = 20
}, 300)
installProgress.value = 30
installMessage.value = t('Installing node...')
const params = new URLSearchParams({
repo_url: node.repository_url
})
response = await axios.post('/jingrow/install-node-from-git', params, {
headers: {
...get_session_api_headers(),
'Content-Type': 'application/x-www-form-urlencoded'
}
})
}
if (!response) {
throw new Error(t('Unable to determine installation method'))
}
//
installProgress.value = 100
if (response.data.success) {
//
installing.value = false
installStatus.value = 'success'
installMessage.value = t('Node installed successfully!')
message.success(t('Node installed successfully'))
//
loadInstalledNodes()
setTimeout(() => {
showProgressModal.value = false
}, 2000)
} else {
throw new Error(response.data.error || t('Installation failed'))
}
} catch (error: any) {
console.error('Install node error:', error)
installing.value = false
installStatus.value = 'error'
installMessage.value = error.response?.data?.detail || error.message || t('Installation failed')
message.error(error.response?.data?.detail || t('Installation failed'))
setTimeout(() => {
showProgressModal.value = false
}, 3000)
}
}
function truncateText(text: string, maxLength: number): string {
if (!text) return ''
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
//
async function loadInstalledNodes() {
try {
const response = await axios.get('/jingrow/installed-node-types')
if (response.data.success) {
const nodeTypes = response.data.node_types || []
installedNodeTypes.value = new Set(nodeTypes)
}
} catch (error) {
console.error('Load installed nodes error:', error)
}
}
//
function isNodeInstalled(nodeType: string): boolean {
if (!nodeType) return false
return installedNodeTypes.value.has(nodeType.toLowerCase())
}
onMounted(() => {
loadNodes()
loadInstalledNodes()
//
window.addEventListener('installedNodesUpdated', () => {
loadInstalledNodes()
})
})
//
watch([searchQuery, sortBy], () => {
page.value = 1 //
loadNodes()
}, { deep: true })
//
watch([page], () => {
loadNodes()
})
//
watch(() => localStorage.getItem('itemsPerPage'), (newValue) => {
if (newValue) {
pageSize.value = parseInt(newValue)
page.value = 1 //
loadNodes()
}
})
</script>
<style scoped>
.node-marketplace {
padding: 24px;
}
.page-header {
margin-bottom: 32px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header-text h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
}
.header-text p {
margin: 0;
color: #666;
font-size: 16px;
}
.search-container {
display: flex;
justify-content: center;
margin-bottom: 32px;
}
.nodes-section {
margin-bottom: 32px;
}
.nodes-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.sort-controls {
display: flex;
align-items: center;
gap: 12px;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 20px 0;
}
.search-bar {
display: flex;
gap: 16px;
align-items: center;
max-width: 600px;
width: 100%;
padding: 20px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 20px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.search-input {
flex: 1;
min-width: 0;
}
.search-button {
border-radius: 12px;
font-weight: 600;
padding: 0 24px;
}
.nodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
.node-card {
border: 1px solid #e5e7eb;
border-radius: 16px;
background: white;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.node-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: #d1d5db;
}
.node-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 120px;
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
cursor: pointer;
padding: 20px;
}
.node-icon-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #9ca3af;
}
.node-content {
padding: 20px;
}
.node-header {
margin-bottom: 12px;
}
.node-title-section h3 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
line-height: 1.2;
}
.clickable-title {
cursor: pointer;
transition: color 0.2s ease;
}
.clickable-title:hover {
color: #10b981;
}
.node-type {
color: #6b7280;
font-size: 11px;
font-weight: 500;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 4px 10px;
display: inline-block;
margin-top: 4px;
}
.node-description {
color: #6b7280;
font-size: 14px;
line-height: 1.5;
margin-bottom: 12px;
}
.node-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
}
.node-actions {
padding: 0 20px 20px;
display: flex;
gap: 12px;
}
.node-actions .n-button {
flex: 1;
}
.loading, .empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
@media (max-width: 768px) {
.nodes-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>

View File

@ -1,11 +1,20 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Form
from typing import Dict, Any, List from typing import Dict, Any, List, Optional
from pathlib import Path from pathlib import Path
import json 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.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() router = APIRouter()
@ -187,3 +196,389 @@ async def get_node_schema(node_type: str):
raise HTTPException(status_code=404, detail=f"节点类型 {node_type} 不存在") raise HTTPException(status_code=404, detail=f"节点类型 {node_type} 不存在")
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(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)}