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);
}