Complete local hybrid search and improve agent reliability

This commit is contained in:
Hamza Ayed
2026-10-02 23:38:02 +03:00
parent 3563a104a3
commit 140f6eb287
62 changed files with 7546 additions and 314 deletions
@@ -10,6 +10,13 @@ import '../../features/chat/presentation/cubit/chat_state.dart';
const _uuid = Uuid();
class AgentRunResult {
const AgentRunResult({required this.answer, this.proposal});
final String answer;
final FileChangeProposal? proposal;
}
class ApiRepository {
ApiRepository({required String baseUrl}) : _baseUrl = baseUrl;
@@ -36,13 +43,21 @@ class ApiRepository {
return data['model'] as String? ?? 'نموذج غير معروف';
}
Future<List<String>> getAvailableModels() async {
Future<List<ModelDescriptor>> 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();
return (data['data'] as List<dynamic>).map((item) {
final model = item as Map<String, dynamic>;
return ModelDescriptor(
id: model['id'] as String,
capabilities:
(model['capabilities'] as List<dynamic>? ?? const [])
.whereType<String>()
.toList(),
capabilitiesVerified: model['capabilities_verified'] as bool? ?? false,
);
}).toList();
}
Future<String> _userId() async {
@@ -66,10 +81,45 @@ class ApiRepository {
void _checkStatus(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('API ${response.statusCode}: ${response.body}');
throw Exception(_apiError(response));
}
}
String _apiError(http.Response response, {String service = 'API'}) {
var message = response.body;
String? requestId = response.headers['x-request-id'];
try {
final data = jsonDecode(response.body);
if (data is Map<String, dynamic>) {
requestId ??= data['request_id']?.toString();
final detail = data['detail'];
if (detail is String) {
message = detail;
} else if (detail is List) {
message = detail
.whereType<Map<String, dynamic>>()
.map((item) {
final location = (item['loc'] as List<dynamic>? ?? const [])
.where((part) => part != 'body')
.join('.');
final error = item['msg']?.toString() ?? 'طلب غير صالح';
return location.isEmpty ? error : '$location: $error';
})
.join('؛ ');
} else if (data['error'] is Map<String, dynamic>) {
message =
(data['error'] as Map<String, dynamic>)['message']?.toString() ??
message;
}
}
} on FormatException {
// Keep the bounded response text when a service returns non-JSON errors.
}
if (message.length > 500) message = '${message.substring(0, 500)}…';
final correlation = requestId == null ? '' : ' (معرّف الطلب: $requestId)';
return '$service ${response.statusCode}: $message$correlation';
}
Future<List<SavedConversation>> listConversations() async {
final response = await http.get(
Uri.parse('$_baseUrl/v1/conversations'),
@@ -174,7 +224,7 @@ class ApiRepository {
)
.timeout(const Duration(minutes: 3));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('API ${response.statusCode}: ${response.body}');
_checkStatus(response);
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final choices = data['choices'] as List<dynamic>;
@@ -182,16 +232,179 @@ class ApiRepository {
return message['content'] as String? ?? 'لم يصل نص في الإجابة.';
}
Future<String> askWorkspaceAgent(String task, {String? model}) async {
Future<List<String>> listWorkspaceFiles(String workspacePath) async {
final response = await http.post(
Uri.parse('$_baseUrl/v1/agent/workspace/files'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'workspace_path': workspacePath}),
);
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
return (data['files'] as List<dynamic>).cast<String>();
}
Future<Map<String, dynamic>> indexWorkspaceKnowledge(
String workspacePath,
List<String> files,
) async {
final response = await http
.post(
Uri.parse('$_baseUrl/v1/agent/knowledge/index'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'workspace_path': workspacePath, 'files': files}),
)
.timeout(const Duration(minutes: 2));
_checkStatus(response);
return jsonDecode(response.body) as Map<String, dynamic>;
}
Future<Map<String, dynamic>> deleteWorkspaceKnowledge(
String workspacePath,
List<String> files,
) async {
final response = await http
.delete(
Uri.parse('$_baseUrl/v1/agent/knowledge/index'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'workspace_path': workspacePath, 'files': files}),
)
.timeout(const Duration(minutes: 1));
_checkStatus(response);
return jsonDecode(response.body) as Map<String, dynamic>;
}
Future<List<AgentSkillDescriptor>> getAgentSkills() async {
final response = await http.get(Uri.parse('$_baseUrl/v1/agent/skills'));
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
return (data['skills'] as List<dynamic>? ?? const [])
.map(
(item) => AgentSkillDescriptor.fromJson(item as Map<String, dynamic>),
)
.toList(growable: false);
}
Future<AgentRunResult> runAgent(
String task, {
String? model,
String? workspacePath,
List<String> workspaceFiles = const [],
String? skillId,
void Function(String message)? onProgress,
}) async {
final client = http.Client();
_activeRequestClient = client;
try {
final request =
http.Request('POST', Uri.parse('$_baseUrl/v1/agent/workspace'))
http.Request('POST', Uri.parse('$_baseUrl/v1/agent/run/stream'))
..headers['Content-Type'] = 'application/json'
..headers['Accept'] = 'text/event-stream'
..body = jsonEncode({
'task': task,
if (model != null) 'model': model,
if (workspacePath != null) 'workspace_path': workspacePath,
if (workspaceFiles.isNotEmpty) 'workspace_files': workspaceFiles,
if (skillId != null) 'skill_id': skillId,
});
final streamed = await client
.send(request)
.timeout(const Duration(minutes: 10));
if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
final response = await http.Response.fromStream(streamed);
_checkStatus(response);
}
String? eventName;
Map<String, dynamic>? resultData;
String? streamError;
await for (final line in streamed.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.timeout(const Duration(minutes: 10))) {
if (line.startsWith('event: ')) {
eventName = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
final data = jsonDecode(line.substring(6)) as Map<String, dynamic>;
switch (eventName) {
case 'progress':
final message = data['message']?.toString();
if (message != null) onProgress?.call(message);
break;
case 'done':
resultData = data;
break;
case 'error':
streamError =
data['message']?.toString() ?? 'تعذر تنفيذ مهمة الوكيل.';
break;
}
eventName = null;
}
}
if (streamError != null) throw Exception(streamError);
final data = resultData;
if (data == null) throw Exception('انقطع اتصال البث قبل اكتمال الوكيل.');
final result = data['result']?.toString() ?? '';
final files =
(data['files'] as List<dynamic>? ?? const []).cast<String>();
final tool = data['tool'] as String?;
final skill = data['skill'] as String?;
final toolLabels = {
'calculator': 'الآلة الحاسبة المحلية',
'search_workspace': 'البحث في مساحة العمل (قراءة فقط)',
'local-llm': 'النموذج المحلي دون أداة',
};
final usedTool = tool == null ? null : toolLabels[tool] ?? tool;
final details = <String>[
if (usedTool != null) 'الأداة المستخدمة: $usedTool.',
if (skill != null)
'المهارة المستخدمة: ${switch (skill) {
'code_explain' => 'شرح الكود',
'code_review' => 'مراجعة الكود',
'test_plan' => 'خطة اختبارات',
_ => skill,
}}.',
if (files.isNotEmpty) 'الملفات المقروءة: ${files.join('، ')}.',
];
final proposalData = data['proposal'];
final proposal =
proposalData is Map<String, dynamic>
? FileChangeProposal.fromJson(proposalData)
: null;
return AgentRunResult(
answer: details.isEmpty ? result : '$result\n\n${details.join('\n')}',
proposal: proposal,
);
} finally {
if (identical(_activeRequestClient, client)) _activeRequestClient = null;
client.close();
}
}
Future<String> applyFileChange(FileChangeProposal proposal) async {
final response = await http.post(
Uri.parse('$_baseUrl/v1/agent/files/apply'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'token': proposal.token, 'confirm': true}),
);
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
final path = data['path']?.toString() ?? proposal.path;
return data['operation'] == 'create'
? 'أُنشئ الملف $path بعد موافقتك.'
: 'طُبّق التعديل على $path بعد موافقتك.';
}
Future<String> searchWeb(String query, {String? model}) async {
final client = http.Client();
_activeRequestClient = client;
try {
final request =
http.Request('POST', Uri.parse('$_baseUrl/v1/web/search'))
..headers['Content-Type'] = 'application/json'
..body = jsonEncode({
'query': query,
'max_results': 5,
if (model != null) 'model': model,
});
final streamed = await client
.send(request)
@@ -200,48 +413,21 @@ class ApiRepository {
_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('، ')}';
} finally {
if (identical(_activeRequestClient, client)) _activeRequestClient = null;
client.close();
}
}
Future<String> searchWeb(String query, {String? model}) async {
final client = http.Client();
_activeRequestClient = client;
try {
final request = http.Request(
'POST',
Uri.parse('$_baseUrl/v1/web/search'),
)
..headers['Content-Type'] = 'application/json'
..body = jsonEncode({
'query': query,
'max_results': 5,
if (model != null) 'model': model,
});
final streamed = await client
.send(request)
.timeout(const Duration(minutes: 10));
final response = await http.Response.fromStream(streamed);
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
final result = data['result'] as String? ?? '';
final sources = (data['sources'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
final sources =
(data['sources'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
if (sources.isEmpty) return result;
final sourceList = sources.map((source) {
final index = source['index'];
final title = source['title'] as String? ?? 'مصدر';
final url = source['url'] as String? ?? '';
final status = source['status'] as String?;
final note = status == 'snippet_only' ? ' (مقتطف فقط؛ تعذر فتح الصفحة)' : '';
return '[$index] [$title]($url)$note';
}).join('\n');
final sourceList = sources
.map((source) {
final index = source['index'];
final title = source['title'] as String? ?? 'مصدر';
final url = source['url'] as String? ?? '';
final status = source['status'] as String?;
final note =
status == 'snippet_only' ? ' (مقتطف فقط؛ تعذر فتح الصفحة)' : '';
return '[$index] [$title]($url)$note';
})
.join('\n');
return '$result\n\nالمصادر:\n$sourceList';
} finally {
if (identical(_activeRequestClient, client)) _activeRequestClient = null;
@@ -278,7 +464,8 @@ class ApiRepository {
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
final result = data['result'] as String? ?? '';
final names = (data['files'] as List<dynamic>? ?? const []).cast<String>();
final names =
(data['files'] as List<dynamic>? ?? const []).cast<String>();
return names.isEmpty
? result
: '$result\n\nالملفات التي حللتها: ${names.join('، ')}';
@@ -288,6 +475,46 @@ class ApiRepository {
}
}
Future<String> analyzeImages(
List<XFile> files,
String question, {
String? model,
}) async {
final client = http.Client();
_activeRequestClient = client;
try {
final request = http.MultipartRequest(
'POST',
Uri.parse('$_baseUrl/v1/agent/images/analyze'),
)..fields['question'] = question;
if (model != null) request.fields['model'] = model;
for (final file in files) {
request.files.add(
http.MultipartFile.fromBytes(
'files',
await file.readAsBytes(),
filename: file.name,
),
);
}
final streamed = await client
.send(request)
.timeout(const Duration(minutes: 10));
final response = await http.Response.fromStream(streamed);
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
final result = data['result'] as String? ?? '';
final names =
(data['files'] as List<dynamic>? ?? const []).cast<String>();
return names.isEmpty
? result
: '$result\n\nالصور التي حللتها: ${names.join('، ')}';
} finally {
if (identical(_activeRequestClient, client)) _activeRequestClient = null;
client.close();
}
}
Future<void> rateAnswer(
String conversationId,
int messageIndex,
@@ -334,7 +561,11 @@ class ApiRepository {
.timeout(const Duration(minutes: 3));
if (response.statusCode < 200 || response.statusCode >= 300) {
final body = await response.stream.bytesToString();
throw Exception('API ${response.statusCode}: $body');
throw Exception(
_apiError(
http.Response(body, response.statusCode, headers: response.headers),
),
);
}
var completed = false;
await for (final line in response.stream
@@ -376,9 +607,7 @@ class ApiRepository {
final streamed = await request.send().timeout(const Duration(minutes: 3));
final response = await http.Response.fromStream(streamed);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception(
'Transcription API ${response.statusCode}: ${response.body}',
);
throw Exception(_apiError(response, service: 'Transcription API'));
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data['text'] as String? ?? '';
@@ -0,0 +1,43 @@
import 'package:flutter/services.dart';
import 'settings_store_memory.dart'
if (dart.library.io) 'settings_store_io.dart'
if (dart.library.html) 'settings_store_web.dart'
as platform;
/// Persists non-secret user preferences outside the conversation database.
/// API credentials must never be stored here.
class LocalSettingsStore {
LocalSettingsStore() : inMemoryOnly = false;
LocalSettingsStore.inMemory() : inMemoryOnly = true;
final bool inMemoryOnly;
Map<String, Object?> _fallback = {};
bool _persistenceAvailable = true;
bool get persistenceAvailable =>
inMemoryOnly || (_persistenceAvailable && platform.hasPersistentStorage);
Future<Map<String, Object?>> read() async {
if (inMemoryOnly) return Map.unmodifiable(_fallback);
try {
_fallback = await platform.readSettings();
} on MissingPluginException {
_persistenceAvailable = false;
}
return Map.unmodifiable(_fallback);
}
Future<bool> update(Map<String, Object?> values) async {
_fallback = {..._fallback, ...values};
if (inMemoryOnly) return true;
try {
await platform.writeSettings(_fallback);
return persistenceAvailable;
} on MissingPluginException {
_persistenceAvailable = false;
return false;
}
}
}
@@ -0,0 +1,36 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
bool get hasPersistentStorage => true;
Future<File> _settingsFile() async {
final support = await getApplicationSupportDirectory();
final directory = Directory(
'${support.path}${Platform.pathSeparator}MithqalAI',
);
await directory.create(recursive: true);
return File('${directory.path}${Platform.pathSeparator}settings.json');
}
Future<Map<String, Object?>> readSettings() async {
final file = await _settingsFile();
if (!await file.exists()) return {};
try {
final decoded = jsonDecode(await file.readAsString());
if (decoded is Map<String, dynamic>) {
return Map<String, Object?>.from(decoded);
}
} on FormatException {
// A partial/corrupt preferences file should not prevent app startup.
}
return {};
}
Future<void> writeSettings(Map<String, Object?> values) async {
final file = await _settingsFile();
final temporary = File('${file.path}.tmp');
await temporary.writeAsString(jsonEncode(values), flush: true);
await temporary.rename(file.path);
}
@@ -0,0 +1,9 @@
Map<String, Object?> _settings = {};
bool get hasPersistentStorage => false;
Future<Map<String, Object?>> readSettings() async => {..._settings};
Future<void> writeSettings(Map<String, Object?> values) async {
_settings = {...values};
}
@@ -0,0 +1,26 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:convert';
import 'dart:html' as html;
const _settingsKey = 'mithqal-ai.settings.v1';
bool get hasPersistentStorage => true;
Future<Map<String, Object?>> readSettings() async {
final raw = html.window.localStorage[_settingsKey];
if (raw == null) return {};
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return Map<String, Object?>.from(decoded);
}
} on FormatException {
// Ignore corrupt browser-local preferences and keep defaults.
}
return {};
}
Future<void> writeSettings(Map<String, Object?> values) async {
html.window.localStorage[_settingsKey] = jsonEncode(values);
}
@@ -1,18 +1,22 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_selector/file_selector.dart';
import '../../../../core/network/api_repository.dart';
import '../../../../core/settings/local_settings_store.dart';
import 'chat_state.dart';
class ChatCubit extends Cubit<ChatState> {
ChatCubit(this._api) : super(ChatState(apiBaseUrl: _api.baseUrl)) {
_loadHistory();
_loadModelName();
ChatCubit(this._api, {LocalSettingsStore? settingsStore})
: _settingsStore = settingsStore ?? LocalSettingsStore(),
super(ChatState(apiBaseUrl: _api.baseUrl)) {
_initialize();
}
final ApiRepository _api;
final LocalSettingsStore _settingsStore;
Timer? _elapsedTimer;
Stopwatch? _requestStopwatch;
int _requestGeneration = 0;
@@ -20,6 +24,94 @@ class ChatCubit extends Cubit<ChatState> {
bool _requestAppendedUserMessage = true;
bool _requestHasProducedText = false;
bool get _defaultNotificationsEnabled =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS);
Future<void> _initialize() async {
try {
final settings = await _settingsStore.read();
final savedApiUrl = settings['api_base_url'];
if (savedApiUrl is String && _isValidApiUrl(savedApiUrl)) {
_api.setBaseUrl(savedApiUrl);
}
final missingDefaults = <String, Object?>{
if (savedApiUrl is! String || !_isValidApiUrl(savedApiUrl))
'api_base_url': _api.baseUrl,
if (settings['notifications_enabled'] is! bool)
'notifications_enabled': _defaultNotificationsEnabled,
if (settings['dark_mode'] is! bool) 'dark_mode': false,
};
if (missingDefaults.isNotEmpty) {
await _persistSettings(missingDefaults);
}
final savedModel = settings['selected_model'];
emit(
state.copyWith(
apiBaseUrl: _api.baseUrl,
selectedModel: savedModel is String ? savedModel : null,
notificationsEnabled:
settings['notifications_enabled'] is bool
? settings['notifications_enabled']! as bool
: _defaultNotificationsEnabled,
isDarkMode:
settings['dark_mode'] is bool
? settings['dark_mode']! as bool
: false,
),
);
if (!_settingsStore.persistenceAvailable) {
emit(
state.copyWith(
error:
'التخزين المحلي الدائم غير متاح؛ ستبقى الإعدادات افتراضية لهذه الجلسة.',
),
);
}
} catch (error) {
emit(
state.copyWith(
notificationsEnabled: _defaultNotificationsEnabled,
error:
'تعذر تحميل الإعدادات المحلية؛ ستُستخدم القيم الافتراضية: $error',
),
);
}
await Future.wait([_loadHistory(), _loadModelName(), _loadAgentSkills()]);
}
bool _isValidApiUrl(String value) {
final uri = Uri.tryParse(value.trim());
return uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.host.isNotEmpty &&
uri.userInfo.isEmpty &&
!uri.hasQuery &&
!uri.hasFragment;
}
Future<bool> _persistSettings(Map<String, Object?> values) async {
try {
final saved = await _settingsStore.update(values);
if (!saved && !isClosed) {
emit(
state.copyWith(
error:
'تعذر حفظ الإعدادات بعد إغلاق التطبيق؛ يعمل الإعداد الحالي لهذه الجلسة فقط.',
),
);
}
return saved;
} catch (error) {
if (!isClosed) {
emit(state.copyWith(error: 'تعذر حفظ الإعدادات المحلية: $error'));
}
return false;
}
}
void _startElapsedTimer(int generation) {
_elapsedTimer?.cancel();
_requestStopwatch = Stopwatch()..start();
@@ -56,14 +148,32 @@ class ChatCubit extends Cubit<ChatState> {
final modelName = await _api.getModelName();
final models = await _api.getAvailableModels();
if (!isClosed) {
final chatModels =
models
.where(
(model) =>
!model.capabilitiesVerified ||
model.capabilities.contains('completion'),
)
.toList();
final selectableModels = chatModels.isEmpty ? models : chatModels;
final previousSelection = state.selectedModel;
final modelIds = selectableModels.map((model) => model.id).toList();
final selectedModel =
modelIds.contains(previousSelection)
? previousSelection!
: modelName;
emit(
state.copyWith(
modelName: modelName,
modelStatus: ModelConnectionStatus.connected,
availableModels: models,
selectedModel: state.selectedModel ?? modelName,
availableModels: selectableModels,
selectedModel: selectedModel,
),
);
if (selectedModel != previousSelection) {
await _persistSettings({'selected_model': selectedModel});
}
}
} catch (_) {
if (!isClosed) {
@@ -78,30 +188,211 @@ class ChatCubit extends Cubit<ChatState> {
}
}
void selectModel(String model) {
if (state.availableModels.contains(model)) {
emit(state.copyWith(selectedModel: model, clearError: true));
Future<void> _loadAgentSkills() async {
try {
final available = await _api.getAgentSkills();
if (!isClosed) emit(state.copyWith(availableSkills: available));
} catch (error) {
if (!isClosed) {
emit(state.copyWith(error: 'تعذر تحميل مهارات الوكيل: $error'));
}
}
}
void selectAgentSkill(String? skillId) {
if (state.isSending) return;
if (skillId != null &&
!state.availableSkills.any((skill) => skill.id == skillId)) {
return;
}
emit(
state.copyWith(
selectedSkillId: skillId,
clearSelectedSkill: skillId == null,
clearError: true,
),
);
}
void selectModel(String model) {
if (state.availableModels.any((descriptor) => descriptor.id == model)) {
emit(state.copyWith(selectedModel: model, clearError: true));
unawaited(_persistSettings({'selected_model': model}));
}
}
Future<bool> setNotificationsEnabled(bool enabled) async {
emit(state.copyWith(notificationsEnabled: enabled, clearError: true));
return _persistSettings({'notifications_enabled': enabled});
}
Future<bool> setDarkMode(bool enabled) async {
emit(state.copyWith(isDarkMode: enabled, clearError: true));
return _persistSettings({'dark_mode': enabled});
}
void toggleWorkspaceMode() {
if (!state.isSending) {
final enable = !state.isWorkspaceMode;
if (enable && state.selectedWorkspacePath == null) return;
emit(
state.copyWith(
isWorkspaceMode: !state.isWorkspaceMode,
isWorkspaceMode: enable,
isWebSearchMode: false,
clearSelectedWorkspacePath: !enable,
availableWorkspaceFiles:
enable ? state.availableWorkspaceFiles : const [],
selectedWorkspaceFiles:
enable ? state.selectedWorkspaceFiles : const [],
clearError: true,
),
);
}
}
Future<List<String>> selectWorkspace(String path) async {
if (state.isSending || path.trim().isEmpty) return const [];
emit(
state.copyWith(
selectedWorkspacePath: path,
isWorkspaceMode: true,
isWebSearchMode: false,
availableWorkspaceFiles: const [],
selectedWorkspaceFiles: const [],
clearError: true,
),
);
try {
final files = await _api.listWorkspaceFiles(path);
if (isClosed || state.selectedWorkspacePath != path) return const [];
emit(state.copyWith(availableWorkspaceFiles: files));
return files;
} catch (error) {
if (!isClosed) {
emit(
state.copyWith(
isWorkspaceMode: false,
clearSelectedWorkspacePath: true,
error: _readableError(error),
),
);
}
return const [];
}
}
void selectWorkspaceFiles(List<String> paths) {
if (state.isSending || paths.length > 3) return;
final available = state.availableWorkspaceFiles.toSet();
if (paths.any((path) => !available.contains(path))) return;
emit(state.copyWith(selectedWorkspaceFiles: List.unmodifiable(paths)));
}
Future<String> indexSelectedWorkspaceKnowledge() async {
if (state.isSending) throw StateError('انتظر حتى يكتمل طلب الوكيل الحالي.');
final path = state.selectedWorkspacePath;
final files = state.selectedWorkspaceFiles;
if (path == null || files.isEmpty) {
throw StateError('اختر مساحة عمل وحدد ملفًا واحدًا على الأقل للفهرسة.');
}
emit(state.copyWith(agentProgress: 'يفهرس الملفات المحددة محليًا…'));
try {
final result = await _api.indexWorkspaceKnowledge(path, files);
final indexed = result['indexed'] as List<dynamic>? ?? const [];
final chunks = indexed.fold<int>(
0,
(total, item) =>
total + ((item as Map<String, dynamic>)['chunks'] as int? ?? 0),
);
if (!isClosed) {
emit(state.copyWith(clearAgentProgress: true, clearError: true));
}
return 'فُهرست ${indexed.length} ملفًا محليًا في SQLite ($chunks مقطعًا).';
} catch (error) {
final message = _readableError(error);
if (!isClosed) {
emit(state.copyWith(clearAgentProgress: true, error: message));
}
throw Exception(message);
}
}
Future<String> removeSelectedWorkspaceKnowledge() async {
if (state.isSending) throw StateError('انتظر حتى يكتمل طلب الوكيل الحالي.');
final path = state.selectedWorkspacePath;
final files = state.selectedWorkspaceFiles;
if (path == null || files.isEmpty) {
throw StateError('اختر مساحة عمل وحدد ملفًا واحدًا على الأقل.');
}
emit(
state.copyWith(agentProgress: 'يحذف الملفات المحددة من الفهرس المحلي…'),
);
try {
final result = await _api.deleteWorkspaceKnowledge(path, files);
final deleted =
(result['deleted'] as List<dynamic>? ?? const [])
.where(
(item) => (item as Map<String, dynamic>)['deleted'] == true,
)
.length;
if (!isClosed) {
emit(state.copyWith(clearAgentProgress: true, clearError: true));
}
return deleted == 0
? 'لم تكن الملفات المحددة موجودة في فهرس المعرفة.'
: 'أُزيل $deleted ملف من فهرس المعرفة المحلي.';
} catch (error) {
final message = _readableError(error);
if (!isClosed) {
emit(state.copyWith(clearAgentProgress: true, error: message));
}
throw Exception(message);
}
}
void dismissFileChangeProposal() {
if (state.pendingFileChangeProposal != null) {
emit(state.copyWith(clearPendingFileChangeProposal: true));
}
}
Future<String> applyFileChangeProposal() async {
final proposal = state.pendingFileChangeProposal;
if (proposal == null) throw StateError('لا توجد معاينة تغيير معلقة.');
emit(state.copyWith(agentProgress: 'يطبق التغيير الذي وافقت عليه.'));
try {
final message = await _api.applyFileChange(proposal);
if (!isClosed) {
emit(
state.copyWith(
clearPendingFileChangeProposal: true,
clearAgentProgress: true,
clearError: true,
),
);
}
return message;
} catch (error) {
if (!isClosed) {
emit(
state.copyWith(
clearPendingFileChangeProposal: true,
clearAgentProgress: true,
error: _readableError(error),
),
);
}
rethrow;
}
}
void toggleWebSearchMode() {
if (!state.isSending) {
emit(
state.copyWith(
isWebSearchMode: !state.isWebSearchMode,
isWorkspaceMode: false,
clearSelectedWorkspacePath: true,
clearError: true,
),
);
@@ -239,9 +530,10 @@ class ChatCubit extends Cubit<ChatState> {
);
final feedback = List<int?>.generate(
message.answerVersions.length,
(index) => index < message.feedbackVersions.length
? message.feedbackVersions[index]
: null,
(index) =>
index < message.feedbackVersions.length
? message.feedbackVersions[index]
: null,
)..[message.selectedVersion] = rating;
messages[messageIndex] = ChatMessage(
message.text,
@@ -296,11 +588,14 @@ class ChatCubit extends Cubit<ChatState> {
_requestAppendedUserMessage = appendUserMessage;
_requestHasProducedText = false;
var answer = '';
FileChangeProposal? pendingFileChangeProposal;
final generation = ++_requestGeneration;
emit(
state.copyWith(
messages: messages,
isSending: true,
clearAgentProgress: true,
clearPendingFileChangeProposal: true,
requestStatus: RequestStatus.generating,
elapsed: Duration.zero,
clearLastResponseDuration: true,
@@ -316,16 +611,58 @@ class ChatCubit extends Cubit<ChatState> {
),
);
_startElapsedTimer(generation);
if (attachments.isNotEmpty || state.isWorkspaceMode || state.isWebSearchMode) {
if (attachments.isNotEmpty ||
state.isWorkspaceMode ||
state.isWebSearchMode) {
const imageExtensions = {'png', 'jpg', 'jpeg', 'webp'};
final hasImages = attachments.any((file) {
final extension = file.name.split('.').last.toLowerCase();
return imageExtensions.contains(extension);
});
final hasNonImages = attachments.any((file) {
final extension = file.name.split('.').last.toLowerCase();
return !imageExtensions.contains(extension);
});
if (hasImages && hasNonImages) {
throw Exception(
'أرسل الصور وحدها، أو أرسل ملفات الكود وحدها في رسالة أخرى.',
);
}
answer =
attachments.isNotEmpty
? await _api.analyzeFiles(
attachments,
prompt.isEmpty ? 'حلّل الملفات المرفقة واشرح وظيفتها.' : prompt,
model: state.selectedModel,
)
? hasImages
? await _api.analyzeImages(
attachments,
prompt.isEmpty
? 'اقرأ النصوص في الصورة واشرح محتواها.'
: prompt,
model: state.selectedModel,
)
: await _api.analyzeFiles(
attachments,
prompt.isEmpty
? 'حلّل الملفات المرفقة واشرح وظيفتها.'
: prompt,
model: state.selectedModel,
)
: state.isWorkspaceMode
? await _api.askWorkspaceAgent(prompt, model: state.selectedModel)
? await _api
.runAgent(
prompt,
model: state.selectedModel,
workspacePath: state.selectedWorkspacePath,
workspaceFiles: state.selectedWorkspaceFiles,
skillId: state.selectedSkillId,
onProgress: (message) {
if (!isClosed && generation == _requestGeneration) {
emit(state.copyWith(agentProgress: message));
}
},
)
.then((result) {
pendingFileChangeProposal = result.proposal;
return result.answer;
})
: await _api.searchWeb(prompt, model: state.selectedModel);
if (isClosed || generation != _requestGeneration) return;
if (answer.trim().isEmpty) {
@@ -348,8 +685,10 @@ class ChatCubit extends Cubit<ChatState> {
messages: updated,
isSending: false,
requestStatus: RequestStatus.completed,
clearAgentProgress: true,
elapsed: duration,
lastResponseDuration: duration,
pendingFileChangeProposal: pendingFileChangeProposal,
),
);
await _saveCurrent(updated);
@@ -425,6 +764,8 @@ class ChatCubit extends Cubit<ChatState> {
messages: partial,
isSending: false,
requestStatus: RequestStatus.failed,
clearAgentProgress: true,
clearPendingFileChangeProposal: true,
elapsed: duration,
lastResponseDuration: duration,
error: _readableError(error),
@@ -459,6 +800,8 @@ class ChatCubit extends Cubit<ChatState> {
messages: messages,
isSending: false,
requestStatus: RequestStatus.cancelled,
clearAgentProgress: true,
clearPendingFileChangeProposal: true,
elapsed: duration,
lastResponseDuration: duration,
clearError: true,
@@ -539,9 +882,9 @@ class ChatCubit extends Cubit<ChatState> {
}
}
void setApiBaseUrl(String value) {
Future<bool> setApiBaseUrl(String value) async {
final url = value.trim();
if (url.isEmpty) return;
if (url.isEmpty || !_isValidApiUrl(url) || state.isSending) return false;
_api.setBaseUrl(url);
emit(
state.copyWith(
@@ -552,6 +895,8 @@ class ChatCubit extends Cubit<ChatState> {
clearError: true,
),
);
_loadModelName();
final persisted = await _persistSettings({'api_base_url': _api.baseUrl});
await _loadModelName();
return persisted;
}
}
@@ -2,6 +2,67 @@ enum ModelConnectionStatus { checking, connected, unavailable }
enum RequestStatus { idle, generating, completed, cancelled, failed }
class ModelDescriptor {
const ModelDescriptor({
required this.id,
required this.capabilities,
required this.capabilitiesVerified,
});
final String id;
final List<String> capabilities;
final bool capabilitiesVerified;
}
class AgentSkillDescriptor {
const AgentSkillDescriptor({
required this.id,
required this.name,
required this.description,
this.allowedTools = const [],
});
final String id;
final String name;
final String description;
final List<String> allowedTools;
factory AgentSkillDescriptor.fromJson(Map<String, dynamic> json) =>
AgentSkillDescriptor(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String? ?? '',
allowedTools:
(json['allowed_tools'] as List<dynamic>? ?? const [])
.cast<String>(),
);
}
class FileChangeProposal {
const FileChangeProposal({
required this.token,
required this.path,
required this.operation,
required this.diff,
required this.expiresInSeconds,
});
final String token;
final String path;
final String operation;
final String diff;
final int expiresInSeconds;
factory FileChangeProposal.fromJson(Map<String, dynamic> json) =>
FileChangeProposal(
token: json['token'] as String,
path: json['path'] as String,
operation: json['operation'] as String,
diff: json['diff'] as String,
expiresInSeconds: json['expires_in_seconds'] as int? ?? 600,
);
}
class ChatMessage {
const ChatMessage(
this.text, {
@@ -87,7 +148,14 @@ class ChatState {
this.activeConversationId,
this.isLoadingHistory = true,
this.isSending = false,
this.agentProgress,
this.pendingFileChangeProposal,
this.notificationsEnabled = false,
this.isDarkMode = false,
this.isWorkspaceMode = false,
this.selectedWorkspacePath,
this.availableWorkspaceFiles = const [],
this.selectedWorkspaceFiles = const [],
this.isWebSearchMode = false,
this.error,
this.modelName = 'جارٍ اكتشاف النموذج…',
@@ -96,6 +164,8 @@ class ChatState {
this.elapsed = Duration.zero,
this.lastResponseDuration,
this.availableModels = const [],
this.availableSkills = const [],
this.selectedSkillId,
this.selectedModel,
required this.apiBaseUrl,
});
@@ -105,7 +175,14 @@ class ChatState {
final String? activeConversationId;
final bool isLoadingHistory;
final bool isSending;
final String? agentProgress;
final FileChangeProposal? pendingFileChangeProposal;
final bool notificationsEnabled;
final bool isDarkMode;
final bool isWorkspaceMode;
final String? selectedWorkspacePath;
final List<String> availableWorkspaceFiles;
final List<String> selectedWorkspaceFiles;
final bool isWebSearchMode;
final String? error;
final String modelName;
@@ -113,7 +190,9 @@ class ChatState {
final RequestStatus requestStatus;
final Duration elapsed;
final Duration? lastResponseDuration;
final List<String> availableModels;
final List<ModelDescriptor> availableModels;
final List<AgentSkillDescriptor> availableSkills;
final String? selectedSkillId;
final String? selectedModel;
final String apiBaseUrl;
@@ -124,7 +203,17 @@ class ChatState {
bool clearActiveConversation = false,
bool? isLoadingHistory,
bool? isSending,
String? agentProgress,
bool clearAgentProgress = false,
FileChangeProposal? pendingFileChangeProposal,
bool clearPendingFileChangeProposal = false,
bool? notificationsEnabled,
bool? isDarkMode,
bool? isWorkspaceMode,
String? selectedWorkspacePath,
bool clearSelectedWorkspacePath = false,
List<String>? availableWorkspaceFiles,
List<String>? selectedWorkspaceFiles,
bool? isWebSearchMode,
String? error,
bool clearError = false,
@@ -134,7 +223,10 @@ class ChatState {
Duration? elapsed,
Duration? lastResponseDuration,
bool clearLastResponseDuration = false,
List<String>? availableModels,
List<ModelDescriptor>? availableModels,
List<AgentSkillDescriptor>? availableSkills,
String? selectedSkillId,
bool clearSelectedSkill = false,
String? selectedModel,
bool clearSelectedModel = false,
String? apiBaseUrl,
@@ -147,7 +239,23 @@ class ChatState {
: (activeConversationId ?? this.activeConversationId),
isLoadingHistory: isLoadingHistory ?? this.isLoadingHistory,
isSending: isSending ?? this.isSending,
agentProgress:
clearAgentProgress ? null : (agentProgress ?? this.agentProgress),
pendingFileChangeProposal:
clearPendingFileChangeProposal
? null
: (pendingFileChangeProposal ?? this.pendingFileChangeProposal),
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
isDarkMode: isDarkMode ?? this.isDarkMode,
isWorkspaceMode: isWorkspaceMode ?? this.isWorkspaceMode,
selectedWorkspacePath:
clearSelectedWorkspacePath
? null
: (selectedWorkspacePath ?? this.selectedWorkspacePath),
availableWorkspaceFiles:
availableWorkspaceFiles ?? this.availableWorkspaceFiles,
selectedWorkspaceFiles:
selectedWorkspaceFiles ?? this.selectedWorkspaceFiles,
isWebSearchMode: isWebSearchMode ?? this.isWebSearchMode,
error: clearError ? null : (error ?? this.error),
modelName: modelName ?? this.modelName,
@@ -159,6 +267,9 @@ class ChatState {
? null
: (lastResponseDuration ?? this.lastResponseDuration),
availableModels: availableModels ?? this.availableModels,
availableSkills: availableSkills ?? this.availableSkills,
selectedSkillId:
clearSelectedSkill ? null : (selectedSkillId ?? this.selectedSkillId),
selectedModel:
clearSelectedModel ? null : (selectedModel ?? this.selectedModel),
apiBaseUrl: apiBaseUrl ?? this.apiBaseUrl,
File diff suppressed because it is too large Load Diff
+30 -10
View File
@@ -6,10 +6,12 @@ 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/cubit/chat_state.dart';
import 'features/chat/presentation/pages/chat_page.dart';
const _green = Color(0xFF167C68);
const _paper = Color(0xFFF6F5F0);
const _night = Color(0xFF101A18);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -38,15 +40,33 @@ class SovereignApp extends StatelessWidget {
const SovereignApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Mithqal AI',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: _paper,
colorScheme: ColorScheme.fromSeed(seedColor: _green, surface: _paper),
fontFamily: 'Segoe UI',
),
home: const ChatPage(),
Widget build(BuildContext context) => BlocBuilder<ChatCubit, ChatState>(
builder:
(context, state) => MaterialApp(
title: 'Mithqal AI',
debugShowCheckedModeBanner: false,
themeMode: state.isDarkMode ? ThemeMode.dark : ThemeMode.light,
theme: ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: _paper,
colorScheme: ColorScheme.fromSeed(
seedColor: _green,
surface: _paper,
),
fontFamily: 'Segoe UI',
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: _night,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF57C6A4),
brightness: Brightness.dark,
surface: _night,
),
fontFamily: 'Segoe UI',
),
home: const ChatPage(),
),
);
}