Complete local hybrid search and improve agent reliability
This commit is contained in:
@@ -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);
|
||||
}
|
||||
+367
-22
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+113
-2
@@ -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,
|
||||
|
||||
+832
-144
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <local_notifier/local_notifier_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) local_notifier_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin");
|
||||
local_notifier_plugin_register_with_registrar(local_notifier_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
local_notifier
|
||||
record_linux
|
||||
url_launcher_linux
|
||||
|
||||
@@ -5,16 +5,20 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import file_selector_macos
|
||||
import flutter_local_notifications
|
||||
import local_notifier
|
||||
import path_provider_foundation
|
||||
import record_macos
|
||||
import share_plus
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -645,6 +645,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.1"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.15"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -653,6 +677,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -37,8 +37,10 @@ dependencies:
|
||||
http: ^1.3.0
|
||||
http_parser: ^4.1.2
|
||||
local_notifier: ^0.1.6
|
||||
path_provider: ^2.1.5
|
||||
record: ^6.2.0
|
||||
share_plus: ^10.1.4
|
||||
url_launcher: ^6.3.1
|
||||
uuid: ^4.5.1
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_app/core/network/api_repository.dart';
|
||||
import 'package:flutter_app/core/settings/local_settings_store.dart';
|
||||
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
|
||||
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
|
||||
|
||||
@@ -13,16 +14,81 @@ class _VersionTestApi extends ApiRepository {
|
||||
['الجواب الثاني'],
|
||||
];
|
||||
SavedConversation? lastSaved;
|
||||
String? indexedWorkspace;
|
||||
List<String> indexedFiles = const [];
|
||||
List<String> deletedFiles = const [];
|
||||
|
||||
@override
|
||||
Future<String> getModelName() async => 'gemma4:e2b';
|
||||
|
||||
@override
|
||||
Future<List<String>> getAvailableModels() async => ['gemma4:e2b'];
|
||||
Future<List<ModelDescriptor>> getAvailableModels() async => const [
|
||||
ModelDescriptor(
|
||||
id: 'gemma4:e2b',
|
||||
capabilities: ['completion', 'vision', 'audio', 'tools'],
|
||||
capabilitiesVerified: true,
|
||||
),
|
||||
ModelDescriptor(
|
||||
id: 'granite-embedding:278m',
|
||||
capabilities: ['embedding'],
|
||||
capabilitiesVerified: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<AgentSkillDescriptor>> getAgentSkills() async => const [
|
||||
AgentSkillDescriptor(
|
||||
id: 'code_explain',
|
||||
name: 'شرح الكود',
|
||||
description: 'شرح المصدر.',
|
||||
),
|
||||
AgentSkillDescriptor(
|
||||
id: 'code_review',
|
||||
name: 'مراجعة الكود',
|
||||
description: 'مراجعة المصدر.',
|
||||
),
|
||||
AgentSkillDescriptor(
|
||||
id: 'test_plan',
|
||||
name: 'خطة اختبارات',
|
||||
description: 'اقتراح الاختبارات.',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<SavedConversation>> listConversations() async => [];
|
||||
|
||||
@override
|
||||
Future<List<String>> listWorkspaceFiles(String workspacePath) async => [
|
||||
'README.md',
|
||||
];
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> indexWorkspaceKnowledge(
|
||||
String workspacePath,
|
||||
List<String> files,
|
||||
) async {
|
||||
indexedWorkspace = workspacePath;
|
||||
indexedFiles = files;
|
||||
return {
|
||||
'indexed': [
|
||||
for (final file in files) {'path': file, 'chunks': 2},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> deleteWorkspaceKnowledge(
|
||||
String workspacePath,
|
||||
List<String> files,
|
||||
) async {
|
||||
deletedFiles = files;
|
||||
return {
|
||||
'deleted': [
|
||||
for (final file in files) {'path': file, 'deleted': true},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveConversation(SavedConversation conversation) async {
|
||||
lastSaved = conversation;
|
||||
@@ -65,10 +131,58 @@ class _CancellableTestApi extends _VersionTestApi {
|
||||
}
|
||||
}
|
||||
|
||||
class _MemorySettingsStore extends LocalSettingsStore {
|
||||
final values = <String, Object?>{};
|
||||
|
||||
@override
|
||||
bool get persistenceAvailable => true;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> read() async => Map.of(values);
|
||||
|
||||
@override
|
||||
Future<bool> update(Map<String, Object?> updates) async {
|
||||
values.addAll(updates);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('embedding-only models are not selectable for chat', () async {
|
||||
final api = _VersionTestApi();
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(cubit.state.availableModels.map((model) => model.id), [
|
||||
'gemma4:e2b',
|
||||
]);
|
||||
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('selected workspace files can be indexed as local knowledge', () async {
|
||||
final api = _VersionTestApi();
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await cubit.selectWorkspace(r'C:\Projects\sample');
|
||||
cubit.selectWorkspaceFiles(['README.md']);
|
||||
final message = await cubit.indexSelectedWorkspaceKnowledge();
|
||||
|
||||
expect(api.indexedWorkspace, r'C:\Projects\sample');
|
||||
expect(api.indexedFiles, ['README.md']);
|
||||
expect(message, contains('2 مقطعًا'));
|
||||
expect(cubit.state.agentProgress, isNull);
|
||||
|
||||
final removal = await cubit.removeSelectedWorkspaceKnowledge();
|
||||
expect(api.deletedFiles, ['README.md']);
|
||||
expect(removal, contains('أُزيل 1 ملف'));
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('regeneration keeps prior answers and selection is persisted', () async {
|
||||
final api = _VersionTestApi();
|
||||
final cubit = ChatCubit(api);
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await cubit.send('ما معنى API؟');
|
||||
@@ -97,11 +211,52 @@ void main() {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'local API, model, and notification settings survive Cubit restart',
|
||||
() async {
|
||||
final settings = _MemorySettingsStore();
|
||||
final firstApi = _VersionTestApi();
|
||||
final first = ChatCubit(firstApi, settingsStore: settings);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(await first.setApiBaseUrl('file:///unsafe'), isFalse);
|
||||
expect(firstApi.baseUrl, 'http://127.0.0.1:8000');
|
||||
expect(await first.setApiBaseUrl('http://127.0.0.1:9123'), isTrue);
|
||||
await first.setNotificationsEnabled(false);
|
||||
await first.setDarkMode(true);
|
||||
first.selectModel('gemma4:e2b');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await first.close();
|
||||
|
||||
final restoredApi = _VersionTestApi();
|
||||
final restored = ChatCubit(restoredApi, settingsStore: settings);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(restored.state.apiBaseUrl, 'http://127.0.0.1:9123');
|
||||
expect(restored.state.notificationsEnabled, isFalse);
|
||||
expect(restored.state.isDarkMode, isTrue);
|
||||
expect(restored.state.selectedModel, 'gemma4:e2b');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
restored.selectAgentSkill('code_review');
|
||||
expect(restored.state.selectedSkillId, 'code_review');
|
||||
restored.selectAgentSkill('unknown');
|
||||
expect(restored.state.selectedSkillId, 'code_review');
|
||||
restored.selectAgentSkill(null);
|
||||
expect(restored.state.selectedSkillId, isNull);
|
||||
await restored.close();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancelling generation preserves partial text and records duration',
|
||||
() async {
|
||||
final api = _CancellableTestApi();
|
||||
final cubit = ChatCubit(api);
|
||||
final cubit = ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final send = cubit.send('اكتب فقرة قصيرة');
|
||||
@@ -127,7 +282,10 @@ void main() {
|
||||
'cancelling regeneration before its first token keeps saved answer',
|
||||
() async {
|
||||
final api = _CancellableTestApi();
|
||||
final cubit = ChatCubit(api);
|
||||
final cubit = ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await cubit.openConversation(api.storedConversation);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:record/record.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_app/core/network/api_repository.dart';
|
||||
import 'package:flutter_app/core/settings/local_settings_store.dart';
|
||||
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
|
||||
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
|
||||
import 'package:flutter_app/features/audio/presentation/cubit/voice_cubit.dart';
|
||||
@@ -17,7 +18,37 @@ class _TestApiRepository extends ApiRepository {
|
||||
Future<String> getModelName() async => 'gemma4:e2b';
|
||||
|
||||
@override
|
||||
Future<List<String>> getAvailableModels() async => ['gemma4:e2b'];
|
||||
Future<List<ModelDescriptor>> getAvailableModels() async => const [
|
||||
ModelDescriptor(
|
||||
id: 'gemma4:e2b',
|
||||
capabilities: ['completion', 'vision', 'audio', 'tools'],
|
||||
capabilitiesVerified: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<AgentSkillDescriptor>> getAgentSkills() async => const [
|
||||
AgentSkillDescriptor(
|
||||
id: 'code_explain',
|
||||
name: 'شرح الكود',
|
||||
description: 'شرح المصدر.',
|
||||
),
|
||||
AgentSkillDescriptor(
|
||||
id: 'code_review',
|
||||
name: 'مراجعة الكود',
|
||||
description: 'مراجعة المصدر.',
|
||||
),
|
||||
AgentSkillDescriptor(
|
||||
id: 'test_plan',
|
||||
name: 'خطة اختبارات',
|
||||
description: 'اقتراح الاختبارات.',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<String>> listWorkspaceFiles(String workspacePath) async => [
|
||||
'lib/main.dart',
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<SavedConversation>> listConversations() async => [];
|
||||
@@ -39,6 +70,63 @@ class _CancellableWidgetApi extends _TestApiRepository {
|
||||
}
|
||||
}
|
||||
|
||||
class _MarkdownWidgetApi extends _TestApiRepository {
|
||||
@override
|
||||
Future<void> saveConversation(SavedConversation conversation) async {}
|
||||
|
||||
@override
|
||||
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
|
||||
Stream.value('''| Feature | Status |
|
||||
| --- | --- |
|
||||
| Markdown | **ready** |
|
||||
|
||||
1. Parent item
|
||||
- Nested item
|
||||
- [x] GFM task''');
|
||||
}
|
||||
|
||||
class _ProposalWidgetApi extends _TestApiRepository {
|
||||
int proposalCount = 0;
|
||||
int applyCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> saveConversation(SavedConversation conversation) async {}
|
||||
|
||||
@override
|
||||
Future<List<String>> listWorkspaceFiles(String workspacePath) async => [
|
||||
'src/hello.py',
|
||||
];
|
||||
|
||||
@override
|
||||
Future<AgentRunResult> runAgent(
|
||||
String task, {
|
||||
String? model,
|
||||
String? workspacePath,
|
||||
List<String> workspaceFiles = const [],
|
||||
String? skillId,
|
||||
void Function(String message)? onProgress,
|
||||
}) async {
|
||||
onProgress?.call('يبني معاينة diff دون الكتابة إلى الملف.');
|
||||
proposalCount++;
|
||||
return AgentRunResult(
|
||||
answer: 'حضّرت معاينة، ولم أكتب الملف.',
|
||||
proposal: FileChangeProposal(
|
||||
token: 'proposal-$proposalCount',
|
||||
path: 'src/hello.py',
|
||||
operation: 'create',
|
||||
diff: '--- /dev/null\n+++ b/src/hello.py\n+print("hello")',
|
||||
expiresInSeconds: 600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> applyFileChange(FileChangeProposal proposal) async {
|
||||
applyCount++;
|
||||
return 'أُنشئ الملف ${proposal.path} بعد موافقتك.';
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('chat page fits a compact Windows window', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 600);
|
||||
@@ -47,12 +135,16 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _TestApiRepository();
|
||||
final chatCubit = ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(create: (_) => ChatCubit(api)),
|
||||
BlocProvider.value(value: chatCubit),
|
||||
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
|
||||
],
|
||||
child: const SovereignApp(),
|
||||
@@ -63,6 +155,57 @@ void main() {
|
||||
|
||||
expect(find.text('مساعدك المحلي'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await chatCubit.setDarkMode(true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode,
|
||||
ThemeMode.dark,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
await chatCubit.close();
|
||||
});
|
||||
|
||||
testWidgets('model selector displays capabilities reported by Ollama', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1000, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _TestApiRepository();
|
||||
final chatCubit = ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: chatCubit),
|
||||
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
|
||||
],
|
||||
child: const SovereignApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await chatCubit.selectWorkspace('C:/sample-project');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('اختيار مهارة للوكيل'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('خطة اختبارات'), findsOneWidget);
|
||||
await tester.tap(find.text('مراجعة الكود'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(chatCubit.state.selectedSkillId, 'code_review');
|
||||
await tester.tap(find.byType(DropdownButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('نص · صور · صوت · أدوات'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await chatCubit.close();
|
||||
});
|
||||
|
||||
testWidgets('stop button cancels an active answer', (tester) async {
|
||||
@@ -72,7 +215,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _CancellableWidgetApi();
|
||||
final cubit = ChatCubit(api);
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
@@ -103,4 +246,101 @@ void main() {
|
||||
await cubit.close();
|
||||
await api.controller.close();
|
||||
});
|
||||
|
||||
testWidgets('assistant renders tables and nested GFM task lists', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1000, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _MarkdownWidgetApi();
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create:
|
||||
(_) => ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
),
|
||||
),
|
||||
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
|
||||
],
|
||||
child: const SovereignApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'اختبار Markdown');
|
||||
await tester.tap(find.byTooltip('إرسال'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Table), findsOneWidget);
|
||||
expect(find.text('Feature'), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is RichText &&
|
||||
widget.text.toPlainText().contains('Nested item'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('☑'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('file changes require explicit review before apply', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1100, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _ProposalWidgetApi();
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await cubit.selectWorkspace(r'C:\Projects\sample');
|
||||
cubit.selectWorkspaceFiles(['src/hello.py']);
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<ChatCubit>.value(value: cubit),
|
||||
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
|
||||
],
|
||||
child: const SovereignApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Future<void> requestProposal() async {
|
||||
await tester.enterText(find.byType(TextField), 'أنشئ ملف Python');
|
||||
await tester.tap(find.byTooltip('إرسال'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await requestProposal();
|
||||
expect(find.text('مراجعة إنشاء الملف'), findsOneWidget);
|
||||
expect(find.text('src/hello.py'), findsOneWidget);
|
||||
expect(api.applyCount, 0);
|
||||
|
||||
await tester.tap(find.text('إلغاء'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(api.applyCount, 0);
|
||||
|
||||
await requestProposal();
|
||||
expect(find.text('مراجعة إنشاء الملف'), findsOneWidget);
|
||||
await tester.tap(find.text('إنشاء الملف'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(api.applyCount, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
await cubit.close();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <local_notifier/local_notifier_plugin.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
LocalNotifierPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("LocalNotifierPlugin"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
local_notifier
|
||||
record_windows
|
||||
share_plus
|
||||
|
||||
Reference in New Issue
Block a user