|
|
|
@@ -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? ?? '';
|
|
|
|
|