feat: Add Nanaxe server with Docker, GitHub Actions CI/CD, and auto-deployment

- Add complete server infrastructure with Gitea, API, PostgreSQL
- Implement Obsidian-compatible markdown mindmap converter
- Add Flutter service for server synchronization
- Configure GitHub Actions for automated build and deploy
- Add production docker-compose with health checks
- Include VPS setup and deployment scripts
- Add comprehensive deployment documentation
This commit is contained in:
2026-04-19 22:18:25 +05:00
parent 52aea9ac87
commit f1e0f6b5ef
56 changed files with 8892 additions and 397 deletions
@@ -0,0 +1,835 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
import 'package:uuid/uuid.dart';
import '../models/mindmap_model.dart';
import '../providers/mind_maps_provider.dart';
import '../core/admin_access_service.dart';
import '../core/deepseek_service.dart';
class MindMapInteractiveScreen extends ConsumerStatefulWidget {
final MindMapModel mindMap;
const MindMapInteractiveScreen({super.key, required this.mindMap});
@override
ConsumerState<MindMapInteractiveScreen> createState() =>
_MindMapInteractiveScreenState();
}
class _MindMapInteractiveScreenState
extends ConsumerState<MindMapInteractiveScreen> {
late List<Map<String, dynamic>> _nodes;
late bool _isMain;
bool _isEditing = false;
bool _showChat = false;
bool _isAISubmitting = false;
final TextEditingController _chatController = TextEditingController();
final List<ChatMessage> _chatMessages = [];
@override
void initState() {
super.initState();
_nodes = widget.mindMap.nodes.toList();
_isMain = widget.mindMap.isMain;
}
@override
void dispose() {
_chatController.dispose();
super.dispose();
}
// ========== CHAT FUNCTIONS ==========
void _addChatMessage(String text, bool isUser, {AIAction? action}) {
setState(() {
_chatMessages.add(ChatMessage(
text: text,
isUser: isUser,
timestamp: DateTime.now(),
action: action,
));
});
}
Future<void> _sendToAI(String message) async {
if (message.trim().isEmpty) return;
_addChatMessage(message, true);
_chatController.clear();
setState(() => _isAISubmitting = true);
try {
// Отправляем запрос к DeepSeek
final response = await deepSeekService.chatCompletion(
messages: [
..._chatMessages
.where((m) => m.isUser || m.text.startsWith('AI:'))
.map((m) => {
'role': m.isUser ? 'user' : 'assistant',
'content': m.text,
})
.toList(),
{
'role': 'system',
'content': '''
Ты AI ассистент для работы с mind maps.
Текущая структура mind map "${widget.mindMap.title}":
${_nodes.map((n) => '${" " * (n["level"] ?? 0)}- ${n["title"]} (уровень ${n["level"] ?? 0})').join('\n')}
Когда пользователь просит что-то сделать с mind map, отвечай в формате:
ACTION: <add|edit|delete|suggest>
TARGET: <id узла или null>
CHANGES: [
{"action": "add", "title": "...", "level": 0, "parentId": null},
{"action": "edit", "id": "...", "title": "..."},
{"action": "delete", "id": "..."}
]
EXPLANATION: <объяснение на русском>
Или просто отвечай текстом если действие не требуется.
''',
},
],
);
// Парсим ответ AI
if (response.containsKey('ACTION')) {
final action = response['ACTION'] as String;
final changes = response['CHANGES'] as List<dynamic>;
final explanation = response['EXPLANATION'] as String? ?? '';
_addChatMessage(
'AI: $explanation',
false,
action: AIAction(
type: action,
changes: changes.map((c) => c as Map<String, dynamic>).toList(),
isApplied: false,
),
);
} else {
_addChatMessage('AI: $response', false);
}
} catch (e) {
_addChatMessage('❌ Ошибка AI: $e', false);
} finally {
setState(() => _isAISubmitting = false);
}
}
void _applyAIChanges(AIAction action) {
for (final change in action.changes) {
final actionType = change['action'] as String;
switch (actionType) {
case 'add':
_nodes.add({
'id': const Uuid().v4(),
'title': change['title'] ?? '',
'level': change['level'] ?? 0,
'parentId': change['parentId'],
'color': _getColorForLevel(change['level'] ?? 0),
});
break;
case 'edit':
final index = _nodes.indexWhere((n) => n['id'] == change['id']);
if (index != -1) {
_nodes[index] = {
..._nodes[index],
'title': change['title'] ?? _nodes[index]['title'],
};
}
break;
case 'delete':
final nodesToDelete = <String>{change['id'] as String};
_collectChildren(change['id'] as String, nodesToDelete);
_nodes.removeWhere((n) => nodesToDelete.contains(n['id']));
break;
}
}
// Обновляем сообщение в чате
final msgIndex = _chatMessages.indexWhere((m) => m.action == action);
if (msgIndex != -1) {
setState(() {
_chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith(
action: action.copyWith(isApplied: true),
);
_nodes = _nodes.toList(); // Триггер rebuild
});
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('✅ Изменения AI применены'),
backgroundColor: Colors.green,
),
);
}
void _rejectAIChanges(AIAction action) {
final msgIndex = _chatMessages.indexWhere((m) => m.action == action);
if (msgIndex != -1) {
setState(() {
_chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith(
action: action.copyWith(isApplied: false),
);
});
}
}
// ========== EDIT FUNCTIONS ==========
void _addNode({String? parentId}) {
showDialog(
context: context,
builder: (context) => _NodeTitleDialog(
onAdd: (title) {
final parentNode = parentId != null
? _nodes.firstWhere((n) => n['id'] == parentId, orElse: () => {})
: null;
setState(() {
_nodes.add({
'id': const Uuid().v4(),
'title': title,
'level': parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0,
'parentId': parentId,
'color': _getColorForLevel(
parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0),
});
});
},
),
);
}
void _editNode(String nodeId) {
final node = _nodes.firstWhere((n) => n['id'] == nodeId);
final controller = TextEditingController(text: node['title'] as String);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Редактировать узел'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Название',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
final index = _nodes.indexWhere((n) => n['id'] == nodeId);
if (index != -1) {
setState(() {
_nodes[index] = {..._nodes[index], 'title': controller.text};
});
}
Navigator.pop(context);
},
child: const Text('Сохранить'),
),
],
),
);
}
void _deleteNode(String nodeId) {
final nodesToDelete = <String>{nodeId};
_collectChildren(nodeId, nodesToDelete);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Удалить узел?'),
content: Text(
'Удалить "${_nodes.firstWhere((n) => n['id'] == nodeId)['title']}" и все дочерние узлы?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
TextButton(
onPressed: () {
setState(() {
_nodes.removeWhere((n) => nodesToDelete.contains(n['id']));
});
Navigator.pop(context);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Удалить'),
),
],
),
);
}
void _collectChildren(String parentId, Set<String> result) {
final children = _nodes.where((n) => n['parentId'] == parentId).toList();
for (final child in children) {
final childId = child['id'] as String;
result.add(childId);
_collectChildren(childId, result);
}
}
Future<void> _saveChanges() async {
try {
final notifier = ref.read(mindMapsNotifierProvider.notifier);
final isAdmin = ref.read(isAdminProvider);
final userEmail = FirebaseAuth.instance.currentUser?.email ?? '';
// Проверка прав
if (!isAdmin && widget.mindMap.ownerEmail != userEmail) {
throw Exception('Нет прав для редактирования');
}
final updated = widget.mindMap.copyWith(
nodes: _nodes,
isMain: _isMain,
updatedAt: DateTime.now(),
);
await notifier.updateMindMap(widget.mindMap.id, updated);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('✅ Изменения сохранены'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red),
);
}
}
}
String _getColorForLevel(int level) {
final colors = [
'#2196F3',
'#4CAF50',
'#FF9800',
'#E91E63',
'#9C27B0',
'#00BCD4'
];
return colors[level % colors.length];
}
Color _hexToColor(String hex) {
hex = hex.replaceAll('#', '');
if (hex.length == 6) return Color(int.parse('FF$hex', radix: 16));
return const Color(0xFF2196F3);
}
// ========== BUILD ==========
@override
Widget build(BuildContext context) {
final isAdmin = ref.watch(isAdminProvider);
final userEmail = FirebaseAuth.instance.currentUser?.email ?? '';
final canEdit = isAdmin || widget.mindMap.ownerEmail == userEmail;
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.mindMap.title),
Row(
children: [
Icon(
_isMain ? Icons.star : Icons.person,
size: 14,
color: _isMain ? Colors.amber : Colors.blue,
),
const SizedBox(width: 4),
Text(
_isMain ? 'Основная' : 'Личная',
style: TextStyle(
fontSize: 12,
color: _isMain ? Colors.amber : Colors.blue,
),
),
if (canEdit && _isEditing)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'✏️ Редактирование',
style: const TextStyle(
fontSize: 12,
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
actions: [
// Toggle edit mode
if (canEdit)
IconButton(
icon: Icon(_isEditing ? Icons.visibility : Icons.edit),
onPressed: () => setState(() => _isEditing = !_isEditing),
tooltip: _isEditing ? 'Режим просмотра' : 'Режим редактирования',
),
// AI Chat
IconButton(
icon: Icon(_showChat ? Icons.close : Icons.auto_awesome),
onPressed: () => setState(() => _showChat = !_showChat),
tooltip: _showChat ? 'Закрыть AI чат' : 'Открыть AI чат',
),
// Save
if (_isEditing)
IconButton(
icon: const Icon(Icons.save),
onPressed: _saveChanges,
tooltip: 'Сохранить изменения',
),
],
),
body: Stack(
children: [
// Mind Map (фон)
_nodes.isEmpty
? _buildEmptyState()
: rm.MindMapWidget(
data: _buildMindMapData(),
style: rm.MindMapStyle(
layout: rm.MindMapLayout.radial,
nodeShape: rm.NodeShape.roundedRectangle,
connectionColor: Colors.blue.shade100,
connectionWidth: 2.0,
),
cameraFocus: rm.CameraFocus.fitAll,
onNodeTap: _isEditing ? (node) => _editNode(node.id) : null,
onNodeLongPress:
_isEditing ? (node) => _deleteNode(node.id) : null,
),
// Чат поверх карты
if (_showChat) _buildChatOverlay(),
],
),
// FAB для добавления узлов (в режиме редактирования)
floatingActionButton: _isEditing && !_showChat
? FloatingActionButton(
onPressed: () => _addNode(),
child: const Icon(Icons.add),
tooltip: 'Добавить корневой узел',
)
: null,
);
}
Widget _buildChatOverlay() {
return Positioned(
left: 0,
right: 0,
bottom: 0,
height: MediaQuery.of(context).size.height * 0.45,
child: Column(
children: [
// Градиентная полоска сверху
Container(
height: 4,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Colors.purple, Colors.deepPurple],
),
),
),
// Панель чата
Expanded(
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(
children: [
// Header
Container(
width: double.infinity,
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 16),
color: Colors.purple.shade800,
child: Row(
children: [
const Icon(Icons.auto_awesome,
color: Colors.white, size: 18),
const SizedBox(width: 8),
const Text(
'AI Ассистент',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const Spacer(),
Text(
'${_chatMessages.length}',
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 12,
),
),
],
),
),
// Messages
Expanded(
child: _chatMessages.isEmpty
? _buildChatEmptyState()
: ListView.builder(
padding: const EdgeInsets.all(6),
itemCount: _chatMessages.length,
itemBuilder: (context, index) {
return _buildChatMessage(_chatMessages[index]);
},
),
),
// Input
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: Theme.of(context).cardColor,
child: SafeArea(
top: false,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 40,
),
child: TextField(
controller: _chatController,
maxLines: null,
minLines: 1,
decoration: InputDecoration(
hintText: 'AI помощь...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 2),
isDense: true,
),
onSubmitted: (text) => _sendToAI(text),
),
),
),
const SizedBox(width: 4),
SizedBox(
width: 34,
height: 34,
child: Material(
color: Colors.purple,
shape: const CircleBorder(),
child: InkWell(
onTap: _isAISubmitting
? null
: () => _sendToAI(_chatController.text),
customBorder: const CircleBorder(),
child: _isAISubmitting
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.send,
color: Colors.white, size: 12),
),
),
),
],
),
),
),
],
),
),
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.account_tree, size: 100, color: Colors.grey[400]),
const SizedBox(height: 24),
Text(
'Mind map пуста',
style: TextStyle(fontSize: 20, color: Colors.grey[600]),
),
if (_isEditing) ...[
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _addNode(),
icon: const Icon(Icons.add),
label: const Text('Добавить первый узел'),
),
],
],
),
);
}
Widget _buildChatEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome, size: 40, color: Colors.purple[300]),
const SizedBox(height: 8),
Text(
'AI Ассистент',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
const SizedBox(height: 4),
Text(
'Напишите "добавь узлы" или\n"предложи улучшения"',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
),
),
);
}
Widget _buildChatMessage(ChatMessage msg) {
return Align(
alignment: msg.isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: msg.isUser ? Colors.blue : Colors.grey[200],
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
msg.isUser ? msg.text : msg.text.replaceFirst('AI: ', ''),
style: TextStyle(
color: msg.isUser ? Colors.white : Colors.black87,
fontSize: 13,
),
),
if (msg.action != null) ...[
const SizedBox(height: 6),
_buildAIActionButtons(msg.action!),
],
],
),
),
);
}
Widget _buildAIActionButtons(AIAction action) {
if (action.isApplied) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green, size: 16),
SizedBox(width: 4),
Text('Применено', style: TextStyle(color: Colors.green)),
],
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon(
onPressed: () => _applyAIChanges(action),
icon: const Icon(Icons.check, size: 18),
label: const Text('Применить'),
style: TextButton.styleFrom(
foregroundColor: Colors.green,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => _rejectAIChanges(action),
icon: const Icon(Icons.close, size: 18),
label: const Text('Отклонить'),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
),
),
],
);
}
rm.MindMapData _buildMindMapData() {
final rootNodes = _nodes.where((n) => n['parentId'] == null).toList();
if (rootNodes.isEmpty) {
return rm.MindMapData(
id: 'empty', title: widget.mindMap.title, children: []);
}
final root = rootNodes.first;
return rm.MindMapData(
id: root['id'] as String,
title: root['title'] as String,
color: _hexToColor(root['color'] as String? ?? '#2196F3'),
children: _buildChildren(root['id'] as String),
);
}
List<rm.MindMapData> _buildChildren(String parentId) {
final children = _nodes.where((n) => n['parentId'] == parentId).toList();
return children.map((node) {
return rm.MindMapData(
id: node['id'] as String,
title: node['title'] as String,
color: _hexToColor(node['color'] as String? ?? '#4CAF50'),
children: _buildChildren(node['id'] as String),
);
}).toList();
}
}
// ========== MODELS ==========
class ChatMessage {
final String text;
final bool isUser;
final DateTime timestamp;
final AIAction? action;
ChatMessage({
required this.text,
required this.isUser,
required this.timestamp,
this.action,
});
ChatMessage copyWith({String? text, bool? isUser, AIAction? action}) {
return ChatMessage(
text: text ?? this.text,
isUser: isUser ?? this.isUser,
timestamp: timestamp,
action: action ?? this.action,
);
}
}
class AIAction {
final String type;
final List<Map<String, dynamic>> changes;
final bool isApplied;
AIAction({
required this.type,
required this.changes,
this.isApplied = false,
});
AIAction copyWith({bool? isApplied}) {
return AIAction(
type: type,
changes: changes,
isApplied: isApplied ?? this.isApplied,
);
}
}
class _NodeTitleDialog extends StatefulWidget {
final Function(String) onAdd;
const _NodeTitleDialog({required this.onAdd});
@override
State<_NodeTitleDialog> createState() => _NodeTitleDialogState();
}
class _NodeTitleDialogState extends State<_NodeTitleDialog> {
final _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Название узла'),
content: TextField(
controller: _controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Введите название',
border: OutlineInputBorder(),
),
onSubmitted: (text) {
if (text.isNotEmpty) widget.onAdd(text);
Navigator.pop(context);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
if (_controller.text.isNotEmpty) {
widget.onAdd(_controller.text);
}
Navigator.pop(context);
},
child: const Text('Добавить'),
),
],
);
}
}