Compare commits

..

5 Commits

Author SHA1 Message Date
siavash 8b1345b013 Update README.md
change hamhoosh to Hamhoosh
2026-06-29 12:18:38 +00:00
aliakbar bae7563adf Redesign Project Design 2026-06-29 15:43:17 +03:30
aliakbar 67e7fdf04e New files 2026-06-25 21:26:53 +03:30
aliakbar 56a5d2c191 change .gitignore 2026-06-25 21:25:24 +03:30
aliakbar 5c34cef2c3 First commit 2026-06-25 21:21:30 +03:30
489 changed files with 30444 additions and 1 deletions
+6
View File
@@ -174,3 +174,9 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# Custom added
*.db
chromadb/
model/
models/
.gguf
+1 -1
View File
@@ -1,2 +1,2 @@
# hamhoosh # Hamhoosh
+48
View File
@@ -0,0 +1,48 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Never commit tokens!
config.json
+45
View File
@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "fcf2c11572af6f390246c056bc905eca609533a0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: android
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: ios
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: linux
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: macos
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: web
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
- platform: windows
create_revision: fcf2c11572af6f390246c056bc905eca609533a0
base_revision: fcf2c11572af6f390246c056bc905eca609533a0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+3
View File
@@ -0,0 +1,3 @@
{
"cmake.sourceDirectory": "/media/wikmgg/A49CB5969CB56408/Distributed-AI/Mobile/linux"
}
+5
View File
@@ -0,0 +1,5 @@
psql "postgresql://postgres:Mey%23Birth%40miN2004@localhost:5432"
uvicorn app.main:app --reload --host 0.0.0.0 --port 8313
livekit-server --dev --bind 0.0.0.0
+22
View File
@@ -0,0 +1,22 @@
# برای اجرای پروژه با Socket.IO:
## Backend
```bash
cd backend
pip install -r requirements.txt
# اجرا با asgi_app
uvicorn app.main:asgi_app --host 0.0.0.0 --port 8000 --reload
```
## Flutter
```bash
flutter pub get
flutter run
```
## مهم:
- باید با `app.main:asgi_app` اجرا کنید نه `app.main:app`
- این باعث می‌شود Socket.IO درست کار کند
+153
View File
@@ -0,0 +1,153 @@
# 🔧 RAG Desktop FFI Isolate Fix - خلاصه تغییرات
## 🎯 مشکل اصلی
```
error: Cannot invoke native callback from a different isolate
```
**علت**: LlamaCpp از FFI استفاده میکنه که نمیتونه از isolate‌های مختلف صدا زده بشه.
## ✅ تغییرات انجام شده
### 1. **embedding_service.dart** - حذف Isolate برای Desktop
```dart
// خط 82-84: غیرفعال کردن Isolate برای LlamaCpp
if (AppConfig.aiBackend == AIBackendType.llamaCpp && PlatformHelper.isDesktop) {
_useIsolate = false; // ✅ اضافه شد
}
```
**تغییر در `generateEmbeddings`:**
- ✅ حذف کامل `Future.wait()` و parallelization
- ✅ پردازش SEQUENTIAL یکی یکی
- ✅ اضافه شدن لاگ‌های debug با emoji
**تغییر در `generateEmbedding`:**
- ✅ اضافه شدن لاگ‌های مفصل برای trace کردن
### 2. **llama_cpp_embedding_backend_io.dart** - لاگ‌های مفصل FFI
```dart
// قبل از FFI call
Log.i('🔧 Calling _modelInstance.getEmbeddings()...', 'LlamaCppEmbedding');
// بعد از FFI call
Log.i('✅ FFI call complete, got embedding (dim: ${embedding.length})', 'LlamaCppEmbedding');
```
### 3. **hierarchical_rag_manager.dart** - حذف Future.wait
**PDF Import (خط 510-541):**
```dart
// قبل: await Future.wait(indexFutures);
// بعد: SEQUENTIAL indexing
for (int i = 0; i < allSecondWindows.length; i++) {
await _invertedIndex!.indexChunk(...); // یکی یکی
if ((i + 1) % 5 == 0) {
await Future.delayed(Duration.zero); // yield to UI
}
}
```
**Text Import (خط 834-865):**
- ✅ همین تغییر برای text files
**Search Method (خط 922-955):**
- ✅ اضافه شدن لاگ‌های مفصل با separator
**loadEmbeddingModel (خط 126-139):**
```dart
// Auto-detect: برای LlamaCpp همیشه useIsolate: false
final shouldUseIsolate = useIsolate ??
(AppConfig.aiBackend == AIBackendType.llamaCpp ? false : true);
```
### 4. **hierarchical_retrieval_service.dart** - لاگ‌های مفصل
**تمام Stage‌ها:**
```dart
// Stage 1, 2, 3:
Log.i('🔍 [Stage X] ...', 'HierarchicalRetrieval');
Log.i('📊 Generating query embedding...', 'HierarchicalRetrieval');
Log.i('✅ Query embedding ready', 'HierarchicalRetrieval');
```
**retrieve method:**
```dart
Log.i('═══════════════════════════════════════════════', 'HierarchicalRetrieval');
Log.i('🚀 Starting 3-Stage Hierarchical Retrieval', 'HierarchicalRetrieval');
```
## 🔍 مسیر کامل لاگ‌ها (برای Debug)
وقتی یک query در `public_lab_chat_screen` با RAG فعال میزنید، باید این لاگ‌ها رو ببینید:
```
1. [HierarchicalRAG]
═══════════════════════════════════════════════
🔎 [HierarchicalRAG] Starting Hybrid Search
═══════════════════════════════════════════════
📝 Query: "your query"
🎯 Max results: 5
2. [HierarchicalRetrieval]
═══════════════════════════════════════════════
🚀 Starting 3-Stage Hierarchical Retrieval
═══════════════════════════════════════════════
3. [HierarchicalRetrieval] 🔍 [Stage 1] Document-level search (topK=3)
[HierarchicalRetrieval] 📊 Generating query embedding...
4. [EmbeddingService] 🔍 [generateEmbedding] Starting for text (X chars)
[EmbeddingService] 🔧 Calling backend.generateEmbedding...
5. [LlamaCppEmbedding] 🔍 [LlamaCppBackend] generateEmbedding called (text: X chars)
[LlamaCppEmbedding] ⏳ Yielding to event loop before FFI call...
[LlamaCppEmbedding] 🔧 Calling _modelInstance.getEmbeddings()...
[LlamaCppEmbedding] ✅ FFI call complete, got embedding (dim: 1024)
[LlamaCppEmbedding] ⏳ Yielding to event loop after FFI call...
6. [EmbeddingService] ✅ Generated embedding (dim: 1024)
[HierarchicalRetrieval] ✅ Query embedding generated (dim: 1024)
7. [HierarchicalRetrieval] 🔍 [Stage 2] First Window search...
8. [HierarchicalRetrieval] 🔍 [Stage 3] Second Window search...
9. [HierarchicalRAG] ✅ Hybrid search complete: X results
```
## ❌ اگر ارور بدید، چک کنید:
1. **"Backend not initialized"**
→ embedding model لود نشده
→ چک کنید: `AppConfig.llamaCppEmbeddingModelPath`
2. **"Model not ready"**
`_isReady` false است
→ چک کنید: آیا `autoLoadEmbeddingModel()` اجرا شده؟
3. **"Cannot invoke native callback from different isolate"**
→ هنوز جایی `_useIsolate=true` هست
→ چک کنید: لاگ "Isolate mode disabled" رو ببینید
## 🚀 نتیجه
- ✅ هیچ isolate spawning برای Desktop
- ✅ هیچ Future.wait() parallelization
- ✅ همه چیز SEQUENTIAL
- ✅ لاگ‌های کامل برای debug
- ✅ Yield to event loop برای responsive UI
## 📝 تست
برای تست کردن:
1. برنامه رو اجرا کنید
2. به `public_lab_chat_screen` برید
3. RAG و Bot رو فعال کنید
4. یک پیام بفرستید
5. لاگ‌ها رو چک کنید
اگر تمام لاگ‌های بالا رو دیدید و ارور نداد → ✅ موفق!
اگر ارور داد → لاگ آخر قبل از ارور رو پیدا کنید و بررسی کنید
+220
View File
@@ -0,0 +1,220 @@
# 🔧 RAG Desktop FFI Isolate Fix - نسخه نهایی
## 🎯 ریشه مشکل (Root Cause)
### مشکل اصلی کشف شد! ✅
**مشاهده کاربر:**
- ✅ Import از `lab_backpack_screen` → موفق
- ❌ Search از `public_lab_chat_screen` → ناموفق
**تحلیل Stack Trace:**
```
[Unoptimized] EditableTextState._finalizeEditing
[Unoptimized] TextInput._handleTextInputInvocation
```
**ریشه مشکل:**
```
┌─────────────────────────────────────────────┐
│ Main Isolate │
│ └─ App Startup │
│ └─ LlamaCpp Model Initialized ✅ │
│ (FFI bindings created HERE) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ TextField Event Isolate (DIFFERENT!) │
│ └─ User presses Enter │
│ └─ _sendMessage() called │
│ └─ hierarchicalRag.search() ❌ │
│ └─ generateEmbedding() ❌ │
│ └─ FFI call FAILS! │
│ "Cannot invoke native │
│ callback from different │
│ isolate" │
└─────────────────────────────────────────────┘
```
**چرا Import موفق بود؟**
- Import از Button → همان isolate اصلی → FFI موفق ✅
**چرا Search ناموفق بود؟**
- Search از TextField onSubmitted → isolate متفاوت → FFI ناموفق ❌
---
## ✅ راه‌حل نهایی (Final Solution)
### تغییر در `public_lab_chat_screen.dart` (خطوط 232-245)
```dart
// CRITICAL FIX: Force execution on MAIN isolate
List<HybridSearchResult> ragResults = [];
await Future(() async {
// این Future تضمین می‌کنه که کد در main event loop اجرا بشه
// همون جایی که LlamaCpp model initialize شده
ragResults = await hierarchicalRag.search(
query: messageToSend,
maxResults: 5,
useRRF: true,
allowedSources: labSources.toSet(),
);
});
```
### چرا این کار می‌کنه؟
`Future(() async { ... })` یک microtask در main event loop ایجاد می‌کنه و اجرای کد رو به همون isolate که app شروع شده برمی‌گردونه.
---
## 🧪 تست کردن
### مراحل تست:
1. **Clean Build:**
```bash
flutter clean
flutter pub get
```
2. **Run Application:**
```bash
flutter run -d linux # or your desktop platform
```
3. **بررسی Logs در Startup:**
```
[EmbeddingService] Using LlamaCpp embedding model from config: /path/to/model.gguf
[EmbeddingService] Isolate mode disabled for LlamaCpp FFI backend
[LlamaCppEmbedding] LlamaCpp embedding model loaded successfully (sync mode)
```
4. **برو به `public_lab_chat_screen`**
- Bot toggle را روشن کن
- RAG toggle را روشن کن
5. **یک پیام بفرست و Logs را بررسی کن:**
### ✅ Logs موفق (اگه کار کنه):
```
[PublicLabChatScreen] 🚀 Starting RAG search with query: "your query"
[HierarchicalRAG] 🔎 Starting Hybrid Search
[HierarchicalRetrieval] 🚀 Starting 3-Stage Hierarchical Retrieval
[HierarchicalRetrieval] 🔍 [Stage 1] Document-level search
[EmbeddingService] 🔍 [generateEmbedding] Starting for text (X chars)
[EmbeddingService] 🔧 Calling backend.generateEmbedding (NO YIELD)...
[LlamaCppEmbedding] 🔍 [LlamaCppBackend] generateEmbedding async wrapper called
[LlamaCppEmbedding] 🔧 [SYNC] Calling FFI getEmbeddings()...
[LlamaCppEmbedding] ✅ [SYNC] FFI call success (dim: 1024)
[EmbeddingService] ✅ Generated embedding (dim: 1024)
[HierarchicalRetrieval] ✅ Query embedding generated
[HierarchicalRetrieval] 🔍 [Stage 2] First Window search
[HierarchicalRetrieval] 🔍 [Stage 3] Second Window search
[HierarchicalRAG] ✅ Hybrid search complete: X results
[PublicLabChatScreen] ✅ RAG search returned X results
```
### ❌ Logs ناموفق (اگه باز ارور بده):
اگر باز این ارور رو دیدی:
```
error: Cannot invoke native callback from a different isolate
```
یعنی `Future(() async { ... })` کافی نبود و باید از یکی از این روش‌های جایگزین استفاده کنیم:
---
## 🔄 راه‌حل‌های جایگزین (اگر Fix فعلی کار نکرد)
### گزینه 1: استفاده از SchedulerBinding
```dart
import 'package:flutter/scheduler.dart';
SchedulerBinding.instance.addPostFrameCallback((_) async {
final ragResults = await hierarchicalRag.search(...);
// Handle results
});
```
### گزینه 2: استفاده از Button به جای TextField onSubmitted
```dart
// Replace TextField onSubmitted with IconButton
IconButton(
icon: Icon(Icons.send),
onPressed: () async {
// This runs in main isolate
await _sendMessage();
},
)
```
### گزینه 3: استفاده از Compute با Model Re-initialization
```dart
// Create isolated function
Future<List<HybridSearchResult>> _searchInIsolate(String query) async {
// Re-initialize LlamaCpp in THIS isolate
final backend = LlamaCppEmbeddingBackend();
await backend.initialize(modelPath: AppConfig.llamaCppEmbeddingModelPath);
// Perform search
final results = await hierarchicalRag.search(query: query, ...);
await backend.dispose();
return results;
}
// Call with compute
final results = await compute(_searchInIsolate, messageToSend);
```
---
## 📊 خلاصه تمام تغییرات انجام شده
### 1. `embedding_service.dart`
- ✅ غیرفعال کردن Isolate برای Desktop (خط 84)
- ✅ پردازش Sequential (خطوط 286-343)
- ✅ لاگ‌های مفصل
### 2. `llama_cpp_embedding_backend_io.dart`
- ✅ متد `_generateEmbeddingSync()` برای FFI خالص (خطوط 25-46)
- ✅ لاگ‌های مفصل برای FFI calls
### 3. `hierarchical_rag_manager.dart`
- ✅ پارامتر `useIsolate` با auto-detection (خطوط 126-139)
- ✅ Sequential indexing بدون `Future.wait()` (خطوط 510-541, 834-865)
- ✅ لاگ‌های مفصل در `search()`
### 4. `hierarchical_retrieval_service.dart`
- ✅ لاگ‌های مفصل برای هر 3 Stage
- ✅ Separator logs برای خوانایی بهتر
### 5. **`public_lab_chat_screen.dart` (FIX اصلی)**
- ✅ Import برای `HybridSearchResult` (خط 5)
-**Wrapping RAG search در `Future(() async { ... })`** (خطوط 232-245)
- ✅ لاگ‌های مفصل قبل و بعد از RAG search
### 6. `RAG_DESKTOP_FIX_SUMMARY.md`
- ✅ مستندات کامل تغییرات قبلی
---
## 🎯 نتیجه‌گیری
این آخرین تلاش برای حل مشکل isolate است. اگر این fix کار کرد:
- ✅ مشکل حل شد، RAG search از TextField کار می‌کنه
اگر کار نکرد:
- باید از یکی از راه‌حل‌های جایگزین استفاده کنیم
- احتمالاً باید Button استفاده کنیم به جای TextField onSubmitted
- یا باید LlamaCpp رو در Isolate جداگانه initialize کنیم
**لطفاً برنامه رو اجرا کن و نتیجه رو بهم بگو! 🙏**
+185
View File
@@ -0,0 +1,185 @@
# Distributed AI Chat (DAI)
A research project implementing a distributed AI chat system with RAG (Retrieval-Augmented Generation) capabilities, supporting multiple AI models and edge computing.
## ⚠️ License Notice
**This is a proprietary research project. All rights reserved.**
This software is protected by copyright and is made available under a proprietary research license. Unauthorized copying, distribution, or commercial use is strictly prohibited.
**Key License Terms:**
-**Research & Education**: Free to use for research and educational purposes
-**Contributions Welcome**: You can contribute improvements via pull requests
-**Commercial Use**: Prohibited without explicit written permission
-**Distribution**: You may not distribute or publish this software
-**Standalone Copies**: You may not create independent forks for distribution
For complete license terms, please see [LICENSE](LICENSE) file.
## 📋 Table of Contents
- [Features](#features)
- [Architecture](#architecture)
- [Installation](#installation)
- [Usage](#usage)
- [Contributing](#contributing)
- [License](#license)
- [Contact](#contact)
## ✨ Features
- **Multiple AI Models**: Support for Gemma, DeepSeek, Llama, and other models
- **Distributed Computing**: Local and distributed processing modes
- **RAG System**: Retrieval-Augmented Generation with document management
- **Edge AI**: On-device AI processing with Flutter Gemma
- **Modern UI**: Optimized, fast, and user-friendly interface
- **Document Management**: Import and manage documents for RAG queries
## 🏗️ Architecture
```
├── 🎨 frontend/ # UI Layer
│ ├── screens/ # Application screens
│ ├── widgets/ # Reusable UI components
│ └── controllers/ # UI controllers
├── 🔧 backend/ # AI Engine Layer
│ ├── ai_engine.dart # Base AI engine interface
│ ├── gemma_engine.dart # Flutter Gemma implementation
│ └── llama_engine.dart # LlamaCpp implementation
├── 🌐 network/ # Distributed System
│ ├── distributed_manager.dart
│ ├── rag_worker.dart
│ └── routing_client.dart
├── 📚 rag/ # RAG System
│ ├── rag_manager.dart
│ ├── embedding_service.dart
│ └── text_chunker.dart
└── 📦 shared/ # Shared utilities
├── models.dart
└── logger.dart
```
## 🚀 Installation
### Prerequisites
- Flutter SDK (>=3.24.0)
- Dart SDK (>=3.5.0)
### Setup
1. Clone the repository:
```bash
git clone [repository-url]
cd distributed-ai
```
2. Install dependencies:
```bash
flutter pub get
```
3. Run the application:
```bash
flutter run
```
## 📖 Usage
### Running the Application
```bash
# Mobile (Android/iOS)
flutter run
# Desktop
flutter run -d windows
flutter run -d macos
flutter run -d linux
# Web
flutter run -d web
```
### Key Features Usage
1. **Model Selection**: Browse and select AI models from the home screen
2. **Download Models**: Download required models before use
3. **Chat Interface**: Start conversations with selected models
4. **Document Management**: Import documents in the Backpack screen
5. **RAG Queries**: Use RAG system for context-aware responses
6. **Distributed Mode**: Toggle between local and distributed processing
## 🤝 Contributing
We welcome contributions to this research project!
**Important**: By contributing, you acknowledge that:
- Your contributions become the property of the project owner
- You grant a perpetual license to use your contributions
- This is a research project, not traditional open-source
### How to Contribute
1. Fork the repository (for contribution purposes only)
2. Create a feature branch
3. Make your changes
4. Submit a pull request
For detailed contribution guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md).
### Contribution Areas
- 🐛 Bug fixes
- ⚡ Performance improvements
- 🎨 UI/UX enhancements
- 📝 Documentation
- 🔧 Code quality improvements
- ✨ New features aligned with research goals
## 📄 License
This project is licensed under a **Proprietary Research License**.
**Copyright (c) 2024 [Your Name/Organization]. All rights reserved.**
### License Summary
- **Research & Education**: ✅ Allowed
- **Contributions**: ✅ Welcome via pull requests
- **Commercial Use**: ❌ Prohibited
- **Distribution**: ❌ Prohibited
- **Standalone Copies**: ❌ Prohibited
For complete license terms and conditions, please read the [LICENSE](LICENSE) file.
### Research Use
If you use this project in your research:
- Please cite the project appropriately
- Acknowledge the project in your publications
- Share your findings and improvements back to the project
## 📧 Contact
For questions, licensing inquiries, or collaboration opportunities:
- **Email**: [mshafie575@gmail.com]
- **Repository**: [https://github.com/wikm360/Distributed-AI]
- **Issues**: [https://github.com/wikm360/Distributed-AI/issues]
## 🙏 Acknowledgments
- Flutter Gemma team for edge AI capabilities
- All contributors who have helped improve this project
- Research community for feedback and suggestions
## ⚠️ Disclaimer
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
---
**Note**: This is a research project. For commercial licensing or special use cases, please contact the project owner.
+154
View File
@@ -0,0 +1,154 @@
Analyzing Mobile...
info • Don't invoke 'print' in production code • lib/core/utils/logger.dart:9:5 • avoid_print
info • Don't invoke 'print' in production code • lib/core/utils/logger.dart:16:5 • avoid_print
info • Don't invoke 'print' in production code • lib/core/utils/logger.dart:23:5 • avoid_print
info • Don't invoke 'print' in production code • lib/core/utils/logger.dart:30:5 • avoid_print
info • Use interpolation to compose strings and values • lib/features/chat/data/chat_repository.dart:85:19 • prefer_interpolation_to_compose_strings
info • Use interpolation to compose strings and values • lib/features/chat/data/websocket_service.dart:16:19 • prefer_interpolation_to_compose_strings
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/bot_generating_bubble.dart:38:60 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/chat_header.dart:43:51 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/chat_header.dart:63:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/chat_header.dart:64:29 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/chat_input.dart:58:41 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/chat_input.dart:130:14 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/chat_input.dart:138:18 • deprecated_member_use
info • Don't use 'BuildContext's across async gaps • lib/features/chat/screens/components/chat_input.dart:176:28 • use_build_context_synchronously
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/file_download_view.dart:260:31 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/message_bubble.dart:57:60 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/unread_separator.dart:16:46 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/unread_separator.dart:19:61 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/unread_separator.dart:42:47 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/unread_separator.dart:45:47 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/warning_banner.dart:14:32 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/components/warning_banner.dart:16:51 • deprecated_member_use
info • Statements in an if should be enclosed in a block • lib/features/chat/screens/lab_chat_screen.dart:111:9 • curly_braces_in_flow_control_structures
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/lab_chat_screen.dart:898:22 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/lab_chat_screen.dart:902:24 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/lab_chat_screen.dart:922:28 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/lab_rooms_screen.dart:182:63 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/lab_rooms_screen.dart:513:61 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/lab_rooms_screen.dart:523:52 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/lab_rooms_screen.dart:605:22 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/lab_rooms_screen.dart:607:41 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:176:55 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:182:57 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:193:57 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:998:33 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1004:35 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1036:38 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1101:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1135:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1157:22 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1195:35 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1213:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1214:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1252:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1265:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1278:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/chat/screens/labs_screen.dart:1305:33 • deprecated_member_use
info • The private field _strokeWidth could be 'final' • lib/features/chat/screens/room_pdf_viewer_screen.dart:106:10 • prefer_final_fields
info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion • lib/features/chat/screens/room_pdf_viewer_screen.dart:623:31 • deprecated_member_use
info • Don't invoke 'print' in production code • lib/features/chat/services/lab_pdf_service.dart:147:13 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/lab_pdf_service.dart:151:11 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/lab_pdf_service.dart:154:11 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/lab_pdf_service.dart:158:7 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/lab_room_service.dart:240:13 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/lab_room_service.dart:244:11 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/lab_room_service.dart:247:11 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/lab_room_service.dart:251:7 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/websocket_service.dart:54:13 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/websocket_service.dart:58:11 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/websocket_service.dart:62:11 • avoid_print
info • Don't invoke 'print' in production code • lib/features/chat/services/websocket_service.dart:67:7 • avoid_print
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • lib/features/note/screens/note_screen.dart:917:31 • use_build_context_synchronously
info • Don't invoke 'print' in production code • lib/features/note/services/note_service.dart:32:7 • avoid_print
info • Don't invoke 'print' in production code • lib/features/note/services/note_service.dart:78:7 • avoid_print
info • Don't invoke 'print' in production code • lib/features/note/services/note_service.dart:93:7 • avoid_print
info • Don't invoke 'print' in production code • lib/features/note/services/note_service.dart:104:7 • avoid_print
info • Don't invoke 'print' in production code • lib/features/note/services/note_service.dart:131:7 • avoid_print
info • Don't invoke 'print' in production code • lib/features/note/services/note_service.dart:162:7 • avoid_print
info • Don't invoke 'print' in production code • lib/features/rag/services/file_manager.dart:36:7 • avoid_print
info • Don't invoke 'print' in production code • lib/features/rag/services/file_manager.dart:133:7 • avoid_print
info • Missing a deprecation message • lib/features/rag/services/hierarchical_chunker.dart:168:3 • provide_deprecation_message
info • Unnecessary braces in a string interpolation • lib/features/rag/services/pdf_page_selector.dart:133:34 • unnecessary_brace_in_string_interps
info • Unnecessary braces in a string interpolation • lib/features/rag/services/text_preprocessor.dart:149:29 • unnecessary_brace_in_string_interps
info • String literals shouldn't be concatenated by the '+' operator • lib/features/rag/services/text_preprocessor.dart:231:40 • prefer_adjacent_string_concatenation
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:297:33 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:298:35 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:299:30 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:304:32 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:316:34 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:324:36 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:367:37 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:368:35 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:369:37 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:383:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:412:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:433:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:456:55 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:462:62 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:524:37 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:525:33 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:526:31 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:545:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:598:41 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:648:33 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:695:29 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:700:29 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:709:40 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:715:35 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:746:29 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:822:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:841:29 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:887:47 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:903:53 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:905:60 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:920:51 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:922:60 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:940:46 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:962:29 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1003:43 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1005:52 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1023:29 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1042:35 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1074:39 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1076:58 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1086:58 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1107:52 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1131:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1141:52 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1192:36 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1197:38 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1211:37 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1226:46 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1271:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1296:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1297:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1302:62 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1306:47 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1307:46 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1345:55 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1346:55 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1352:58 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1422:62 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1429:64 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1443:54 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1448:56 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1486:58 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1489:60 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1503:60 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1516:59 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1521:59 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1592:47 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1625:43 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1633:43 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1650:58 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1753:52 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • lib/features/settings/screens/settings_screen.dart:1763:56 • deprecated_member_use
info • Don't invoke 'print' in production code • lib/main.dart:164:5 • avoid_print
info • Don't invoke 'print' in production code • lib/main.dart:166:5 • avoid_print
info • The constant name 'qwen25_1_5B_Instruct' isn't a lowerCamelCase identifier • lib/shared/models/models.dart:165:3 • constant_identifier_names
info • The constant name 'qwen25_0_5B_instruct' isn't a lowerCamelCase identifier • lib/shared/models/models.dart:181:3 • constant_identifier_names
150 issues found. (ran in 78.4s)
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
@@ -0,0 +1,72 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// خواندن key.properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.example.chatbot"
compileSdk = 36
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
applicationId = "com.example.chatbot"
minSdk = 24
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it as String) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
// getByName("release") {
// isMinifyEnabled = false
// isShrinkResources = false
// signingConfig = signingConfigs.getByName("release")
// }
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
getByName("debug") {
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
+19
View File
@@ -0,0 +1,19 @@
# Mediapipe
-keep class com.google.mediapipe.** { *; }
-dontwarn com.google.mediapipe.**
# Protobuf
-keep class com.google.protobuf.** { *; }
-dontwarn com.google.protobuf.**
# AutoValue / JavaPoet
-keep class com.google.auto.value.** { *; }
-dontwarn com.google.auto.value.**
# javax.lang.model
-dontwarn javax.lang.model.**
# OkHttp & BouncyCastle & Conscrypt
-dontwarn org.bouncycastle.**
-dontwarn org.conscrypt.**
-dontwarn org.openjsse.**
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,65 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="DAI"
android:name="${applicationName}"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher">
<uses-native-library
android:name="libOpenCL.so"
android:required="false"/>
<uses-native-library android:name="libOpenCL-car.so" android:required="false"/>
<uses-native-library android:name="libOpenCL-pixel.so" android:required="false"/>
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.example.chatbot
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#000000</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#ffffff</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<resources>
<color name="ic_launcher_background">#ffffff</color>
<color name="splash_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">dai.wikm.ir</domain>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</domain-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+26
View File
@@ -0,0 +1,26 @@
allprojects {
repositories {
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
// Flutter mirror for engine artifacts
maven { url = uri("https://storage.flutter-io.cn/download.flutter.io") }
google()
mavenCentral()
}
}
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+17
View File
@@ -0,0 +1,17 @@
# org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.parallel=true
# systemProp.http.proxyHost=127.0.0.1
# systemProp.http.proxyPort=12334
# systemProp.https.proxyHost=127.0.0.1
# systemProp.https.proxyPort=12334
# systemProp.http.nonProxyHosts=localhost|127.*|[::1]
org.gradle.offline=true
org.gradle.caching=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
@@ -0,0 +1,30 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
// Flutter mirror for plugin artifacts
maven { url = uri("https://storage.flutter-io.cn/download.flutter.io") }
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="511px" height="511px" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" xmlns:xlink="http://www.w3.org/1999/xlink">
<g><path style="opacity:0.992" fill="#ebebeb" d="M 232.5,-0.5 C 247.5,-0.5 262.5,-0.5 277.5,-0.5C 353.52,7.75086 414.687,42.0842 461,102.5C 488.98,141.46 505.48,184.793 510.5,232.5C 510.5,247.167 510.5,261.833 510.5,276.5C 500.368,364.98 457.368,432.146 381.5,478C 348.877,495.821 314.211,506.655 277.5,510.5C 262.5,510.5 247.5,510.5 232.5,510.5C 151.78,501.407 88.28,463.74 42,397.5C 17.9708,360.577 3.80413,320.243 -0.5,276.5C -0.5,261.833 -0.5,247.167 -0.5,232.5C 9.92391,144.436 52.9239,77.6029 128.5,32C 161.093,14.0272 195.76,3.19385 232.5,-0.5 Z"/></g>
<g><path style="opacity:1" fill="#151516" d="M 245.5,42.5 C 256.332,40.9645 266.332,43.1312 275.5,49C 326.167,78.3333 376.833,107.667 427.5,137C 435.772,142.71 440.605,150.543 442,160.5C 442.667,223.167 442.667,285.833 442,348.5C 440.584,358.51 435.75,366.343 427.5,372C 374.336,402.414 321.336,433.081 268.5,464C 258.316,467.654 248.316,467.32 238.5,463C 186.33,432.414 133.997,402.081 81.5,372C 73.2497,366.343 68.4164,358.51 67,348.5C 66.3333,285.833 66.3333,223.167 67,160.5C 68.9972,148.334 75.4972,139.5 86.5,134C 139.595,103.459 192.595,72.9593 245.5,42.5 Z"/></g>
<g><path style="opacity:1" fill="#ebebeb" d="M 249.5,133.5 C 255.533,132.965 261.199,134.132 266.5,137C 295.167,153.667 323.833,170.333 352.5,187C 356.491,189.989 359.324,193.822 361,198.5C 361.667,235.833 361.667,273.167 361,310.5C 358.608,316.226 354.774,320.726 349.5,324C 321.67,339.747 294.003,355.747 266.5,372C 258.5,376.667 250.5,376.667 242.5,372C 214.997,355.747 187.33,339.747 159.5,324C 154.226,320.726 150.392,316.226 148,310.5C 147.333,273.167 147.333,235.833 148,198.5C 151.5,191 157,185.5 164.5,182C 192.928,165.792 221.261,149.626 249.5,133.5 Z"/></g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1 @@
whiteboard-room
+42
View File
@@ -0,0 +1,42 @@
# Environment Variables Template for Backend
# Database
DATABASE_URL=postgresql+asyncpg://postgres:Mey#Birth@miN2004@localhost:5432/dai_db
# Redis
REDIS_URL=redis://localhost:6379/0
# JWT Settings
SECRET_KEY=supersecretkeychangeinproduction
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=100
# LiveKit Settings
LIVEKIT_URL=ws://94.183.170.121:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# LibreTranslate Settings
# Set to true to enable translation feature
ENABLE_TRANSLATION=false
# LibreTranslate URL (only needed if ENABLE_TRANSLATION=true)
# For local Docker Compose deployment
LIBRETRANSLATE_URL=http://localhost:5000
# For Docker Compose with service name
# LIBRETRANSLATE_URL=http://libretranslate:5000
# For remote server
# LIBRETRANSLATE_URL=http://YOUR_SERVER_IP:5000
# News Settings
NEWS_FETCH_INTERVAL_HOURS=4
NEWS_MAX_ITEMS_PER_CATEGORY=50
# Gitea Settings
GITEA_API_URL=http://localhost:3000/api/v1
GITEA_ADMIN_TOKEN=
GITEA_ADMIN_USERNAME=
GITEA_WEBHOOK_SECRET=
GIT_REPOS_BASE_DIR=/tmp/git_repos
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter, HTTPException
from app.ai.service import AIService
from pydantic import BaseModel
from fastapi.responses import StreamingResponse
router = APIRouter()
class ChatRequest(BaseModel):
messages: list # لیست پیام‌ها (مثل [{"role": "user", "content": "سلام"}])
ai_service = AIService()
@router.post("/chat/stream")
async def chat_stream(request: ChatRequest):
"""
پاسخها را به صورت استریم (تکهتکه) برای فرانتاند ارسال میکند
"""
try:
# ایجاد استریم و ارسال تکه‌های پاسخ
return StreamingResponse(
ai_service.generate_stream(request.messages),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+34
View File
@@ -0,0 +1,34 @@
import json
from openai import AsyncOpenAI
from app.core.config import settings
class AIService:
def __init__(self):
self.client = AsyncOpenAI(
api_key=settings.AI_API_KEY,
base_url=settings.AI_BASE_URL
)
async def generate_stream(self, messages: list):
"""
پاسخها را به صورت استریم دریافت میکند (با بررسی امنیتی)
"""
stream = await self.client.chat.completions.create(
model=settings.AI_MODEL,
messages=messages,
stream=True,
temperature=0.9,
max_tokens=10000
)
async for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content is not None:
token = chunk.choices[0].delta.content
payload = json.dumps({"token": token}, ensure_ascii=False)
yield f"data: {payload}\n\n"
elif chunk.choices and chunk.choices[0].delta.content is None:
continue
else:
print(f"Empty choices in chunk: {chunk}")
yield "data: [DONE]\n\n"
@@ -0,0 +1,32 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import create_access_token
from app.core.config import settings
from app.auth.service import authenticate_user, register_new_user
from app.auth.schemas import Token, UserLogin
from app.users.schemas import UserCreate, UserResponse
router = APIRouter()
@router.post("/login", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
user = await authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
subject=user.username, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/register", response_model=UserResponse)
async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
user = await register_new_user(db, user_in)
return user
@@ -0,0 +1,13 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
class UserLogin(BaseModel):
username: str
password: str
@@ -0,0 +1,35 @@
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import HTTPException, status
from app.users.service import get_user_by_username, create_user
from app.users.schemas import UserCreate
from app.core.security import verify_password, create_access_token
from app.auth.schemas import UserLogin
async def authenticate_user(db: AsyncSession, username: str, password: str):
user = await get_user_by_username(db, username)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
async def register_new_user(db: AsyncSession, user_in: UserCreate):
# Check if username already exists
user = await get_user_by_username(db, user_in.username)
if user:
raise HTTPException(
status_code=400,
detail="Username already registered",
)
# Check if email already exists
from app.users.service import get_user_by_email
existing_email = await get_user_by_email(db, user_in.email)
if existing_email:
raise HTTPException(
status_code=400,
detail="Email already registered",
)
user = await create_user(db, user_in)
return user
+24
View File
@@ -0,0 +1,24 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from tavily import TavilyClient
router = APIRouter()
class SearchRequest(BaseModel):
query: str
@router.post("/search")
async def search_web(request: SearchRequest):
try:
client = TavilyClient("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")
response = client.search(
query=request.query,
include_answer="basic"
)
return response
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/process")
async def process_bot_message(message: dict):
return {"status": "received", "message": "Bot is under construction"}
@@ -0,0 +1,64 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class Chat(Base):
__tablename__ = "chats"
id = Column(Integer, primary_key=True, index=True)
lab_id = Column(Integer, ForeignKey("labs.id"), nullable=False)
title = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
lab = relationship("Lab", back_populates="chats")
messages = relationship("ChatMessage", back_populates="chat", cascade="all, delete-orphan")
members = relationship("ChatMember", back_populates="chat", cascade="all, delete-orphan")
class ChatMember(Base):
__tablename__ = "chat_members"
id = Column(Integer, primary_key=True, index=True)
chat_id = Column(Integer, ForeignKey("chats.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
joined_at = Column(DateTime(timezone=True), server_default=func.now())
last_read_message_id = Column(Integer, ForeignKey("chat_messages.id"), nullable=True)
chat = relationship("Chat", back_populates="members")
user = relationship("User")
class ChatMessage(Base):
__tablename__ = "chat_messages"
id = Column(Integer, primary_key=True, index=True)
chat_id = Column(Integer, ForeignKey("chats.id"), nullable=False)
lab_id = Column(Integer, ForeignKey("labs.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
content = Column(String, nullable=False)
is_bot_message = Column(Boolean, default=False)
timestamp = Column(DateTime(timezone=True), server_default=func.now())
# File attachment fields
file_url = Column(String, nullable=True)
file_name = Column(String, nullable=True)
file_type = Column(String, nullable=True)
file_size = Column(Integer, nullable=True)
# Reply-to field (self-referential)
reply_to_id = Column(Integer, ForeignKey("chat_messages.id"), nullable=True)
chat = relationship("Chat", back_populates="messages")
lab = relationship("Lab")
user = relationship("User")
reply_to = relationship(
"ChatMessage",
foreign_keys=[reply_to_id],
remote_side="ChatMessage.id",
uselist=False,
lazy="selectin",
)
@property
def sender_name(self) -> str:
return self.user.username if self.user else "Unknown"
@@ -0,0 +1,24 @@
import json
import asyncio
from app.core.redis import get_redis
class RedisManager:
def __init__(self):
self.redis = None
async def connect(self):
self.redis = await get_redis()
async def publish(self, channel: str, message: str):
if not self.redis:
await self.connect()
await self.redis.publish(channel, message)
async def subscribe(self, channel: str):
if not self.redis:
await self.connect()
pubsub = self.redis.pubsub()
await pubsub.subscribe(channel)
return pubsub
redis_manager = RedisManager()
+295
View File
@@ -0,0 +1,295 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import get_current_user
from app.chat.models import ChatMessage
from app.chat.schemas import MessageResponse, ChatCreate, ChatResponse, ChatMemberResponse
from app.chat.redis_manager import redis_manager
from app.chat import service as chat_service
from app.labs import service as labs_service
from app.users.models import User
from pydantic import BaseModel
from typing import Optional
import json
import os
import uuid
from fastapi import UploadFile, File
from app.core.config import settings
router = APIRouter()
class BotMessageRequest(BaseModel):
chat_id: int
lab_id: int
bot_response: str
original_sender: str
bot_username: str
# Chat management endpoints
@router.post("/{lab_id}/chats", response_model=ChatResponse)
async def create_chat(
lab_id: int,
chat_in: ChatCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Create a new chat in a lab"""
# Check if user is lab member
is_member = await labs_service.is_lab_member(db, lab_id, current_user.id)
if not is_member:
raise HTTPException(status_code=403, detail="Must be a lab member to create chats")
chat = await chat_service.create_chat(db, lab_id, chat_in, current_user.id)
# Format response
members = [ChatMemberResponse.from_orm(member) for member in chat.members]
return ChatResponse(
id=chat.id,
title=chat.title,
lab_id=chat.lab_id,
created_at=chat.created_at,
updated_at=chat.updated_at,
members=members
)
@router.get("/{lab_id}/chats", response_model=List[ChatResponse])
async def get_lab_chats(
lab_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get all chats for a lab"""
chat_data = await chat_service.get_lab_chats(db, lab_id)
# Format response
responses = []
for chat in chat_data:
# Get unread count for the current user
unread_count = await chat_service.get_unread_count(db, chat['id'], current_user.id)
members = [ChatMemberResponse.from_orm(member) for member in chat['members']]
responses.append(ChatResponse(
id=chat['id'],
title=chat['title'],
lab_id=chat['lab_id'],
created_at=chat['created_at'],
updated_at=chat['updated_at'],
last_message=chat['last_message'],
unread_count=unread_count,
members=members
))
return responses
@router.post("/{lab_id}/chats/{chat_id}/join")
async def join_chat(
lab_id: int,
chat_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Join a chat"""
# Check if user is lab member
is_member = await labs_service.is_lab_member(db, lab_id, current_user.id)
if not is_member:
raise HTTPException(status_code=403, detail="Must be a lab member to join chats")
# Add user to chat
member = await chat_service.add_chat_member(db, chat_id, current_user.id)
if member is None:
raise HTTPException(status_code=400, detail="Already a chat member")
return {"status": "success", "message": "Joined chat successfully"}
@router.get("/{lab_id}/chats/{chat_id}/history", response_model=List[MessageResponse])
async def get_chat_history(
lab_id: int,
chat_id: int,
limit: int = 50,
before_id: Optional[int] = None,
after_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get chat message history with pagination"""
# Check if user is chat member
is_member = await chat_service.is_chat_member(db, chat_id, current_user.id)
if not is_member:
raise HTTPException(status_code=403, detail="Must be a chat member to see history")
messages = await chat_service.get_chat_history(db, chat_id, limit=limit, before_id=before_id, after_id=after_id)
return messages
@router.delete("/{lab_id}/chats/{chat_id}")
async def delete_chat_endpoint(
lab_id: int,
chat_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete a chat entirely"""
# Verify owner of lab or just require them to be lab owner / chat owner?
# For now, let's verify if they are lab owner (or you can verify chat membership)
is_owner = await labs_service.is_lab_owner(db, lab_id, current_user.id)
if not is_owner:
raise HTTPException(status_code=403, detail="Only lab owner can delete a chat")
success = await chat_service.delete_chat_full(db, chat_id, current_user.id)
if not success:
raise HTTPException(status_code=404, detail="Chat not found")
return {"status": "success", "message": "Chat deleted successfully"}
@router.post("/{lab_id}/chats/{chat_id}/read")
async def mark_as_read(
lab_id: int,
chat_id: int,
message_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Mark chat messages as read up to message_id"""
await chat_service.mark_chat_read(db, chat_id, current_user.id, message_id)
await db.refresh(current_user)
# Publish read event to Redis for real-time updates
event = {
"type": "read_event",
"chat_id": chat_id,
"user_id": current_user.id,
"last_read_message_id": message_id
}
await redis_manager.publish(f"chat_{chat_id}", json.dumps(event))
return {"status": "success"}
@router.get("/{lab_id}/chats/{chat_id}/first-unread")
async def get_first_unread(
lab_id: int,
chat_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get the ID of the first unread message in the chat"""
message_id = await chat_service.get_first_unread_message_id(db, chat_id, current_user.id)
return {"message_id": message_id}
@router.post("/{lab_id}/chats/{chat_id}/bot-response")
async def send_bot_response(
lab_id: int,
chat_id: int,
bot_message: BotMessageRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Send bot response to chat"""
# Check if user is chat member
is_member = await chat_service.is_chat_member(db, chat_id, current_user.id)
if not is_member:
raise HTTPException(status_code=403, detail="Must be a chat member to send messages")
# Save bot message to DB
content = f"@{bot_message.bot_username}: {bot_message.bot_response}"
db_message = await chat_service.save_message(db, chat_id, lab_id, current_user.id, content, is_bot=True)
# Publish to Redis for other users
response = {
"type": "bot_message",
"sender": bot_message.bot_username,
"sender_id": current_user.id,
"content": bot_message.bot_response,
"lab_id": lab_id,
"chat_id": chat_id,
"timestamp": db_message.timestamp.isoformat(),
"sender_avatar_url": None
}
await redis_manager.publish(f"chat_{chat_id}", json.dumps(response))
return {"status": "success", "message": "Bot response sent"}
@router.post("/{lab_id}/chats/{chat_id}/upload", response_model=MessageResponse)
async def upload_chat_file(
lab_id: int,
chat_id: int,
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Upload a file to chat"""
# Check if user is chat member
is_member = await chat_service.is_chat_member(db, chat_id, current_user.id)
if not is_member:
raise HTTPException(status_code=403, detail="Must be a chat member to upload files")
# Create directory if it doesn't exist
upload_dir = "app/static/chat_files"
os.makedirs(upload_dir, exist_ok=True)
# Generate unique filename
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = os.path.join(upload_dir, unique_filename)
# Save file
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
file_size = len(content)
file_url = f"/static/chat_files/{unique_filename}"
# Save message to DB
db_message = await chat_service.save_message_with_file(
db,
chat_id,
lab_id,
current_user.id,
f"Sent a file: {file.filename}",
file_url=file_url,
file_name=file.filename,
file_type=file.content_type,
file_size=file_size
)
# Publish to Redis for real-time updates
response = {
"type": "user_message",
"id": db_message.id,
"sender": current_user.username,
"sender_id": current_user.id,
"content": db_message.content,
"lab_id": lab_id,
"chat_id": chat_id,
"timestamp": db_message.timestamp.isoformat(),
"file_url": file_url,
"file_name": file.filename,
"file_type": file.content_type,
"file_size": file_size,
"sender_avatar_url": current_user.avatar_url
}
await redis_manager.publish(f"chat_{chat_id}", json.dumps(response))
return db_message
@router.delete("/{lab_id}/chats/{chat_id}/messages/{message_id}")
async def delete_chat_message(
lab_id: int,
chat_id: int,
message_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete a chat message"""
success = await chat_service.delete_message(db, message_id, current_user.id)
if not success:
raise HTTPException(status_code=403, detail="Could not delete message (not yours or already deleted)")
# Publish delete event to Redis
event = {
"type": "delete_message",
"chat_id": chat_id,
"message_id": message_id
}
await redis_manager.publish(f"chat_{chat_id}", json.dumps(event))
return {"status": "success"}
@@ -0,0 +1,69 @@
from pydantic import BaseModel
from datetime import datetime
from typing import List, Optional
from app.users.schemas import UserResponse
class ChatCreate(BaseModel):
title: str
class ChatMemberResponse(BaseModel):
id: int
username: str
joined_at: datetime
last_read_message_id: Optional[int] = None
class Config:
from_attributes = True
@classmethod
def from_orm(cls, chat_member):
return cls(
id=chat_member.user.id,
username=chat_member.user.username,
joined_at=chat_member.joined_at,
last_read_message_id=chat_member.last_read_message_id
)
class ChatResponse(BaseModel):
id: int
title: str
lab_id: int
created_at: datetime
updated_at: Optional[datetime] = None
last_message: Optional[str] = None
unread_count: int = 0
members: List[ChatMemberResponse] = []
class Config:
from_attributes = True
class MessageCreate(BaseModel):
content: str
bot_enabled: bool = False
reply_to_id: Optional[int] = None
class ReplyPreviewResponse(BaseModel):
id: int
sender_name: str
content: str
class Config:
from_attributes = True
class MessageResponse(BaseModel):
id: int
content: str
timestamp: datetime
user: UserResponse
lab_id: int
chat_id: int
is_bot_message: bool = False
reply_to_id: Optional[int] = None
reply_to: Optional[ReplyPreviewResponse] = None
file_url: Optional[str] = None
file_name: Optional[str] = None
file_type: Optional[str] = None
file_size: Optional[int] = None
class Config:
from_attributes = True
+340
View File
@@ -0,0 +1,340 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy import desc, func, and_, update
from app.chat.models import Chat, ChatMember, ChatMessage
from app.chat.schemas import ChatCreate
from typing import Optional
async def create_chat(db: AsyncSession, lab_id: int, chat_in: ChatCreate, user_id: int):
db_chat = Chat(
lab_id=lab_id,
title=chat_in.title
)
db.add(db_chat)
await db.commit()
await db.refresh(db_chat)
# Save ID BEFORE second commit (important!)
chat_id = db_chat.id
member = ChatMember(chat_id=chat_id, user_id=user_id)
db.add(member)
await db.commit()
# Reload with members safely
result = await db.execute(
select(Chat)
.options(selectinload(Chat.members).selectinload(ChatMember.user))
.filter(Chat.id == chat_id)
)
return result.scalar_one()
async def get_lab_chats(db: AsyncSession, lab_id: int):
"""Get all chats for a lab with last message"""
result = await db.execute(
select(Chat)
.options(
selectinload(Chat.members).selectinload(ChatMember.user),
selectinload(Chat.messages)
)
.filter(Chat.lab_id == lab_id)
.order_by(Chat.updated_at.desc())
)
chats = result.scalars().all()
# Add last message to each chat
chat_responses = []
for chat in chats:
last_msg = None
if chat.messages:
sorted_msgs = sorted(chat.messages, key=lambda m: (m.timestamp, m.id), reverse=True)
if sorted_msgs:
last_msg = sorted_msgs[0].content[:50] + "..." if len(sorted_msgs[0].content) > 50 else sorted_msgs[0].content
# Calculate unread count if user_id is provided
# This part might need further refinement depending on how many chats are loaded
chat_data = {
'id': chat.id,
'title': chat.title,
'lab_id': chat.lab_id,
'created_at': chat.created_at,
'updated_at': chat.updated_at,
'last_message': last_msg,
'unread_count': 0, # Placeholder, will be updated in router if needed or here
'members': chat.members
}
chat_responses.append(chat_data)
return chat_responses
async def get_unread_count(db: AsyncSession, chat_id: int, user_id: int) -> int:
"""Get unread message count for a user in a chat"""
# Get last read message id
member_result = await db.execute(
select(ChatMember).filter(
ChatMember.chat_id == chat_id,
ChatMember.user_id == user_id
)
)
member = member_result.scalars().first()
if not member:
return 0
query = select(func.count(ChatMessage.id)).filter(ChatMessage.chat_id == chat_id)
if member.last_read_message_id:
query = query.filter(ChatMessage.id > member.last_read_message_id)
result = await db.execute(query)
return result.scalar() or 0
async def get_chat(db: AsyncSession, chat_id: int):
"""Get a specific chat with members"""
result = await db.execute(
select(Chat)
.options(selectinload(Chat.members).selectinload(ChatMember.user))
.filter(Chat.id == chat_id)
)
return result.scalars().first()
async def delete_chat_full(db: AsyncSession, chat_id: int, user_id: int):
"""Delete a chat and all its contents"""
# Verify chat exists
chat = await get_chat(db, chat_id)
if not chat:
return False
# Clear last_read_message_id and reply_to_id to avoid FK violations during cascade delete
from app.chat.models import ChatMember, ChatMessage
from sqlalchemy import update
await db.execute(
update(ChatMember)
.where(ChatMember.chat_id == chat_id)
.values(last_read_message_id=None)
)
await db.execute(
update(ChatMessage)
.where(ChatMessage.chat_id == chat_id)
.values(reply_to_id=None)
)
await db.delete(chat)
await db.commit()
return True
async def add_chat_member(db: AsyncSession, chat_id: int, user_id: int):
"""Add a member to a chat"""
# Check if already a member
result = await db.execute(
select(ChatMember).filter(
ChatMember.chat_id == chat_id,
ChatMember.user_id == user_id
)
)
if result.scalars().first():
return None # Already a member
member = ChatMember(chat_id=chat_id, user_id=user_id)
db.add(member)
await db.commit()
await db.refresh(member)
return member
async def is_chat_member(db: AsyncSession, chat_id: int, user_id: int) -> bool:
"""Check if user is a member of a chat"""
result = await db.execute(
select(ChatMember).filter(
ChatMember.chat_id == chat_id,
ChatMember.user_id == user_id
)
)
return result.scalars().first() is not None
async def get_chat_history(db: AsyncSession, chat_id: int, limit: int = 50, before_id: Optional[int] = None, after_id: Optional[int] = None):
"""Get messages for a chat with pagination"""
query = select(ChatMessage).options(
selectinload(ChatMessage.user),
selectinload(ChatMessage.reply_to).selectinload(ChatMessage.user),
).filter(ChatMessage.chat_id == chat_id)
if before_id:
query = query.filter(ChatMessage.id < before_id)
query = query.order_by(ChatMessage.timestamp.desc(), ChatMessage.id.desc())
elif after_id:
query = query.filter(ChatMessage.id > after_id)
query = query.order_by(ChatMessage.timestamp.asc(), ChatMessage.id.asc())
else:
query = query.order_by(ChatMessage.timestamp.desc(), ChatMessage.id.desc())
query = query.limit(limit)
result = await db.execute(query)
messages = result.scalars().all()
# Always return in ascending order for the UI
if before_id or not after_id:
return sorted(messages, key=lambda m: (m.timestamp, m.id))
return messages
async def mark_chat_read(db: AsyncSession, chat_id: int, user_id: int, last_message_id: int):
"""Update last read message ID for a user in a chat"""
result = await db.execute(
select(ChatMember).filter(
ChatMember.chat_id == chat_id,
ChatMember.user_id == user_id
)
)
member = result.scalars().first()
if member:
# Only update if the new message ID is greater than the current one
if not member.last_read_message_id or last_message_id > member.last_read_message_id:
member.last_read_message_id = last_message_id
await db.commit()
return member
async def get_first_unread_message_id(db: AsyncSession, chat_id: int, user_id: int) -> Optional[int]:
"""Get the ID of the first unread message"""
member_result = await db.execute(
select(ChatMember).filter(
ChatMember.chat_id == chat_id,
ChatMember.user_id == user_id
)
)
member = member_result.scalars().first()
if not member or not member.last_read_message_id:
# If never read, get the oldest message or None
query = select(ChatMessage.id).filter(ChatMessage.chat_id == chat_id).order_by(ChatMessage.timestamp.asc(), ChatMessage.id.asc()).limit(1)
result = await db.execute(query)
return result.scalar()
query = select(ChatMessage.id).filter(
ChatMessage.chat_id == chat_id,
ChatMessage.id > member.last_read_message_id
).order_by(ChatMessage.timestamp.asc(), ChatMessage.id.asc()).limit(1)
result = await db.execute(query)
return result.scalar()
async def save_message(db: AsyncSession, chat_id: int, lab_id: int, user_id: int, content: str, is_bot: bool = False, reply_to_id: Optional[int] = None):
"""Save a message to database"""
message = ChatMessage(
chat_id=chat_id,
lab_id=lab_id,
user_id=user_id,
content=content,
is_bot_message=is_bot,
reply_to_id=reply_to_id,
)
db.add(message)
# Update chat's updated_at timestamp
chat_result = await db.execute(select(Chat).filter(Chat.id == chat_id))
chat = chat_result.scalars().first()
if chat:
chat.updated_at = message.timestamp
await db.commit()
await db.refresh(message)
# Load user and reply_to relationships
result = await db.execute(
select(ChatMessage)
.options(
selectinload(ChatMessage.user),
selectinload(ChatMessage.reply_to).selectinload(ChatMessage.user),
)
.filter(ChatMessage.id == message.id)
)
return result.scalar_one()
async def save_message_with_file(
db: AsyncSession,
chat_id: int,
lab_id: int,
user_id: int,
content: str,
file_url: str,
file_name: str,
file_type: str,
file_size: int,
reply_to_id: Optional[int] = None,
):
"""Save a message with a file attachment to database"""
message = ChatMessage(
chat_id=chat_id,
lab_id=lab_id,
user_id=user_id,
content=content,
file_url=file_url,
file_name=file_name,
file_type=file_type,
file_size=file_size,
is_bot_message=False,
reply_to_id=reply_to_id,
)
db.add(message)
# Update chat's updated_at timestamp
chat_result = await db.execute(select(Chat).filter(Chat.id == chat_id))
chat = chat_result.scalars().first()
if chat:
chat.updated_at = message.timestamp
await db.commit()
await db.refresh(message)
# Load user and reply_to relationships
result = await db.execute(
select(ChatMessage)
.options(
selectinload(ChatMessage.user),
selectinload(ChatMessage.reply_to).selectinload(ChatMessage.user),
)
.filter(ChatMessage.id == message.id)
)
return result.scalar_one()
async def delete_message(db: AsyncSession, message_id: int, user_id: int):
"""Delete a chat message if it belongs to the user"""
result = await db.execute(
select(ChatMessage).filter(
ChatMessage.id == message_id,
ChatMessage.user_id == user_id
)
)
message = result.scalars().first()
if not message:
return False
chat_id = message.chat_id
# 1. Handle replies: set reply_to_id to NULL for messages replying to this one
await db.execute(
update(ChatMessage)
.where(ChatMessage.reply_to_id == message_id)
.values(reply_to_id=None)
)
# 2. Handle last_read_message_id in ChatMember
# Find the previous message in this chat to point to
prev_msg_result = await db.execute(
select(ChatMessage.id)
.where(ChatMessage.chat_id == chat_id, ChatMessage.id < message_id)
.order_by(desc(ChatMessage.id))
.limit(1)
)
prev_msg_id = prev_msg_result.scalar()
await db.execute(
update(ChatMember)
.where(ChatMember.last_read_message_id == message_id)
.values(last_read_message_id=prev_msg_id)
)
# 3. Delete the message
await db.delete(message)
await db.commit()
return True
@@ -0,0 +1,251 @@
import json
import asyncio
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from socketio import AsyncNamespace, AsyncServer
from app.core.database import SessionLocal
from app.chat.redis_manager import redis_manager
from app.chat import service as chat_service
from app.users.service import get_user_by_username
from app.chat.models import ChatMessage
from app.core.security import settings
from jose import jwt, JWTError
sio = AsyncServer(
async_mode='asgi',
cors_allowed_origins="*",
logger=True,
engineio_logger=False,
ping_timeout=30,
ping_interval=10
)
class ChatNamespace(AsyncNamespace):
def __init__(self, namespace='/chat'):
super().__init__(namespace)
self._session_rooms = {}
self._redis_tasks = {}
self._redis_pubsubs = {}
self._chat_refcounts = {}
self._redis_lock = asyncio.Lock()
async def _ensure_redis_listener(self, chat_id: int):
async with self._redis_lock:
if chat_id in self._redis_tasks:
self._chat_refcounts[chat_id] += 1
return
channel_name = f"chat_{chat_id}"
pubsub = await redis_manager.subscribe(channel_name)
self._redis_pubsubs[chat_id] = pubsub
self._chat_refcounts[chat_id] = 1
async def listen_to_redis():
try:
async for message in pubsub.listen():
if message.get("type") != "message":
continue
try:
payload = json.loads(message.get("data", "{}"))
except Exception:
continue
event_type = payload.get("type")
if event_type == "read_event":
await self.emit("read_event", payload, room=channel_name)
elif event_type == "delete_message":
await self.emit("delete_message", payload, room=channel_name)
else:
await self.emit("message", payload, room=channel_name)
except Exception as e:
print(f"Socket.IO Redis listener error for chat {chat_id}: {e}")
self._redis_tasks[chat_id] = asyncio.create_task(listen_to_redis())
async def _release_redis_listener(self, chat_id: int):
async with self._redis_lock:
if chat_id not in self._chat_refcounts:
return
self._chat_refcounts[chat_id] -= 1
if self._chat_refcounts[chat_id] > 0:
return
channel_name = f"chat_{chat_id}"
task = self._redis_tasks.pop(chat_id, None)
pubsub = self._redis_pubsubs.pop(chat_id, None)
self._chat_refcounts.pop(chat_id, None)
if task:
task.cancel()
if pubsub:
try:
await pubsub.unsubscribe(channel_name)
except Exception:
pass
async def on_connect(self, sid, environ, auth):
data = auth or {}
lab_id = data.get('lab_id')
chat_id = data.get('chat_id')
token = data.get('token')
if not all([lab_id, chat_id, token]):
return False
try:
async with SessionLocal() as db:
user = await self._get_user_from_token(token, db)
if not user:
return False
is_member = await chat_service.is_chat_member(db, chat_id, user.id)
if not is_member:
return False
self._session_rooms[sid] = {
'lab_id': lab_id,
'chat_id': chat_id,
'user_id': user.id,
'username': user.username,
'bot_username': user.bot_username if user.bot_username else None,
'avatar_url': user.avatar_url,
}
room_name = f"chat_{chat_id}"
await self.enter_room(sid, room_name)
await self._ensure_redis_listener(chat_id)
print(f"Socket.IO: User {user.username} connected to chat {chat_id}")
except Exception as e:
print(f"Socket.IO connection error: {e}")
return False
return True
async def _get_user_from_token(self, token: str, db: AsyncSession):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
except JWTError:
return None
return await get_user_by_username(db, username)
async def on_disconnect(self, sid):
room_info = self._session_rooms.get(sid)
if room_info:
room_name = f"chat_{room_info['chat_id']}"
await self.leave_room(sid, room_name)
await self._release_redis_listener(room_info['chat_id'])
print(f"Socket.IO: User {room_info['username']} disconnected from chat {room_info['chat_id']}")
del self._session_rooms[sid]
async def on_message(self, sid, data):
room_info = self._session_rooms.get(sid)
if not room_info:
return
lab_id = room_info['lab_id']
chat_id = room_info['chat_id']
user_id = room_info['user_id']
username = room_info['username']
bot_username = room_info['bot_username']
avatar_url = room_info['avatar_url']
if not isinstance(data, dict):
data = {}
is_bot_response = data.get("is_bot_response", False)
try:
async with SessionLocal() as db:
if is_bot_response:
if not bot_username:
return
content = data.get("content", "")
db_message = await chat_service.save_message(
db, chat_id, lab_id, user_id, content, is_bot=True
)
response = {
"type": "bot_message",
"sender": bot_username,
"sender_id": user_id,
"content": content,
"lab_id": lab_id,
"chat_id": chat_id,
"timestamp": db_message.timestamp.isoformat(),
"is_bot_message": True,
"sender_avatar_url": None,
}
await self.emit('message', response, room=f"chat_{chat_id}")
return
bot_enabled = data.get("bot_enabled", False)
reply_to_id = data.get("reply_to_id")
if reply_to_id is not None:
try:
reply_to_id = int(reply_to_id)
except (ValueError, TypeError):
reply_to_id = None
has_bot_mention = False
clean_message = data.get("content", "")
if bot_enabled and bot_username:
bot_mention_pattern = f"@{bot_username}"
has_bot_mention = bot_mention_pattern in clean_message
if has_bot_mention:
clean_message = clean_message.replace(bot_mention_pattern, "").strip()
db_message = await chat_service.save_message(
db, chat_id, lab_id, user_id, data.get("content", ""),
is_bot=False, reply_to_id=reply_to_id
)
reply_to_preview = None
if db_message.reply_to is not None:
reply_sender = db_message.reply_to.user.username if db_message.reply_to.user else "Unknown"
reply_to_preview = {
"id": db_message.reply_to.id,
"sender_name": reply_sender,
"content": db_message.reply_to.content[:200],
}
response = {
"type": "user_message",
"id": db_message.id,
"sender": username,
"sender_id": user_id,
"content": data.get("content", ""),
"lab_id": lab_id,
"chat_id": chat_id,
"timestamp": db_message.timestamp.isoformat(),
"reply_to_id": reply_to_id,
"reply_to": reply_to_preview,
"sender_avatar_url": avatar_url,
}
await self.emit('message', response, room=f"chat_{chat_id}")
if has_bot_mention:
bot_request = {
"type": "bot_request",
"sender": username,
"sender_id": user_id,
"bot_username": bot_username,
"original_message": data.get("content", ""),
"clean_message": clean_message,
"lab_id": lab_id,
"chat_id": chat_id
}
await self.emit('message', bot_request, to=sid)
except Exception as e:
print(f"Socket.IO message error: {e}")
chat_namespace = ChatNamespace('/chat')
sio.register_namespace(chat_namespace)
@@ -0,0 +1,248 @@
import json
import asyncio
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import update
from datetime import datetime, timezone
from app.core.database import get_db
from app.chat.redis_manager import redis_manager
from app.chat import service as chat_service
from app.users.service import get_user_by_username
from app.users.models import User
from app.core.security import settings
from jose import jwt, JWTError
router = APIRouter()
# Store active WebSocket connections per user
active_connections = {} # user_id -> list of websockets
def get_active_websockets(user_id: int):
"""Get all active WebSocket connections for a user"""
return active_connections.get(user_id, [])
def add_active_connection(user_id: int, websocket: WebSocket):
"""Add a WebSocket connection for a user"""
if user_id not in active_connections:
active_connections[user_id] = []
active_connections[user_id].append(websocket)
def remove_active_connection(user_id: int, websocket: WebSocket):
"""Remove a WebSocket connection for a user"""
if user_id in active_connections:
if websocket in active_connections[user_id]:
active_connections[user_id].remove(websocket)
if not active_connections[user_id]:
del active_connections[user_id]
async def broadcast_user_online_status(user_id: int, lab_id: int, is_online: bool):
"""Broadcast user online/offline status to lab members"""
status_update = {
"type": "user_status",
"user_id": user_id,
"lab_id": lab_id,
"is_online": is_online,
"timestamp": datetime.now(timezone.utc).isoformat()
}
await redis_manager.publish(f"lab_{lab_id}_status", json.dumps(status_update))
async def get_user_from_token(token: str, db: AsyncSession):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
except JWTError:
return None
user = await get_user_by_username(db, username)
return user
@router.websocket("/ws/{lab_id}/{chat_id}")
async def websocket_endpoint(
websocket: WebSocket,
lab_id: int,
chat_id: int,
token: str,
db: AsyncSession = Depends(get_db)
):
user = await get_user_from_token(token, db)
if not user:
await websocket.close(code=1008)
return
await websocket.accept()
user_id = user.id
# Set user as online and add connection
await db.execute(
update(User)
.where(User.id == user_id)
.values(is_online=True, last_seen=datetime.now(timezone.utc))
)
await db.commit()
add_active_connection(user_id, websocket)
# Broadcast user came online
await broadcast_user_online_status(user_id, lab_id, True)
redis_task = asyncio.create_task(
redis_manager.subscribe(f"chat_{chat_id}", redis_handler)
)
# Subscribe to lab status updates
status_task = asyncio.create_task(
redis_manager.subscribe(f"lab_{lab_id}_status", status_handler)
)
def status_handler(message):
if not websocket.client_connected:
return
try:
import asyncio
asyncio.run_coroutine_threadsafe(
websocket.send_text(message),
asyncio.get_event_loop()
)
except:
pass
def redis_handler(message):
if not websocket.client_connected:
return
try:
import asyncio
asyncio.run_coroutine_threadsafe(
websocket.send_text(message),
asyncio.get_event_loop()
)
except:
pass
# Initialize bot user
bot_enabled = user.bot_username is not None and user.bot_username != ""
# Send connection confirmation
await websocket.send_text(json.dumps({"type": "connected"}))
try:
while True:
data = await websocket.receive_json()
message_type = data.get("type")
if message_type == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
elif message_type == "join":
pass
elif message_type == "bot_request":
pass
elif message_type == "message":
# Check if user is lab member
is_member = await chat_service.is_lab_member(db, lab_id, user_id)
if not is_member:
await websocket.send_text(json.dumps({"type": "error", "message": "Not a lab member"}))
continue
# Parse message
message_data = data.get("message", {})
content = message_data.get("content", "")
reply_to_id = message_data.get("reply_to_id")
if reply_to_id is not None:
try:
reply_to_id = int(reply_to_id)
except (ValueError, TypeError):
reply_to_id = None
# Check if bot is enabled and message mentions user's bot
has_bot_mention = False
clean_message = message_data["content"]
if bot_enabled and user.bot_username:
bot_mention_pattern = f"@{user.bot_username}"
has_bot_mention = bot_mention_pattern in message_data["content"]
if has_bot_mention:
# Remove bot mention from message
clean_message = message_data["content"].replace(bot_mention_pattern, "").strip()
# Save to DB
db_message = await chat_service.save_message(
db, chat_id, lab_id, user.id, message_data["content"],
is_bot=False, reply_to_id=reply_to_id
)
# Build reply preview if available
reply_to_preview = None
if db_message.reply_to is not None:
reply_sender = db_message.reply_to.user.username if db_message.reply_to.user else "Unknown"
reply_to_preview = {
"id": db_message.reply_to.id,
"sender_name": reply_sender,
"content": db_message.reply_to.content[:200],
}
# Construct response
response = {
"type": "user_message",
"id": db_message.id,
"sender": user.username,
"sender_id": user.id,
"content": message_data["content"],
"lab_id": lab_id,
"chat_id": chat_id,
"timestamp": db_message.timestamp.isoformat(),
"reply_to_id": reply_to_id,
"reply_to": reply_to_preview,
"sender_avatar_url": user.avatar_url,
}
# Publish to Redis for other users
await redis_manager.publish(f"chat_{chat_id}", json.dumps(response))
# If bot is enabled and mentioned, process bot message
if has_bot_mention:
# Send bot request back to the sender for local processing
bot_request = {
"type": "bot_request",
"sender": user.username,
"sender_id": user.id,
"bot_username": user.bot_username,
"original_message": message_data["content"],
"clean_message": clean_message,
"lab_id": lab_id,
"chat_id": chat_id
}
await websocket.send_text(json.dumps(bot_request))
except WebSocketDisconnect:
redis_task.cancel()
status_task.cancel()
# Set user as offline and remove connection
await db.execute(
update(User)
.where(User.id == user_id)
.values(is_online=False, last_seen=datetime.now(timezone.utc))
)
await db.commit()
remove_active_connection(user_id, websocket)
# Broadcast user went offline
await broadcast_user_online_status(user_id, lab_id, False)
except Exception as e:
redis_task.cancel()
status_task.cancel()
# Set user as offline and remove connection
await db.execute(
update(User)
.where(User.id == user_id)
.values(is_online=False, last_seen=datetime.now(timezone.utc))
)
await db.commit()
remove_active_connection(user_id, websocket)
@@ -0,0 +1,64 @@
from pydantic_settings import BaseSettings
from typing import Optional
from functools import lru_cache
class Settings(BaseSettings):
PROJECT_NAME: str = "Distributed AI Chat"
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = "supersecretkeychangeinproduction"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 43200 # 30 days
DATABASE_URL: str = "postgresql+asyncpg://postgres:Mey%23Birth%40miN2004@localhost:5432/dai_db"
REDIS_URL: str = "redis://localhost:6379/0"
# LiveKit Settings
LIVEKIT_URL: str = "ws://94.183.170.121:7880"
LIVEKIT_API_KEY: str = "devkey"
LIVEKIT_API_SECRET: str = "secret"
# News Settings
NEWS_FETCH_INTERVAL_HOURS: int = 4
NEWS_MAX_ITEMS_PER_CATEGORY: int = 50
NEWS_FEED_URLS: dict = {
"Computer Science": "https://sciencedaily.com/rss/computers_math/computer_science.xml",
"AI": "https://www.sciencedaily.com/rss/computers_math/artificial_intelligence.xml",
"Technology": "https://www.sciencedaily.com/rss/matter_energy/technology.xml",
"Quantum Physics":"https://sciencedaily.com/rss/matter_energy/quantum_physics.xml",
"Quantum Computing":"https://sciencedaily.com/rss/matter_energy/quantum_computing.xml",
"Mathematics":"https://sciencedaily.com/rss/computers_math/mathematics.xml"
}
# LibreTranslate Settings
ENABLE_TRANSLATION: bool = False # Set to True to enable translation feature
LIBRETRANSLATE_URL: str = "http://localhost:5000"
# Cloud AI Config
AI_API_KEY: str = "17356777-475f-5060-abb8-0f0ca04df768" # کلید API ارائه‌شده
AI_BASE_URL: str = "https://arvancloudai.ir/gateway/models/Qwen3-30B-A3B/JXMbt0YuGgdXs2emO40IIqCQswz7IwuUbLmX1uW-4m6wqb_OBQPYUj9tbBYoEViHY0w_UHYv7-ykcN3pM7lqFRqvvlXY4chS8JXWTOw4qULKkd2Jw98ILSGJPbSLrmQBHIYkPErjc8AMCE2eQF94vGbZu5PEKaYtGONxbpZJ_BQhSr-By79PCADPP3wMaGAugY4W1j7NvNgp4U2Db9G_MlnfaVBrKJUTmTwBQyrMgNJOpPlpEycsyYtwXMSqmh-V/v1"
AI_MODEL: str = "Qwen3-30B-A3B"
# AI_BASE_URL: str = "https://arvancloudai.ir/gateway/models/Xerxes-1/t6tYSSaxEeUypQzkFQgozwlCd34729chHZKFs13eBKJsZymUkkLsBqVuMOPGZV2rAGI21B9JvKDQS_oDierPWTLBvp3SHTnknGZ0DzPZl28-Qv7ianVwZ_lUWrn8NQlL_yYjyAHQ4QKfgYA0I52BsCZB94H-klrsXso8BjTXRYc2sPnXDmPVoosepHYu5Vp4tB6nzVWs-dvr6iwUDz_pPX8HfRK6fENs_-awsN86XpjUJMa_ADQ/v1"
# AI_MODEL: str ="Xerxes-1"
# Gitea Configuration
GITEA_API_URL: str = "http://localhost:3000/api/v1"
GITEA_ADMIN_TOKEN: str = "11e7888696c99a980dac2e74e5422feaf4da10ba"
GITEA_ADMIN_USERNAME: str = "wikm"
GITEA_WEBHOOK_SECRET: str = ""
GIT_REPOS_BASE_DIR: str = "/tmp/git_repos" # Local directory for Git repositories
# AI_API_KEY: str = "sk-nn1BTh3sxtrg3ouyor8zil2vpmFatYX41HKhorofAj2EK9w9" # کلید API ارائه‌شده
# AI_BASE_URL: str = "https://api.gapgpt.app/v1"
# AI_MODEL: str = "gapgpt-qwen-3.5"
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
@@ -0,0 +1,18 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=True)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
Base = declarative_base()
async def get_db():
async with SessionLocal() as session:
yield session
+31
View File
@@ -0,0 +1,31 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.users.service import get_user_by_username
from app.auth.schemas import TokenData
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
print(payload)
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = await get_user_by_username(db, username=token_data.username)
if user is None:
raise credentials_exception
return user
@@ -0,0 +1,7 @@
import redis.asyncio as redis
from app.core.config import settings
redis_client = redis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async def get_redis():
return redis_client
@@ -0,0 +1,34 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.core.config import settings
from app.core.database import SessionLocal
from app.news.service import NewsService
import logging
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
async def scheduled_news_fetch():
logger.info("Running scheduled news fetch")
async with SessionLocal() as db:
await NewsService.fetch_and_store_news(db)
def start_scheduler():
if not scheduler.running:
scheduler.add_job(
scheduled_news_fetch,
'interval',
hours=settings.NEWS_FETCH_INTERVAL_HOURS,
id='news_fetch_job',
replace_existing=True
)
scheduler.start()
logger.info("Scheduler started")
# Trigger immediate fetch on startup
scheduler.add_job(scheduled_news_fetch, 'date', run_date=None, id='news_fetch_startup')
def shutdown_scheduler():
if scheduler.running:
scheduler.shutdown()
logger.info("Scheduler shutdown")
@@ -0,0 +1,34 @@
from datetime import datetime, timedelta
from typing import Optional, Union, Any
import hashlib
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
# Try verifying with the new SHA256 pre-hashing method first
sha256_password = hashlib.sha256(plain_password.encode("utf-8")).hexdigest()
if pwd_context.verify(sha256_password, hashed_password):
return True
# Fallback: Try verifying with the legacy truncation method
# This ensures existing users can still login
legacy_password = plain_password.encode("utf-8")[:72].decode("utf-8", errors="ignore")
return pwd_context.verify(legacy_password, hashed_password)
def get_password_hash(password: str) -> str:
# Pre-hash with SHA256 to handle long passwords and avoid bcrypt 72-byte limit
password = hashlib.sha256(password.encode("utf-8")).hexdigest()
return pwd_context.hash(password)
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
@@ -0,0 +1,4 @@
"""
Gitea Integration Module
Handles Git repository management and version control for labs
"""
@@ -0,0 +1,38 @@
"""
Git-related database models
"""
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime
from sqlalchemy.orm import relationship
from app.core.database import Base
from datetime import datetime
class GitRepository(Base):
"""Git repository mapping for labs"""
__tablename__ = 'git_repositories'
id = Column(Integer, primary_key=True, index=True)
lab_id = Column(Integer, ForeignKey('labs.id'), unique=True, nullable=False)
gitea_repo_id = Column(Integer, nullable=False)
gitea_repo_name = Column(String(255), nullable=False)
gitea_clone_url = Column(String(512), nullable=False)
last_sync_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship
lab = relationship("Lab", back_populates="git_repository")
class GitCommit(Base):
"""Track commits made by users"""
__tablename__ = 'git_commits'
id = Column(Integer, primary_key=True, index=True)
lab_id = Column(Integer, ForeignKey('labs.id'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
commit_hash = Column(String(40), nullable=False)
commit_message = Column(String(1000), nullable=False)
branch = Column(String(100), default='main')
created_at = Column(DateTime, default=datetime.utcnow)
+357
View File
@@ -0,0 +1,357 @@
"""
Git API Router - Endpoints for Git operations
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.deps import get_current_user, get_db
from app.gitea.service import GiteaService
from app.gitea.models import GitRepository, GitCommit
from app.labs.models import Lab, LabMember
from app.core.config import get_settings, Settings
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
class CommitRequest(BaseModel):
message: str
files: List[str] = []
branch: str = "main"
@router.post("/{lab_id}/repository")
async def create_lab_repository(
lab_id: int,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Create Git repository for lab"""
# Check if lab exists and user is owner
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
owner_result = await db.execute(
select(LabMember).where(
LabMember.lab_id == lab_id,
LabMember.user_id == current_user.id,
LabMember.role == "owner",
)
)
owner_member = owner_result.scalar_one_or_none()
if not owner_member:
raise HTTPException(status_code=403, detail="Not authorized")
# Check if repository already exists
result = await db.execute(
select(GitRepository).where(GitRepository.lab_id == lab_id)
)
existing_repo = result.scalar_one_or_none()
if existing_repo:
return {"status": "exists", "repository": existing_repo.gitea_repo_name}
# Create repository in Gitea
try:
gitea_service = GiteaService(settings)
repo_info = await gitea_service.create_repository(lab_id, lab.name, current_user.username)
# Store repository mapping in database
git_repo = GitRepository(
lab_id=lab_id,
gitea_repo_id=repo_info["id"],
gitea_repo_name=repo_info["name"],
gitea_clone_url=repo_info["clone_url"]
)
db.add(git_repo)
await db.commit()
return {"status": "success", "repository": repo_info}
except Exception as e:
logger.error(f"Failed to create repository for lab {lab_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{lab_id}/repository")
async def get_lab_repository(
lab_id: int,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get lab repository information"""
# Check if lab exists and user has access
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
result = await db.execute(
select(GitRepository).where(GitRepository.lab_id == lab_id)
)
git_repo = result.scalar_one_or_none()
if not git_repo:
return {"status": "not_initialized"}
return {
"status": "success",
"repository": {
"id": git_repo.gitea_repo_id,
"name": git_repo.gitea_repo_name,
"clone_url": git_repo.gitea_clone_url,
"last_sync": git_repo.last_sync_at.isoformat() if git_repo.last_sync_at else None
}
}
@router.get("/{lab_id}/commits")
async def get_commit_history(
lab_id: int,
limit: int = 50,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Get commit history for lab"""
# Check if lab exists and user has access
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
gitea_service = GiteaService(settings)
commits = await gitea_service.get_commit_history(lab_id, limit)
return {"status": "success", "commits": commits}
@router.post("/{lab_id}/commit")
async def create_commit(
lab_id: int,
commit_data: CommitRequest,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Create commit with file changes"""
# Check if lab exists and user has access
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
if not commit_data.message.strip():
raise HTTPException(status_code=400, detail="Commit message is required")
try:
gitea_service = GiteaService(settings)
commit_hash = await gitea_service.create_commit(
lab_id, current_user.id, commit_data.message, commit_data.files
)
# Store commit record in database
commit = GitCommit(
lab_id=lab_id,
user_id=current_user.id,
commit_hash=commit_hash,
commit_message=commit_data.message,
branch=commit_data.branch
)
db.add(commit)
await db.commit()
return {"status": "success", "commit_hash": commit_hash}
except Exception as e:
logger.error(f"Failed to create commit for lab {lab_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{lab_id}/push")
async def push_changes(
lab_id: int,
branch: str = "main",
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Push local commits to Gitea"""
# Check if lab exists and user has access
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
try:
gitea_service = GiteaService(settings)
await gitea_service.push_to_gitea(db, lab_id, branch)
# Update last sync time
result = await db.execute(
select(GitRepository).where(GitRepository.lab_id == lab_id)
)
git_repo = result.scalar_one_or_none()
if git_repo:
git_repo.last_sync_at = datetime.utcnow()
await db.commit()
return {"status": "success", "message": "Pushed to Gitea"}
except Exception as e:
logger.error(f"Failed to push lab {lab_id} to Gitea: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{lab_id}/pull")
async def pull_changes(
lab_id: int,
branch: str = "main",
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Pull changes from Gitea"""
# Check if lab exists and user has access
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
try:
gitea_service = GiteaService(settings)
await gitea_service.pull_from_gitea(db, lab_id, branch)
# Update last sync time
result = await db.execute(
select(GitRepository).where(GitRepository.lab_id == lab_id)
)
git_repo = result.scalar_one_or_none()
if git_repo:
git_repo.last_sync_at = datetime.utcnow()
await db.commit()
return {"status": "success", "message": "Pulled from Gitea"}
except Exception as e:
logger.error(f"Failed to pull lab {lab_id} from Gitea: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{lab_id}/diff/{commit_hash}")
async def get_file_diff(
lab_id: int,
commit_hash: str,
path: Optional[str] = None,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Get diff for a commit (optionally scoped to a file)"""
# Check if lab exists and user has access
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
try:
gitea_service = GiteaService(settings)
diff = await gitea_service.get_file_diff(lab_id, commit_hash, path)
return {"status": "success", "diff": diff}
except Exception as e:
logger.error(f"Failed to get diff for lab {lab_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{lab_id}/checkout/{commit_hash}")
async def checkout_commit(
lab_id: int,
commit_hash: str,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Checkout a commit and rebuild backpack files"""
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
try:
gitea_service = GiteaService(settings)
await gitea_service.checkout_commit(db, lab_id, current_user.id, commit_hash)
return {"status": "success"}
except Exception as e:
logger.error(f"Failed to checkout commit for lab {lab_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{lab_id}/status")
async def get_git_status(
lab_id: int,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Get git status for a lab repository"""
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
try:
gitea_service = GiteaService(settings)
status_info = await gitea_service.get_status(lab_id)
return {"status": "success", **status_info}
except Exception as e:
logger.error(f"Failed to get status for lab {lab_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{lab_id}/file/{commit_hash}/{file_path:path}")
async def get_file_content(
lab_id: int,
commit_hash: str,
file_path: str,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Get file content at specific commit"""
# Check if lab exists and user has access
lab = await db.get(Lab, lab_id)
if not lab:
raise HTTPException(status_code=404, detail="Lab not found")
try:
gitea_service = GiteaService(settings)
content = await gitea_service.get_file_content_at_commit(lab_id, commit_hash, file_path)
return {"status": "success", "content": content}
except Exception as e:
logger.error(f"Failed to get file content for lab {lab_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/test/{lab_id}")
async def test_git_integration(
lab_id: int,
db: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_settings)
):
"""Test Git integration for a lab"""
try:
result = await db.execute(
select(GitRepository).where(GitRepository.lab_id == lab_id)
)
git_repo = result.scalar_one_or_none()
if not git_repo:
return {"status": "error", "message": "No Git repository found"}
# Check if local repository exists
import os
repo_dir = os.path.join(settings.GIT_REPOS_BASE_DIR, f"lab_{lab_id}")
repo_exists = os.path.exists(os.path.join(repo_dir, '.git'))
return {
"status": "success",
"lab_id": lab_id,
"gitea_repo_id": git_repo.gitea_repo_id,
"repo_name": git_repo.gitea_repo_name,
"local_repo_exists": repo_exists,
"last_sync": git_repo.last_sync_at.isoformat() if git_repo.last_sync_at else None
}
except Exception as e:
logger.error(f"Error testing Git integration for lab {lab_id}: {e}")
return {"status": "error", "message": str(e)}

Some files were not shown because too many files have changed in this diff Show More