feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* XFlow AI Utility — OpenRouter & Google Gemini Integration (Updated for May 2026 Models)
|
||||
*/
|
||||
|
||||
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||
|
||||
/** Доступные модели с описанием для выбора в UI */
|
||||
export const AI_MODELS = {
|
||||
/** Сложные инженерные и архитектурные задачи, высокая стабильность */
|
||||
laguna: 'poolside/laguna-m.1:free',
|
||||
/** MoE 120B тяжёлая аналитика, архитектурный анализ, сложные рассуждения */
|
||||
nemotron: 'nvidia/nemotron-3-super-120b-a12b:free',
|
||||
/** Прямой вызов Gemini 3.5 Flash */
|
||||
geminiFlash: 'gemini-3.5-flash',
|
||||
/** Прямой вызов Gemini 3.1 Pro Preview */
|
||||
geminiPro: 'gemini-3.1-pro-preview',
|
||||
/** Прямой вызов Gemini 3.1 Flash Lite */
|
||||
geminiLite: 'gemini-3.1-flash-lite'
|
||||
} as const
|
||||
|
||||
export type AiModelKey = keyof typeof AI_MODELS
|
||||
|
||||
/** Дефолтная модель, если не указана (мыслящая Gemini 3.1 Pro) */
|
||||
const DEFAULT_MODEL: AiModelKey = 'geminiPro'
|
||||
|
||||
export const generateAIResponse = async (
|
||||
prompt: string,
|
||||
systemPrompt = 'You are a helpful assistant.',
|
||||
modelKey?: AiModelKey | string,
|
||||
): Promise<string> => {
|
||||
// Получаем ключи из окружения
|
||||
const openRouterKey = process.env.NUXT_OPEN_ROUTER_API_KEY || process.env.OPEN_ROUTER_API_KEY
|
||||
const geminiKey = process.env.GEMINI_API_KEY || process.env.NUXT_GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
||||
|
||||
// Резолвим модель: принимаем как ключ ('laguna') так и полное имя
|
||||
const resolvedModel: string =
|
||||
modelKey && modelKey in AI_MODELS
|
||||
? AI_MODELS[modelKey as AiModelKey]
|
||||
: modelKey && (modelKey.includes('/') || modelKey.startsWith('gemini'))
|
||||
? modelKey
|
||||
: AI_MODELS[DEFAULT_MODEL]
|
||||
|
||||
// Определяем, использовать ли Gemini напрямую
|
||||
const useGeminiDirect = resolvedModel.startsWith('gemini') || (!openRouterKey && geminiKey)
|
||||
|
||||
if (useGeminiDirect) {
|
||||
if (!geminiKey) {
|
||||
throw new Error('Gemini API key (GEMINI_API_KEY or GOOGLE_API_KEY) is not configured')
|
||||
}
|
||||
const geminiModel = resolvedModel.startsWith('gemini') ? resolvedModel : 'gemini-3.5-flash'
|
||||
console.log(`[AI] Direct Google Gemini call: ${geminiModel}`)
|
||||
|
||||
try {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${geminiKey}`
|
||||
const response = await $fetch<any>(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }]
|
||||
}
|
||||
],
|
||||
systemInstruction: {
|
||||
parts: [{ text: systemPrompt }]
|
||||
},
|
||||
generationConfig: {
|
||||
temperature: 0.3
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const content = response?.candidates?.[0]?.content?.parts?.[0]?.text
|
||||
if (!content) {
|
||||
throw new Error('Empty response from Gemini API')
|
||||
}
|
||||
return content
|
||||
} catch (error: any) {
|
||||
console.error(`[AI] Gemini direct error:`, error?.data || error?.message)
|
||||
throw new Error(`Gemini direct generation failed: ${error?.message || error?.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Иначе используем OpenRouter
|
||||
if (!openRouterKey) {
|
||||
throw new Error('Neither OPEN_ROUTER_API_KEY nor GEMINI_API_KEY is configured')
|
||||
}
|
||||
|
||||
console.log(`[AI] OpenRouter call: ${resolvedModel}`)
|
||||
|
||||
try {
|
||||
const response = await $fetch<any>(OPENROUTER_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${openRouterKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://a-crm.controlsystem.kz',
|
||||
'X-Title': 'XFlow AI Node',
|
||||
},
|
||||
body: {
|
||||
model: resolvedModel,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
stream: false,
|
||||
},
|
||||
})
|
||||
|
||||
const content = response?.choices?.[0]?.message?.content
|
||||
if (!content) {
|
||||
throw new Error('Empty response from OpenRouter model')
|
||||
}
|
||||
return content
|
||||
} catch (error: any) {
|
||||
console.error(`[AI] OpenRouter error with model ${resolvedModel}:`, error?.data || error?.message)
|
||||
throw new Error(`OpenRouter generation failed (${resolvedModel}): ${error?.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Хелпер: список моделей для фронтенда */
|
||||
export const getAvailableModels = () =>
|
||||
Object.entries(AI_MODELS).map(([key, modelId]) => ({
|
||||
key,
|
||||
modelId,
|
||||
label: modelLabels[key as AiModelKey],
|
||||
}))
|
||||
|
||||
const modelLabels: Record<AiModelKey, string> = {
|
||||
laguna: '🏊 Laguna M.1 — стабильная инженерная (Claude via OpenRouter)',
|
||||
nemotron: '🔬 Nemotron Super 120B — тяжёлая аналитика (NVIDIA via OpenRouter)',
|
||||
geminiFlash: '⚡ Gemini 3.5 Flash — быстрая и стабильная (Google Direct)',
|
||||
geminiPro: '🧠 Gemini 3.1 Pro — продвинутая аналитика (Google Direct)',
|
||||
geminiLite: '🍃 Gemini 3.1 Flash Lite — экономичная (Google Direct)'
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { H3Event, createError } from 'h3'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
displayName?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export function generateToken(user: AuthUser): string {
|
||||
const config = useRuntimeConfig()
|
||||
const secret = config.jwtSecret || 'your-super-secret-jwt-key'
|
||||
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
role: user.role || 'user'
|
||||
},
|
||||
secret,
|
||||
{ expiresIn: '7d' }
|
||||
)
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): AuthUser {
|
||||
const config = useRuntimeConfig()
|
||||
const secret = config.jwtSecret || 'your-super-secret-jwt-key'
|
||||
|
||||
return jwt.verify(token, secret) as AuthUser
|
||||
}
|
||||
|
||||
export function getAuthUser(event: H3Event): AuthUser | null {
|
||||
const authHeader = getHeader(event, 'authorization')
|
||||
const token = authHeader?.split(' ')[1]
|
||||
if (!token) return null
|
||||
try {
|
||||
return verifyToken(token)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(event: H3Event): AuthUser {
|
||||
const user = getAuthUser(event)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export function requireRole(event: H3Event, role: string): AuthUser {
|
||||
const user = requireAuth(event)
|
||||
if (user.role !== role) {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import type { AuthUser } from './auth'
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
text?: string
|
||||
file?: string
|
||||
frontmatter?: Record<string, any>
|
||||
state?: 'pending' | 'in_progress' | 'completed'
|
||||
label?: string
|
||||
assigned_to?: string
|
||||
alarm?: string
|
||||
script_id?: string
|
||||
prompt?: string
|
||||
url?: string
|
||||
method?: string
|
||||
repo?: string
|
||||
branch?: string
|
||||
message?: string
|
||||
style?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface CanvasEdge {
|
||||
id: string
|
||||
fromNode: string
|
||||
fromSide: string
|
||||
toNode: string
|
||||
toSide: string
|
||||
}
|
||||
|
||||
export interface CanvasData {
|
||||
id: string
|
||||
name: string
|
||||
nodes: CanvasNode[]
|
||||
edges: CanvasEdge[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
ownerId: string
|
||||
groupId?: string
|
||||
isPublic?: boolean
|
||||
}
|
||||
|
||||
export interface UserGroup {
|
||||
userId: string
|
||||
groupId: string
|
||||
role: 'admin' | 'member' | 'viewer'
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
isPublic: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
ownerId: string
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
userId: string
|
||||
displayName: string
|
||||
bio: string
|
||||
avatar: string
|
||||
joinedAt: string
|
||||
canvasCount: number
|
||||
followerCount: number
|
||||
followingCount: number
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string
|
||||
userId: string
|
||||
action: string
|
||||
targetId: string
|
||||
targetType: string
|
||||
timestamp: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
// In-memory stores
|
||||
export const users = new Map<string, { password: string } & AuthUser>()
|
||||
export const nodes = new Map<string, any>()
|
||||
export const achievements = new Map<string, any[]>()
|
||||
export const pushSubscriptions = new Map<string, any>()
|
||||
export const canvases = new Map<string, CanvasData>()
|
||||
export const userGroups = new Map<string, UserGroup>()
|
||||
export const groups = new Map<string, Group>()
|
||||
export const profiles = new Map<string, Profile>()
|
||||
export const activities = new Map<string, Activity>()
|
||||
export const follows = new Map<string, { followerId: string, followingId: string, since: string }>()
|
||||
|
||||
// Seed admins
|
||||
const seedAdmins = [
|
||||
{ id: '1', email: 'admin@xflow.local', username: 'admin' },
|
||||
{ id: '2', email: 'almalexyz@gmail.com', username: 'almalexyz' },
|
||||
{ id: '3', email: 'ormix16@gmail.com', username: 'ormix16' }
|
||||
]
|
||||
|
||||
for (const admin of seedAdmins) {
|
||||
users.set(admin.email, {
|
||||
...admin,
|
||||
password: 'admin123',
|
||||
role: 'admin'
|
||||
})
|
||||
|
||||
// Create profiles for admins
|
||||
profiles.set(admin.id, {
|
||||
userId: admin.id,
|
||||
displayName: admin.username,
|
||||
bio: 'XFlow Administrator',
|
||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${admin.id}`,
|
||||
joinedAt: new Date().toISOString(),
|
||||
canvasCount: 0,
|
||||
followerCount: 0,
|
||||
followingCount: 0
|
||||
})
|
||||
}
|
||||
|
||||
// Create demo groups
|
||||
const demoGroups: Group[] = [
|
||||
{ id: 'group-1', name: 'XFlow Team', description: 'Core development team', isPublic: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ownerId: '1' },
|
||||
{ id: 'group-2', name: 'Beta Testers', description: 'Early access users', isPublic: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ownerId: '1' },
|
||||
{ id: 'group-3', name: 'AI Research', description: 'AI/LLM integration research', isPublic: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ownerId: '2' }
|
||||
]
|
||||
|
||||
demoGroups.forEach(group => {
|
||||
groups.set(group.id, group)
|
||||
|
||||
// Add admins to groups
|
||||
seedAdmins.forEach(admin => {
|
||||
userGroups.set(`${admin.id}-${group.id}`, {
|
||||
userId: admin.id,
|
||||
groupId: group.id,
|
||||
role: admin.id === '1' ? 'admin' : 'member',
|
||||
joinedAt: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Seed comprehensive XFlow project mind map
|
||||
const xflowProjectCanvas: CanvasData = {
|
||||
id: 'xflow-project',
|
||||
name: 'Обзор Проекта XFlow',
|
||||
nodes: [
|
||||
// Core Architecture
|
||||
{
|
||||
id: 'arch-1',
|
||||
type: 'text',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 320,
|
||||
height: 180,
|
||||
text: '# 🏗️ Основная Архитектура\n\n- **Nuxt 3** - Vue 3 фреймворк\n- **TailwindCSS** - Стилизация\n- **Vue Flow** - Движок канваса\n- **Firebase** - Аутентификация\n- **In-memory DB** - Хранение данных\n- **PWA** - Progressive Web App',
|
||||
style: { background: '#1e293b', border: '#3b82f6' }
|
||||
},
|
||||
// Frontend Components
|
||||
{
|
||||
id: 'fe-1',
|
||||
type: 'text',
|
||||
x: 500,
|
||||
y: 50,
|
||||
width: 280,
|
||||
height: 160,
|
||||
text: '# 🎨 Фронтенд\n\n- **FlowEditor.vue** - Основной редактор\n- **FlowNodeRenderer.vue** - Отображение узлов\n- **CanvasViewer.vue** - Устаревший просмотр\n- **Админ Панель** - Управление интерфейсом\n- **Система Аутентификации** - Вход через Google',
|
||||
style: { background: '#1e293b', border: '#10b981' }
|
||||
},
|
||||
// Backend APIs
|
||||
{
|
||||
id: 'be-1',
|
||||
type: 'text',
|
||||
x: 500,
|
||||
y: 250,
|
||||
width: 280,
|
||||
height: 160,
|
||||
text: '# ⚙️ Backend API\n\n- `/api/canvas/*` - CRUD операций с канвасами\n- `/api/nodes/*` - Управление узлами\n- `/api/auth/*` - Аутентификация\n- `/api/admin/*` - Административные операции\n- `/api/push/*` - Уведомления',
|
||||
style: { background: '#1e293b', border: '#f59e0b' }
|
||||
},
|
||||
// Node Types
|
||||
{
|
||||
id: 'nodes-1',
|
||||
type: 'text',
|
||||
x: 100,
|
||||
y: 320,
|
||||
width: 320,
|
||||
height: 200,
|
||||
text: '# 📦 Типы Узлов\n\n- **Текстовая Заметка** - Markdown контент\n- **Ссылка на Файл** - Ссылки на файлы\n- **Узел Задачи** - Отслеживание состояния\n- **AI/LLM Узел** - AI обработка\n- **Webhook Узел** - HTTP триггеры\n- **Git Sync Узел** - Контроль версий',
|
||||
style: { background: '#1e293b', border: '#8b5cf6' }
|
||||
},
|
||||
// Features
|
||||
{
|
||||
id: 'feat-1',
|
||||
type: 'text',
|
||||
x: 850,
|
||||
y: 150,
|
||||
width: 300,
|
||||
height: 220,
|
||||
text: '# ✨ Ключевые Функции\n\n- **Редактор в стиле Node-RED**\n - Палитра сбоку\n - Панель свойств\n - Drag & drop\n - Соединения проводами\n\n- **Совместимость с Obsidian**\n - Экспорт в .canvas формат\n - Импорт существующих карт\n - Поддержка Markdown\n\n- **Социальные Функции**\n - Группы пользователей\n - Общие канвасы\n - Лента активности',
|
||||
style: { background: '#1e293b', border: '#ef4444' }
|
||||
},
|
||||
// Tech Stack
|
||||
{
|
||||
id: 'tech-1',
|
||||
type: 'text',
|
||||
x: 100,
|
||||
y: 560,
|
||||
width: 320,
|
||||
height: 180,
|
||||
text: '# 🛠️ Технологический Стек\n\n```json\n{\n "frontend": "Vue 3 + Nuxt 3",\n "styling": "TailwindCSS",\n "canvas": "Vue Flow",\n "auth": "Firebase Google",\n "storage": "In-memory",\n "pwa": "@vite-pwa/nuxt"\n}\n```',
|
||||
style: { background: '#1e293b', border: '#06b6d4' }
|
||||
},
|
||||
// Future Plans
|
||||
{
|
||||
id: 'future-1',
|
||||
type: 'text',
|
||||
x: 500,
|
||||
y: 450,
|
||||
width: 280,
|
||||
height: 180,
|
||||
text: '# 🚀 Будущее Развитие\n\n- **Git Интеграция**\n - Commit/push/pull карт\n - История версий\n - Управление ветками\n\n- **AI Интеграция**\n - OpenClaw агенты\n - Автоматизация LLM узлов\n - Умные предложения\n\n- **Социальная Сеть**\n - Профили пользователей\n - Система подписок\n - Ленты активности',
|
||||
style: { background: '#1e293b', border: '#ec4899' }
|
||||
},
|
||||
// Database Schema
|
||||
{
|
||||
id: 'db-1',
|
||||
type: 'file',
|
||||
x: 850,
|
||||
y: 400,
|
||||
width: 300,
|
||||
height: 160,
|
||||
file: 'app/server/utils/db.ts',
|
||||
text: '## Схема Базы Данных\n\n- `users` - Пользовательские аккаунты\n- `canvases` - Ментальные карты\n- `nodes` - Отдельные узлы\n- `groups` - Группы пользователей\n- `profiles` - Профили пользователей\n- `activities` - Лента активности',
|
||||
style: { background: '#1e293b', border: '#84cc16' }
|
||||
},
|
||||
// Deployment
|
||||
{
|
||||
id: 'deploy-1',
|
||||
type: 'text',
|
||||
x: 850,
|
||||
y: 590,
|
||||
width: 300,
|
||||
height: 140,
|
||||
text: '# 🚀 Развертывание\n\n- **Docker Compose**\n- **Nginx** - Обратный прокси\n- **Gitea** - Git сервер\n- **PostgreSQL** - База данных\n- **Certbot** - SSL сертификаты',
|
||||
style: { background: '#1e293b', border: '#f97316' }
|
||||
},
|
||||
// Social Features
|
||||
{
|
||||
id: 'social-1',
|
||||
type: 'text',
|
||||
x: 100,
|
||||
y: 780,
|
||||
width: 320,
|
||||
height: 160,
|
||||
text: '# 👥 Social Network\n\n- **User Profiles**\n - Avatar & bio\n - Canvas statistics\n - Following/followers\n\n- **Groups System**\n - Public/private groups\n - Role-based permissions\n - Shared canvases',
|
||||
style: { background: '#1e293b', border: '#a855f7' }
|
||||
},
|
||||
// File Structure
|
||||
{
|
||||
id: 'files-1',
|
||||
type: 'file',
|
||||
x: 500,
|
||||
y: 660,
|
||||
width: 280,
|
||||
height: 140,
|
||||
file: 'project-structure.md',
|
||||
text: '## Project Structure\n\n```\napp/\n├── components/\n│ ├── FlowEditor.vue\n│ └── FlowNodeRenderer.vue\n├── pages/\n│ ├── canvas.vue\n│ └── admin.vue\n└── server/api/\n```',
|
||||
style: { background: '#1e293b', border: '#0ea5e9' }
|
||||
}
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', fromNode: 'arch-1', fromSide: 'right', toNode: 'fe-1', toSide: 'left' },
|
||||
{ id: 'e2', fromNode: 'arch-1', fromSide: 'right', toNode: 'be-1', toSide: 'left' },
|
||||
{ id: 'e3', fromNode: 'arch-1', fromSide: 'bottom', toNode: 'nodes-1', toSide: 'top' },
|
||||
{ id: 'e4', fromNode: 'fe-1', fromSide: 'right', toNode: 'feat-1', toSide: 'left' },
|
||||
{ id: 'e5', fromNode: 'be-1', fromSide: 'right', toNode: 'feat-1', toSide: 'left' },
|
||||
{ id: 'e6', fromNode: 'nodes-1', fromSide: 'bottom', toNode: 'tech-1', toSide: 'top' },
|
||||
{ id: 'e7', fromNode: 'be-1', fromSide: 'bottom', toNode: 'future-1', toSide: 'top' },
|
||||
{ id: 'e8', fromNode: 'feat-1', fromSide: 'bottom', toNode: 'db-1', toSide: 'top' },
|
||||
{ id: 'e9', fromNode: 'db-1', fromSide: 'bottom', toNode: 'deploy-1', toSide: 'top' },
|
||||
{ id: 'e10', fromNode: 'tech-1', fromSide: 'right', toNode: 'future-1', toSide: 'left' },
|
||||
{ id: 'e11', fromNode: 'tech-1', fromSide: 'bottom', toNode: 'social-1', toSide: 'top' },
|
||||
{ id: 'e12', fromNode: 'future-1', fromSide: 'bottom', toNode: 'files-1', toSide: 'top' },
|
||||
{ id: 'e13', fromNode: 'social-1', fromSide: 'right', toNode: 'files-1', toSide: 'left' }
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
ownerId: '1',
|
||||
isPublic: true
|
||||
}
|
||||
|
||||
canvases.set('xflow-project', xflowProjectCanvas)
|
||||
|
||||
// Create shared canvases for groups
|
||||
const sharedCanvases = [
|
||||
{ id: 'team-roadmap', name: 'Team Roadmap 2025', groupId: 'group-1', ownerId: '1' },
|
||||
{ id: 'beta-feedback', name: 'Beta Feedback Map', groupId: 'group-2', ownerId: '1' },
|
||||
{ id: 'ai-research', name: 'AI Integration Ideas', groupId: 'group-3', ownerId: '2' }
|
||||
]
|
||||
|
||||
sharedCanvases.forEach(canvas => {
|
||||
const canvasData: CanvasData = {
|
||||
id: canvas.id,
|
||||
name: canvas.name,
|
||||
nodes: [
|
||||
{
|
||||
id: `${canvas.id}-node-1`,
|
||||
type: 'text',
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 150,
|
||||
text: `# ${canvas.name}\n\nThis is a shared canvas for the group.\n\nStart adding your ideas!`,
|
||||
style: { background: '#1e293b', border: '#3b82f6' }
|
||||
}
|
||||
],
|
||||
edges: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
ownerId: canvas.ownerId,
|
||||
groupId: canvas.groupId,
|
||||
isPublic: true
|
||||
}
|
||||
canvases.set(canvas.id, canvasData)
|
||||
})
|
||||
|
||||
// Add demo activities
|
||||
const demoActivities: Activity[] = [
|
||||
{ id: 'activity-1', userId: '1', action: 'created_canvas', targetId: 'xflow-project', targetType: 'canvas', timestamp: new Date().toISOString() },
|
||||
{ id: 'activity-2', userId: '2', action: 'joined_group', targetId: 'group-1', targetType: 'group', timestamp: new Date().toISOString() },
|
||||
{ id: 'activity-3', userId: '3', action: 'created_canvas', targetId: 'ai-research', targetType: 'canvas', timestamp: new Date().toISOString() },
|
||||
{ id: 'activity-4', userId: '1', action: 'followed_user', targetId: '2', targetType: 'user', timestamp: new Date().toISOString() }
|
||||
]
|
||||
|
||||
demoActivities.forEach(activity => {
|
||||
activities.set(activity.id, activity)
|
||||
})
|
||||
|
||||
// Add demo follows
|
||||
follows.set('1-2', { followerId: '1', followingId: '2', since: new Date().toISOString() })
|
||||
follows.set('2-3', { followerId: '2', followingId: '3', since: new Date().toISOString() })
|
||||
|
||||
// Keep the old demo canvas for backward compatibility
|
||||
canvases.set('demo', {
|
||||
id: 'demo',
|
||||
name: 'Demo Canvas',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'text',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 200,
|
||||
text: '# Welcome\n\nЭто демо-нода с **Markdown** поддержкой.',
|
||||
state: 'pending',
|
||||
style: { background: '#1e293b', border: '#334155' }
|
||||
},
|
||||
{
|
||||
id: 'node-2',
|
||||
type: 'file',
|
||||
x: 400,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 200,
|
||||
file: 'tasks.md',
|
||||
frontmatter: { alarm: '2025-04-24T09:00:00Z', script_id: 'notify', assigned_to: 'user' },
|
||||
state: 'pending',
|
||||
style: { background: '#1e293b', border: '#334155' }
|
||||
},
|
||||
{
|
||||
id: 'node-3',
|
||||
type: 'text',
|
||||
x: 200,
|
||||
y: 300,
|
||||
width: 300,
|
||||
height: 150,
|
||||
text: '### Завершенная задача\n- [x] Настроить PWA',
|
||||
state: 'completed',
|
||||
style: { background: '#064e3b', border: '#059669' }
|
||||
}
|
||||
],
|
||||
edges: [
|
||||
{ id: 'edge-1', fromNode: 'node-1', fromSide: 'right', toNode: 'node-2', toSide: 'left' },
|
||||
{ id: 'edge-2', fromNode: 'node-2', fromSide: 'bottom', toNode: 'node-3', toSide: 'top' }
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
ownerId: '1'
|
||||
})
|
||||
|
||||
export function getUserAchievements(userId: string) {
|
||||
return achievements.get(userId) || []
|
||||
}
|
||||
|
||||
export function addAchievement(userId: string, achievement: any) {
|
||||
const list = achievements.get(userId) || []
|
||||
list.push({ ...achievement, unlockedAt: new Date().toISOString() })
|
||||
achievements.set(userId, list)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Deprecated: replaced by giteaApi.ts
|
||||
export { }
|
||||
@@ -0,0 +1,160 @@
|
||||
// Gitea API integration for XFlow canvas sync
|
||||
// Structure:
|
||||
// Personal canvases → {username}/MM (personal user repo, created on first save)
|
||||
// Group canvases → AVTEX/group-{groupId} (created on group creation)
|
||||
// Public canvases → AVTEX/MMAVTEX (shared org repo)
|
||||
|
||||
interface GiteaFile {
|
||||
content: string // base64
|
||||
sha?: string
|
||||
}
|
||||
|
||||
const getConfig = () => {
|
||||
const config = useRuntimeConfig()
|
||||
return {
|
||||
url: config.giteaUrl,
|
||||
token: config.giteaToken,
|
||||
org: config.giteaOrg,
|
||||
mindmapRepo: config.giteaMindmapRepo,
|
||||
projectRepo: config.giteaProjectRepo,
|
||||
}
|
||||
}
|
||||
|
||||
const apiHeaders = (token: string) => ({
|
||||
'Authorization': `token ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
})
|
||||
|
||||
// Resolve which repo to use based on canvas ownership
|
||||
// Returns { owner, repo } to support both org and personal repos
|
||||
export function resolveRepo(opts: { username?: string; groupId?: string; isPublic?: boolean }): { owner: string; repo: string } {
|
||||
const { org, mindmapRepo } = getConfig()
|
||||
if (opts.groupId) return { owner: org, repo: `group-${opts.groupId}` }
|
||||
if (opts.username && !opts.isPublic) return { owner: opts.username, repo: 'MM' }
|
||||
return { owner: org, repo: mindmapRepo }
|
||||
}
|
||||
|
||||
// Ensure repository exists, create if not (supports personal and org repos)
|
||||
export async function ensureRepo(owner: string, repoName: string, description = ''): Promise<boolean> {
|
||||
const { url, token, org } = getConfig()
|
||||
const h = apiHeaders(token)
|
||||
|
||||
// Check if exists
|
||||
try {
|
||||
await $fetch(`${url}/api/v1/repos/${owner}/${repoName}`, { headers: h })
|
||||
return true
|
||||
} catch { }
|
||||
|
||||
// Create — personal repo or org repo
|
||||
try {
|
||||
const isOrg = owner === org
|
||||
const endpoint = isOrg
|
||||
? `${url}/api/v1/orgs/${org}/repos`
|
||||
: `${url}/api/v1/user/repos`
|
||||
await $fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: h,
|
||||
body: {
|
||||
name: repoName,
|
||||
description: description || `XFlow canvas repo: ${repoName}`,
|
||||
private: true,
|
||||
auto_init: true,
|
||||
default_branch: 'main'
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
console.error(`Gitea ensureRepo(${owner}/${repoName}) error:`, e.data || e)
|
||||
throw new Error(`ensureRepo failed: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Get file from Gitea (returns null if not found)
|
||||
export async function giteaGetFile(path: string, owner?: string, repo?: string): Promise<GiteaFile | null> {
|
||||
const { url, token, org, mindmapRepo } = getConfig()
|
||||
const repoOwner = owner || org
|
||||
const repoName = repo || mindmapRepo
|
||||
try {
|
||||
const res = await $fetch<any>(`${url}/api/v1/repos/${repoOwner}/${repoName}/contents/${path}`, {
|
||||
headers: apiHeaders(token)
|
||||
})
|
||||
return { content: res.content, sha: res.sha }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update file in Gitea
|
||||
export async function giteaPutFile(path: string, content: string, message: string, sha?: string, owner?: string, repo?: string): Promise<boolean> {
|
||||
const { url, token, org, mindmapRepo } = getConfig()
|
||||
const repoOwner = owner || org
|
||||
const repoName = repo || mindmapRepo
|
||||
const encoded = Buffer.from(content).toString('base64')
|
||||
|
||||
try {
|
||||
await $fetch(`${url}/api/v1/repos/${repoOwner}/${repoName}/contents/${path}`, {
|
||||
method: sha ? 'PUT' : 'POST',
|
||||
headers: apiHeaders(token),
|
||||
body: {
|
||||
message,
|
||||
content: encoded,
|
||||
...(sha ? { sha } : {})
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
console.error('Gitea putFile error:', e.data || e)
|
||||
throw new Error(`giteaPutFile failed: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Save canvas to correct repo based on ownership
|
||||
export async function saveCanvasToGitea(
|
||||
canvasId: string,
|
||||
canvasData: any,
|
||||
opts: { username?: string; groupId?: string; isPublic?: boolean } = {},
|
||||
commitMessage?: string
|
||||
): Promise<boolean> {
|
||||
const { owner, repo } = resolveRepo(opts)
|
||||
const path = `${canvasId}.canvas`
|
||||
const content = JSON.stringify(canvasData, null, 2)
|
||||
const message = commitMessage || `Update ${canvasId} at ${new Date().toLocaleString('ru-RU')}`
|
||||
|
||||
await ensureRepo(owner, repo)
|
||||
const existing = await giteaGetFile(path, owner, repo)
|
||||
return giteaPutFile(path, content, message, existing?.sha, owner, repo)
|
||||
}
|
||||
|
||||
// Load canvas from Gitea
|
||||
export async function loadCanvasFromGitea(
|
||||
canvasId: string,
|
||||
opts: { username?: string; groupId?: string; isPublic?: boolean } = {}
|
||||
): Promise<any | null> {
|
||||
const { owner, repo } = resolveRepo(opts)
|
||||
const file = await giteaGetFile(`${canvasId}.canvas`, owner, repo)
|
||||
if (!file) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.from(file.content, 'base64').toString('utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// List canvases in a repo
|
||||
export async function listCanvasesFromGitea(owner?: string, repo?: string): Promise<string[]> {
|
||||
const { url, token, org, mindmapRepo } = getConfig()
|
||||
const repoOwner = owner || org
|
||||
const repoName = repo || mindmapRepo
|
||||
try {
|
||||
const res = await $fetch<any[]>(`${url}/api/v1/repos/${repoOwner}/${repoName}/contents/`, {
|
||||
headers: apiHeaders(token)
|
||||
})
|
||||
return res
|
||||
.filter((f: any) => f.name.endsWith('.canvas'))
|
||||
.map((f: any) => f.name.replace('.canvas', ''))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { Pool } from 'pg'
|
||||
import PrismaPkg from '@prisma/client'
|
||||
const PrismaClient = PrismaPkg.PrismaClient
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||
const adapter = new PrismaPg(pool)
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: InstanceType<typeof PrismaClient> }
|
||||
|
||||
export const prisma = globalForPrisma.prisma || new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
@@ -0,0 +1,264 @@
|
||||
// Real Git integration utilities for XFlow with Gitea
|
||||
// This handles actual Git operations, caching, and change tracking
|
||||
|
||||
import { execSync } from 'child_process'
|
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
export interface GitRepository {
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
url: string
|
||||
cloneUrl: string
|
||||
isPrivate: boolean
|
||||
type: 'personal' | 'group'
|
||||
ownerId: string
|
||||
localPath: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GitCommit {
|
||||
id: string
|
||||
message: string
|
||||
author: string
|
||||
timestamp: string
|
||||
canvasId: string
|
||||
changes: {
|
||||
added: string[]
|
||||
modified: string[]
|
||||
deleted: string[]
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for Git repositories
|
||||
const gitCache = new Map<string, GitRepository>()
|
||||
const canvasCache = new Map<string, any>()
|
||||
|
||||
// Base path for local Git repositories
|
||||
const GIT_BASE_PATH = process.env.GIT_BASE_PATH || '/tmp/xflow-git'
|
||||
|
||||
// Ensure base directory exists
|
||||
if (!existsSync(GIT_BASE_PATH)) {
|
||||
mkdirSync(GIT_BASE_PATH, { recursive: true })
|
||||
}
|
||||
|
||||
// Create a new Git repository for a user
|
||||
export async function createPersonalRepository(userId: string, canvasId: string): Promise<GitRepository> {
|
||||
const repoId = `repo-${userId}-${canvasId}-${Date.now()}`
|
||||
const repoName = `canvas-${canvasId}`
|
||||
const localPath = join(GIT_BASE_PATH, userId, repoName)
|
||||
|
||||
// Create local directory
|
||||
if (!existsSync(localPath)) {
|
||||
mkdirSync(localPath, { recursive: true })
|
||||
}
|
||||
|
||||
// Initialize Git repository
|
||||
try {
|
||||
execSync('git init', { cwd: localPath })
|
||||
execSync('git config user.name "XFlow System"', { cwd: localPath })
|
||||
execSync('git config user.email "xflow@system.local"', { cwd: localPath })
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Git repository:', error)
|
||||
}
|
||||
|
||||
const repository: GitRepository = {
|
||||
id: repoId,
|
||||
name: repoName,
|
||||
owner: userId,
|
||||
url: `https://gitea.example.com/${userId}/${repoName}`,
|
||||
cloneUrl: `https://gitea.example.com/${userId}/${repoName}.git`,
|
||||
isPrivate: true,
|
||||
type: 'personal',
|
||||
ownerId: userId,
|
||||
localPath,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
gitCache.set(repoId, repository)
|
||||
|
||||
// Initialize with initial commit
|
||||
await initializeRepository(repository, canvasId)
|
||||
|
||||
return repository
|
||||
}
|
||||
|
||||
// Initialize repository with initial canvas data
|
||||
async function initializeRepository(repository: GitRepository, canvasId: string): Promise<void> {
|
||||
const canvasFile = join(repository.localPath, `${canvasId}.canvas`)
|
||||
|
||||
// Create initial canvas file
|
||||
const initialCanvas = {
|
||||
nodes: [],
|
||||
edges: []
|
||||
}
|
||||
|
||||
writeFileSync(canvasFile, JSON.stringify(initialCanvas, null, 2))
|
||||
|
||||
// Create initial commit
|
||||
try {
|
||||
execSync('git add .', { cwd: repository.localPath })
|
||||
execSync(`git commit -m "Initial commit for canvas ${canvasId}"`, { cwd: repository.localPath })
|
||||
} catch (error) {
|
||||
console.error('Failed to create initial commit:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync canvas changes to Git repository
|
||||
export async function syncCanvasToGit(
|
||||
canvasId: string,
|
||||
canvasData: any,
|
||||
commitMessage?: string
|
||||
): Promise<GitCommit> {
|
||||
// Find repository for this canvas
|
||||
const repository = Array.from(gitCache.values())
|
||||
.find(repo => repo.name.includes(canvasId))
|
||||
|
||||
if (!repository) {
|
||||
throw new Error(`No Git repository found for canvas ${canvasId}`)
|
||||
}
|
||||
|
||||
const canvasFile = join(repository.localPath, `${canvasId}.canvas`)
|
||||
|
||||
// Check if file has changed
|
||||
const currentContent = JSON.stringify(canvasData, null, 2)
|
||||
let hasChanges = true
|
||||
|
||||
if (existsSync(canvasFile)) {
|
||||
const existingContent = readFileSync(canvasFile, 'utf-8')
|
||||
hasChanges = existingContent !== currentContent
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
// Return latest commit if no changes
|
||||
const commits = getRepositoryHistory(repository)
|
||||
return commits[0] || {
|
||||
id: 'no-changes',
|
||||
message: 'No changes detected',
|
||||
author: 'system',
|
||||
timestamp: new Date().toISOString(),
|
||||
canvasId,
|
||||
changes: { added: [], modified: [], deleted: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// Write canvas data
|
||||
writeFileSync(canvasFile, currentContent)
|
||||
|
||||
// Commit changes
|
||||
const message = commitMessage || `Update canvas ${canvasId}`
|
||||
|
||||
try {
|
||||
execSync('git add .', { cwd: repository.localPath })
|
||||
execSync(`git commit -m "${message}"`, { cwd: repository.localPath })
|
||||
} catch (error) {
|
||||
console.error('Failed to commit changes:', error)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
repository.updatedAt = new Date().toISOString()
|
||||
canvasCache.set(canvasId, canvasData)
|
||||
|
||||
// Get commit hash
|
||||
let commitHash = 'unknown'
|
||||
try {
|
||||
commitHash = execSync('git rev-parse HEAD', { cwd: repository.localPath, encoding: 'utf-8' }).trim()
|
||||
} catch (error) {
|
||||
console.error('Failed to get commit hash:', error)
|
||||
}
|
||||
|
||||
const commit: GitCommit = {
|
||||
id: commitHash,
|
||||
message,
|
||||
author: repository.owner,
|
||||
timestamp: new Date().toISOString(),
|
||||
canvasId,
|
||||
changes: {
|
||||
added: existsSync(canvasFile) ? [`${canvasId}.canvas`] : [],
|
||||
modified: [`${canvasId}.canvas`],
|
||||
deleted: []
|
||||
}
|
||||
}
|
||||
|
||||
return commit
|
||||
}
|
||||
|
||||
// Load canvas from Git repository
|
||||
export async function loadCanvasFromGit(canvasId: string): Promise<any> {
|
||||
// Check cache first
|
||||
if (canvasCache.has(canvasId)) {
|
||||
return canvasCache.get(canvasId)
|
||||
}
|
||||
|
||||
// Find repository for this canvas
|
||||
const repository = Array.from(gitCache.values())
|
||||
.find(repo => repo.name.includes(canvasId))
|
||||
|
||||
if (!repository) {
|
||||
throw new Error(`No Git repository found for canvas ${canvasId}`)
|
||||
}
|
||||
|
||||
const canvasFile = join(repository.localPath, `${canvasId}.canvas`)
|
||||
|
||||
if (!existsSync(canvasFile)) {
|
||||
throw new Error(`Canvas file not found: ${canvasFile}`)
|
||||
}
|
||||
|
||||
const canvasData = JSON.parse(readFileSync(canvasFile, 'utf-8'))
|
||||
|
||||
// Cache the data
|
||||
canvasCache.set(canvasId, canvasData)
|
||||
|
||||
return canvasData
|
||||
}
|
||||
|
||||
// Get repository for a canvas
|
||||
export function getCanvasRepository(canvasId: string): GitRepository | null {
|
||||
return Array.from(gitCache.values())
|
||||
.find(repo => repo.name.includes(canvasId)) || null
|
||||
}
|
||||
|
||||
// Get commit history for a repository
|
||||
export function getRepositoryHistory(repository: GitRepository): GitCommit[] {
|
||||
try {
|
||||
const log = execSync('git log --pretty=format:"%H|%s|%an|%ad" --date=iso', {
|
||||
cwd: repository.localPath,
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
|
||||
return log.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => {
|
||||
const [id, message, author, timestamp] = line.split('|')
|
||||
return {
|
||||
id,
|
||||
message,
|
||||
author,
|
||||
timestamp,
|
||||
canvasId: repository.name.replace('canvas-', ''),
|
||||
changes: { added: [], modified: [], deleted: [] }
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get repository history:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Check if canvas has unsaved changes
|
||||
export function hasUnsavedChanges(canvasId: string, currentData: any): boolean {
|
||||
if (!canvasCache.has(canvasId)) {
|
||||
return true // No cached data, assume changes
|
||||
}
|
||||
|
||||
const cachedData = canvasCache.get(canvasId)
|
||||
return JSON.stringify(cachedData) !== JSON.stringify(currentData)
|
||||
}
|
||||
|
||||
// Clear cache for a canvas
|
||||
export function clearCanvasCache(canvasId: string): void {
|
||||
canvasCache.delete(canvasId)
|
||||
}
|
||||
Reference in New Issue
Block a user