Save verified Mithqal AI project progress

This commit is contained in:
Hamza Ayed
2026-10-01 01:02:03 +03:00
parent f0a444ca46
commit e1f981d29e
111 changed files with 1589 additions and 295 deletions
@@ -29,6 +29,15 @@ class ApiRepository {
return data['model'] as String? ?? 'نموذج غير معروف';
}
Future<List<String>> getAvailableModels() async {
final response = await http.get(Uri.parse('$_baseUrl/v1/models'));
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
return (data['data'] as List<dynamic>)
.map((model) => (model as Map<String, dynamic>)['id'] as String)
.toList();
}
Future<String> _userId() async {
final cached = _cachedUserId;
if (cached != null) return cached;
@@ -145,7 +154,26 @@ class ApiRepository {
return message['content'] as String? ?? 'لم يصل نص في الإجابة.';
}
Stream<String> completeStream(List<ChatMessage> messages) async* {
Future<String> askWorkspaceAgent(String task, {String? model}) async {
final response = await http
.post(
Uri.parse('$_baseUrl/v1/agent/workspace'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'task': task, if (model != null) 'model': model}),
)
.timeout(const Duration(minutes: 10));
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
final result = data['result'] as String? ?? '';
final files = (data['files'] as List<dynamic>? ?? const []).cast<String>();
if (files.isEmpty) return result;
return '$result\n\nالمصادر المقروءة: ${files.join('، ')}';
}
Stream<String> completeStream(
List<ChatMessage> messages, {
String? model,
}) async* {
final client = http.Client();
try {
final request =
@@ -155,6 +183,7 @@ class ApiRepository {
'Accept': 'application/x-ndjson',
})
..body = jsonEncode({
if (model != null && model.isNotEmpty) 'model': model,
'messages':
messages
.map(
@@ -0,0 +1,88 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:local_notifier/local_notifier.dart';
class CompletionNotificationService {
CompletionNotificationService._();
static final instance = CompletionNotificationService._();
final _plugin = FlutterLocalNotificationsPlugin();
bool _initialized = false;
Future<void> initialize() async {
if (kIsWeb || _initialized) return;
if (_isDesktop) {
await localNotifier.setup(appName: 'Mithqal AI');
_initialized = true;
return;
}
const settings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
);
await _plugin.initialize(settings);
_initialized = true;
}
Future<bool> requestPermission() async {
if (kIsWeb) return false;
await initialize();
if (_isDesktop) return true;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
true;
case TargetPlatform.iOS:
return await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
default:
return true;
}
}
Future<void> showFinished({required bool succeeded}) async {
if (kIsWeb || !_initialized) return;
final title = succeeded ? 'اكتملت الإجابة' : 'تعذر إكمال الطلب';
final body = succeeded
? 'انتهى النموذج من معالجة طلبك.'
: 'افتح التطبيق لمراجعة الخطأ.';
if (_isDesktop) {
LocalNotification(title: title, body: body).show();
return;
}
const android = AndroidNotificationDetails(
'generation_complete',
'اكتملت المهام',
channelDescription: 'تنبيه عند انتهاء توليد الإجابة أو مهمة الوكيل',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
);
const details = NotificationDetails(
android: android,
iOS: DarwinNotificationDetails(),
);
await _plugin.show(
1001,
title,
body,
details,
);
}
bool get _isDesktop =>
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS;
}
@@ -14,9 +14,42 @@ class ChatCubit extends Cubit<ChatState> {
Future<void> _loadModelName() async {
try {
final modelName = await _api.getModelName();
if (!isClosed) emit(state.copyWith(modelName: modelName));
final models = await _api.getAvailableModels();
if (!isClosed) {
emit(
state.copyWith(
modelName: modelName,
availableModels: models,
selectedModel: state.selectedModel ?? modelName,
),
);
}
} catch (_) {
if (!isClosed) emit(state.copyWith(modelName: 'تعذر الاتصال بالخادم'));
if (!isClosed) {
emit(
state.copyWith(
modelName: 'تعذر الاتصال بالخادم',
availableModels: const [],
),
);
}
}
}
void selectModel(String model) {
if (state.availableModels.contains(model)) {
emit(state.copyWith(selectedModel: model, clearError: true));
}
}
void toggleWorkspaceMode() {
if (!state.isSending) {
emit(
state.copyWith(
isWorkspaceMode: !state.isWorkspaceMode,
clearError: true,
),
);
}
}
@@ -76,20 +109,79 @@ class ChatCubit extends Cubit<ChatState> {
}
Future<void> send(String text) async {
await _sendWithHistory(text, state.messages);
}
Future<void> regenerateLastAnswer() async {
if (state.isSending) return;
final messages = state.messages;
if (messages.length < 2 ||
messages.last.fromUser ||
!messages[messages.length - 2].fromUser) {
return;
}
final history = messages.sublist(0, messages.length - 1);
await _sendWithHistory(
history.last.text,
history,
appendUserMessage: false,
);
}
Future<void> editLastQuestion(String text) async {
if (state.isSending || text.trim().isEmpty) return;
final messages = state.messages;
final questionIndex = messages.lastIndexWhere(
(message) => message.fromUser,
);
if (questionIndex < 0 || questionIndex != messages.length - 2) return;
final history = [
...messages.take(questionIndex),
ChatMessage(text.trim(), fromUser: true),
];
await _sendWithHistory(text.trim(), history, appendUserMessage: false);
}
Future<void> _sendWithHistory(
String text,
List<ChatMessage> history, {
bool appendUserMessage = true,
}) async {
final prompt = text.trim();
if (prompt.isEmpty || state.isSending || state.isLoadingHistory) return;
final messages = [...state.messages, ChatMessage(prompt, fromUser: true)];
final messages =
appendUserMessage
? [...history, ChatMessage(prompt, fromUser: true)]
: List<ChatMessage>.from(history);
final originalMessages = state.messages;
var answer = '';
emit(state.copyWith(messages: messages, isSending: true, clearError: true));
try {
await _saveCurrent(messages);
if (appendUserMessage) await _saveCurrent(messages);
if (isClosed) return;
emit(
state.copyWith(
messages: [...messages, const ChatMessage('', fromUser: false)],
),
);
await for (final delta in _api.completeStream(messages)) {
if (state.isWorkspaceMode) {
answer = await _api.askWorkspaceAgent(
prompt,
model: state.selectedModel,
);
if (isClosed) return;
if (answer.trim().isEmpty) {
throw Exception('لم يصل نص في إجابة الوكيل.');
}
final updated = [...messages, ChatMessage(answer, fromUser: false)];
emit(state.copyWith(messages: updated, isSending: false));
await _saveCurrent(updated);
return;
}
await for (final delta in _api.completeStream(
messages,
model: state.selectedModel,
)) {
if (isClosed) return;
answer += delta;
emit(
@@ -106,7 +198,9 @@ class ChatCubit extends Cubit<ChatState> {
} catch (error) {
if (isClosed) return;
final partial =
answer.isEmpty
!appendUserMessage
? originalMessages
: answer.isEmpty
? messages
: [...messages, ChatMessage(answer, fromUser: false)];
emit(
@@ -116,7 +210,7 @@ class ChatCubit extends Cubit<ChatState> {
error: error.toString(),
),
);
if (answer.isNotEmpty) {
if (appendUserMessage && answer.isNotEmpty) {
try {
await _saveCurrent(partial);
} catch (_) {
@@ -181,7 +275,14 @@ class ChatCubit extends Cubit<ChatState> {
final url = value.trim();
if (url.isEmpty) return;
_api.setBaseUrl(url);
emit(state.copyWith(apiBaseUrl: _api.baseUrl, clearError: true));
emit(
state.copyWith(
apiBaseUrl: _api.baseUrl,
availableModels: const [],
clearSelectedModel: true,
clearError: true,
),
);
_loadModelName();
}
}
@@ -59,8 +59,11 @@ class ChatState {
this.activeConversationId,
this.isLoadingHistory = true,
this.isSending = false,
this.isWorkspaceMode = false,
this.error,
this.modelName = 'جارٍ اكتشاف النموذج…',
this.availableModels = const [],
this.selectedModel,
required this.apiBaseUrl,
});
@@ -69,8 +72,11 @@ class ChatState {
final String? activeConversationId;
final bool isLoadingHistory;
final bool isSending;
final bool isWorkspaceMode;
final String? error;
final String modelName;
final List<String> availableModels;
final String? selectedModel;
final String apiBaseUrl;
ChatState copyWith({
@@ -80,9 +86,13 @@ class ChatState {
bool clearActiveConversation = false,
bool? isLoadingHistory,
bool? isSending,
bool? isWorkspaceMode,
String? error,
bool clearError = false,
String? modelName,
List<String>? availableModels,
String? selectedModel,
bool clearSelectedModel = false,
String? apiBaseUrl,
}) => ChatState(
messages: messages ?? this.messages,
@@ -93,8 +103,12 @@ class ChatState {
: (activeConversationId ?? this.activeConversationId),
isLoadingHistory: isLoadingHistory ?? this.isLoadingHistory,
isSending: isSending ?? this.isSending,
isWorkspaceMode: isWorkspaceMode ?? this.isWorkspaceMode,
error: clearError ? null : (error ?? this.error),
modelName: modelName ?? this.modelName,
availableModels: availableModels ?? this.availableModels,
selectedModel:
clearSelectedModel ? null : (selectedModel ?? this.selectedModel),
apiBaseUrl: apiBaseUrl ?? this.apiBaseUrl,
);
}
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/monokai-sublime.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import '../../../audio/presentation/cubit/voice_cubit.dart';
import '../../../audio/presentation/cubit/voice_state.dart';
import '../../../../core/notifications/completion_notification_service.dart';
import '../cubit/chat_cubit.dart';
import '../cubit/chat_state.dart';
@@ -22,6 +25,16 @@ class ChatPage extends StatefulWidget {
class _ChatPageState extends State<ChatPage> {
final _input = TextEditingController();
final _scroll = ScrollController();
bool _notificationsEnabled = false;
@override
void initState() {
super.initState();
_notificationsEnabled = !kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS);
}
@override
void dispose() {
@@ -108,6 +121,73 @@ class _ChatPageState extends State<ChatPage> {
}
}
Future<void> _copyAnswer(String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('تم نسخ الإجابة')));
}
}
Future<void> _shareAnswer(String text) async {
await Share.share(text);
}
Future<void> _enableNotifications() async {
final allowed =
await CompletionNotificationService.instance.requestPermission();
if (!mounted) return;
setState(() => _notificationsEnabled = allowed);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
kIsWeb
? 'إشعارات النظام غير مفعلة في نسخة الويب؛ سيظهر إشعار داخل الصفحة.'
: allowed
? 'تم تفعيل إشعارات اكتمال المهام.'
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
),
),
);
}
Future<void> _editQuestion(String question) async {
final controller = TextEditingController(text: question);
final revised = await showDialog<String>(
context: context,
builder:
(context) => Directionality(
textDirection: TextDirection.rtl,
child: AlertDialog(
title: const Text('تعديل السؤال'),
content: TextField(
controller: controller,
autofocus: true,
minLines: 2,
maxLines: 6,
decoration: const InputDecoration(border: OutlineInputBorder()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('إلغاء'),
),
FilledButton(
onPressed:
() => Navigator.pop(context, controller.text.trim()),
child: const Text('إعادة الإرسال'),
),
],
),
),
);
controller.dispose();
if (revised != null && revised.isNotEmpty && mounted) {
context.read<ChatCubit>().editLastQuestion(revised);
}
}
@override
Widget build(BuildContext context) => MultiBlocListener(
listeners: [
@@ -122,6 +202,28 @@ class _ChatPageState extends State<ChatPage> {
: previous.messages.last.text),
listener: (context, state) => _followStreamIfNearby(),
),
BlocListener<ChatCubit, ChatState>(
listenWhen: (previous, current) =>
previous.isSending && !current.isSending,
listener: (context, state) {
final succeeded = state.error == null;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
succeeded ? 'اكتملت الإجابة.' : 'انتهى الطلب مع خطأ.',
),
duration: const Duration(seconds: 3),
),
);
if (_notificationsEnabled) {
CompletionNotificationService.instance.showFinished(
succeeded: succeeded,
);
}
},
),
BlocListener<VoiceCubit, VoiceState>(
listenWhen:
(previous, current) =>
@@ -178,12 +280,16 @@ class _ChatPageState extends State<ChatPage> {
child: const Icon(Icons.hub_rounded, color: Colors.white),
),
const SizedBox(width: 10),
const Text(
'Sovereign AI',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
const Expanded(
child: Text(
'Mithqal AI',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
),
),
),
],
@@ -345,37 +451,105 @@ class _ChatPageState extends State<ChatPage> {
);
}
Widget _topBar() => Container(
Widget _topBar() => LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 640;
return Container(
height: 70,
padding: const EdgeInsets.symmetric(horizontal: 26),
padding: EdgeInsets.symmetric(horizontal: compact ? 12 : 26),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
),
child: Row(
children: [
const Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'مساعدك المحلي',
style: TextStyle(
color: _ink,
fontWeight: FontWeight.w700,
fontSize: 16,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'مساعدك المحلي',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _ink,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
if (!compact) ...[
const SizedBox(height: 3),
const Text(
'المحادثة بالنموذج المحلي · تفريغ الصوت عبر Groq',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Color(0xFF7A867F), fontSize: 11),
),
],
],
),
),
SizedBox(width: compact ? 4 : 12),
IconButton(
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
tooltip:
context.watch<ChatCubit>().state.isWorkspaceMode
? 'إيقاف وضع الوكيل (قراءة فقط)'
: 'وضع الوكيل: قراءة ملفات المشروع فقط',
color:
context.watch<ChatCubit>().state.isWorkspaceMode ? _green : _ink,
icon: Icon(
context.watch<ChatCubit>().state.isWorkspaceMode
? Icons.folder_open_rounded
: Icons.folder_outlined,
),
),
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 112 : 190),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value:
context.watch<ChatCubit>().state.availableModels.contains(
context.watch<ChatCubit>().state.selectedModel,
)
? context.watch<ChatCubit>().state.selectedModel
: context
.watch<ChatCubit>()
.state
.availableModels
.first,
isExpanded: true,
isDense: true,
items:
context
.watch<ChatCubit>()
.state
.availableModels
.map(
(model) => DropdownMenuItem(
value: model,
child: Text(
model,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
style: const TextStyle(fontSize: 11),
),
),
)
.toList(),
onChanged: (model) {
if (model != null) {
context.read<ChatCubit>().selectModel(model);
}
},
),
),
SizedBox(height: 3),
Text(
'المحادثة بالنموذج المحلي · تفريغ الصوت عبر Groq',
style: TextStyle(color: Color(0xFF7A867F), fontSize: 11),
),
],
),
const Spacer(),
),
SizedBox(width: compact ? 2 : 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 7),
padding: EdgeInsets.symmetric(horizontal: compact ? 7 : 11, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFE6F2EC),
borderRadius: BorderRadius.circular(20),
@@ -395,15 +569,29 @@ class _ChatPageState extends State<ChatPage> {
],
),
),
const SizedBox(width: 8),
SizedBox(width: compact ? 2 : 8),
IconButton(
onPressed: _settings,
tooltip: 'إعداد الاتصال',
icon: const Icon(Icons.tune_rounded, color: _ink),
),
IconButton(
onPressed: _enableNotifications,
tooltip: _notificationsEnabled
? 'إشعارات اكتمال المهام مفعلة'
: 'تفعيل إشعارات اكتمال المهام',
color: _notificationsEnabled ? _green : _ink,
icon: Icon(
_notificationsEnabled
? Icons.notifications_active_outlined
: Icons.notifications_none_outlined,
),
),
],
),
);
},
);
Widget _welcome() => Center(
child: SingleChildScrollView(
@@ -440,9 +628,11 @@ class _ChatPageState extends State<ChatPage> {
),
),
const SizedBox(height: 8),
const Text(
'اكتب سؤالك، وسأرسله إلى النموذج المحلي على جهازك.',
style: TextStyle(color: Color(0xFF718078), fontSize: 14),
Text(
context.watch<ChatCubit>().state.isWorkspaceMode
? 'وضع الوكيل يبحث في ملفات المشروع للقراءة فقط.'
: 'اكتب سؤالك، وسأرسله إلى النموذج المحلي على جهازك.',
style: const TextStyle(color: Color(0xFF718078), fontSize: 14),
),
const SizedBox(height: 28),
Wrap(
@@ -505,18 +695,69 @@ class _ChatPageState extends State<ChatPage> {
? null
: Border.all(color: const Color(0xFFE8EAE5)),
),
child:
message.fromUser
? SelectableText(
message.text,
textDirection: TextDirection.rtl,
style: const TextStyle(
color: _ink,
fontSize: 14,
height: 1.65,
),
)
: _AssistantMarkdown(text: message.text),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (message.fromUser)
SelectableText(
message.text,
textDirection: TextDirection.rtl,
style: const TextStyle(
color: _ink,
fontSize: 14,
height: 1.65,
),
)
else
_AssistantMarkdown(text: message.text),
if (!chat.isSending &&
!showTyping &&
index == chat.messages.length - 1 &&
!message.fromUser &&
message.text.isNotEmpty)
Align(
alignment: AlignmentDirectional.centerStart,
child: Wrap(
spacing: 2,
children: [
IconButton(
tooltip: 'نسخ الإجابة',
visualDensity: VisualDensity.compact,
onPressed: () => _copyAnswer(message.text),
icon: const Icon(Icons.copy_rounded, size: 18),
),
IconButton(
tooltip: 'مشاركة الإجابة',
visualDensity: VisualDensity.compact,
onPressed: () => _shareAnswer(message.text),
icon: const Icon(Icons.share_rounded, size: 18),
),
IconButton(
tooltip: 'إعادة توليد الإجابة',
visualDensity: VisualDensity.compact,
onPressed:
() =>
context
.read<ChatCubit>()
.regenerateLastAnswer(),
icon: const Icon(Icons.refresh_rounded, size: 19),
),
],
),
)
else if (!chat.isSending &&
index == chat.messages.length - 2 &&
message.fromUser)
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
onPressed: () => _editQuestion(message.text),
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('تعديل السؤال'),
),
),
],
),
),
);
},
@@ -582,8 +823,11 @@ class _ChatPageState extends State<ChatPage> {
textDirection: TextDirection.rtl,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
decoration: const InputDecoration(
hintText: 'اكتب رسالتك هنا…',
decoration: InputDecoration(
hintText:
chat.isWorkspaceMode
? 'اسأل عن ملفات مساحة العمل…'
: 'اكتب رسالتك هنا…',
hintStyle: TextStyle(color: Color(0xFF9BA59E)),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:record/record.dart';
import 'core/network/api_repository.dart';
import 'core/notifications/completion_notification_service.dart';
import 'features/audio/presentation/cubit/voice_cubit.dart';
import 'features/chat/presentation/cubit/chat_cubit.dart';
import 'features/chat/presentation/pages/chat_page.dart';
@@ -10,7 +11,9 @@ import 'features/chat/presentation/pages/chat_page.dart';
const _green = Color(0xFF167C68);
const _paper = Color(0xFFF6F5F0);
void main() {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await CompletionNotificationService.instance.initialize();
final api = ApiRepository(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
@@ -36,7 +39,7 @@ class SovereignApp extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Sovereign AI',
title: 'Mithqal AI',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,