chore: establish Sovereign AI reference

This commit is contained in:
Hamza Ayed
2026-09-29 22:38:37 +03:00
commit ce1b22f100
147 changed files with 7394 additions and 0 deletions
@@ -0,0 +1,214 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:uuid/uuid.dart';
import '../../features/chat/presentation/cubit/chat_state.dart';
const _uuid = Uuid();
class ApiRepository {
ApiRepository({required String baseUrl}) : _baseUrl = baseUrl;
String _baseUrl;
String? _cachedUserId;
Future<String>? _loadingUserId;
String get baseUrl => _baseUrl;
String newId() => _uuid.v4();
void setBaseUrl(String value) =>
_baseUrl = value.replaceFirst(RegExp(r'/+$'), '');
Future<String> _userId() async {
final cached = _cachedUserId;
if (cached != null) return cached;
return _loadingUserId ??= _loadLocalUserId();
}
Future<String> _loadLocalUserId() async {
final response = await http.get(Uri.parse('$_baseUrl/v1/local-user'));
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
_cachedUserId = data['user_id'] as String;
return _cachedUserId!;
}
Future<Map<String, String>> _userHeaders() async => {
'Content-Type': 'application/json',
'X-User-ID': await _userId(),
};
void _checkStatus(http.Response response) {
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('API ${response.statusCode}: ${response.body}');
}
}
Future<List<SavedConversation>> listConversations() async {
final response = await http.get(
Uri.parse('$_baseUrl/v1/conversations'),
headers: await _userHeaders(),
);
_checkStatus(response);
return (jsonDecode(response.body) as List<dynamic>)
.map(
(item) =>
SavedConversation.fromSummaryJson(item as Map<String, dynamic>),
)
.toList();
}
Future<SavedConversation> getConversation(String id) async {
final response = await http.get(
Uri.parse('$_baseUrl/v1/conversations/$id'),
headers: await _userHeaders(),
);
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
return SavedConversation(
id: data['id'] as String,
title: data['title'] as String,
updatedAt: DateTime.parse(data['updated_at'] as String).toLocal(),
messages:
(data['messages'] as List<dynamic>).map((item) {
final message = item as Map<String, dynamic>;
return ChatMessage(
message['content'] as String,
fromUser: message['role'] == 'user',
);
}).toList(),
);
}
Future<void> saveConversation(SavedConversation conversation) async {
final response = await http.put(
Uri.parse('$_baseUrl/v1/conversations/${conversation.id}'),
headers: await _userHeaders(),
body: jsonEncode({
'title': conversation.title,
'messages':
conversation.messages
.map(
(message) => {
'role': message.fromUser ? 'user' : 'assistant',
'content': message.text,
},
)
.toList(),
}),
);
_checkStatus(response);
}
Future<void> deleteConversation(String id) async {
final response = await http.delete(
Uri.parse('$_baseUrl/v1/conversations/$id'),
headers: await _userHeaders(),
);
_checkStatus(response);
}
Future<String> complete(List<ChatMessage> messages) async {
final response = await http
.post(
Uri.parse('$_baseUrl/v1/chat/completions'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({
'messages':
messages
.map(
(message) => {
'role': message.fromUser ? 'user' : 'assistant',
'content': message.text,
},
)
.toList(),
}),
)
.timeout(const Duration(minutes: 3));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('API ${response.statusCode}: ${response.body}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final choices = data['choices'] as List<dynamic>;
final message = choices.first['message'] as Map<String, dynamic>;
return message['content'] as String? ?? 'لم يصل نص في الإجابة.';
}
Stream<String> completeStream(List<ChatMessage> messages) async* {
final client = http.Client();
try {
final request =
http.Request('POST', Uri.parse('$_baseUrl/v1/chat/stream'))
..headers.addAll({
'Content-Type': 'application/json',
'Accept': 'application/x-ndjson',
})
..body = jsonEncode({
'messages':
messages
.map(
(message) => {
'role': message.fromUser ? 'user' : 'assistant',
'content': message.text,
},
)
.toList(),
});
final response = await client
.send(request)
.timeout(const Duration(minutes: 3));
if (response.statusCode < 200 || response.statusCode >= 300) {
final body = await response.stream.bytesToString();
throw Exception('API ${response.statusCode}: $body');
}
var completed = false;
await for (final line in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.timeout(const Duration(minutes: 3))) {
if (line.isEmpty) continue;
final event = jsonDecode(line) as Map<String, dynamic>;
if (event['error'] != null) {
throw Exception(event['error']);
}
if (event['done'] == true) {
completed = true;
break;
}
final delta = event['delta'] as String?;
if (delta != null && delta.isNotEmpty) yield delta;
}
if (!completed) throw Exception('انقطع بث الإجابة قبل اكتماله.');
} finally {
client.close();
}
}
Future<String> transcribe(Uint8List wavBytes) async {
final request = http.MultipartRequest(
'POST',
Uri.parse('$_baseUrl/v1/audio/transcriptions'),
)
..files.add(
http.MultipartFile.fromBytes(
'file',
wavBytes,
filename: 'recording.wav',
contentType: MediaType('audio', 'wav'),
),
);
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}',
);
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data['text'] as String? ?? '';
}
}
@@ -0,0 +1,103 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:record/record.dart';
import '../../../../core/network/api_repository.dart';
import 'voice_state.dart';
class VoiceCubit extends Cubit<VoiceState> {
VoiceCubit(this._api, this._recorder) : super(const VoiceState());
final ApiRepository _api;
final AudioRecorder _recorder;
StreamSubscription<Uint8List>? _recordingSubscription;
final List<int> _pcm = [];
Future<void> startRecording() async {
emit(state.copyWith(clearError: true, clearTranscript: true));
try {
if (!await _recorder.hasPermission()) {
throw Exception('لم يتم السماح باستخدام الميكروفون.');
}
_pcm.clear();
final stream = await _recorder.startStream(
const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 16000,
numChannels: 1,
),
);
_recordingSubscription = stream.listen(
_pcm.addAll,
onError: (Object error) {
if (!isClosed) emit(state.copyWith(error: error.toString()));
},
);
emit(state.copyWith(status: VoiceStatus.recording));
} catch (error) {
emit(state.copyWith(status: VoiceStatus.idle, error: error.toString()));
}
}
Future<void> stopAndTranscribe() async {
try {
await _recorder.stop();
await _recordingSubscription?.cancel();
_recordingSubscription = null;
if (_pcm.isEmpty) throw Exception('لم يلتقط التسجيل أي صوت.');
if (_pcm.length > 25 * 1024 * 1024 - 44) {
throw Exception('التسجيل طويل جدًا؛ الحد الحالي 25 ميغابايت.');
}
emit(state.copyWith(status: VoiceStatus.transcribing, clearError: true));
final transcript = await _api.transcribe(
_toWav(Uint8List.fromList(_pcm)),
);
emit(
state.copyWith(
status: VoiceStatus.idle,
transcript: transcript,
clearError: true,
),
);
} catch (error) {
emit(state.copyWith(status: VoiceStatus.idle, error: error.toString()));
} finally {
_pcm.clear();
}
}
Uint8List _toWav(Uint8List pcm) {
final wav = Uint8List(44 + pcm.length);
final view = ByteData.sublistView(wav);
void writeText(int offset, String value) {
for (var i = 0; i < value.length; i++) {
wav[offset + i] = value.codeUnitAt(i);
}
}
writeText(0, 'RIFF');
view.setUint32(4, 36 + pcm.length, Endian.little);
writeText(8, 'WAVE');
writeText(12, 'fmt ');
view.setUint32(16, 16, Endian.little);
view.setUint16(20, 1, Endian.little);
view.setUint16(22, 1, Endian.little);
view.setUint32(24, 16000, Endian.little);
view.setUint32(28, 32000, Endian.little);
view.setUint16(32, 2, Endian.little);
view.setUint16(34, 16, Endian.little);
writeText(36, 'data');
view.setUint32(40, pcm.length, Endian.little);
wav.setRange(44, wav.length, pcm);
return wav;
}
@override
Future<void> close() {
_recordingSubscription?.cancel();
_recorder.dispose();
return super.close();
}
}
@@ -0,0 +1,25 @@
enum VoiceStatus { idle, recording, transcribing }
class VoiceState {
const VoiceState({
this.status = VoiceStatus.idle,
this.transcript,
this.error,
});
final VoiceStatus status;
final String? transcript;
final String? error;
VoiceState copyWith({
VoiceStatus? status,
String? transcript,
String? error,
bool clearTranscript = false,
bool clearError = false,
}) => VoiceState(
status: status ?? this.status,
transcript: clearTranscript ? null : (transcript ?? this.transcript),
error: clearError ? null : (error ?? this.error),
);
}
@@ -0,0 +1,176 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/api_repository.dart';
import 'chat_state.dart';
class ChatCubit extends Cubit<ChatState> {
ChatCubit(this._api) : super(ChatState(apiBaseUrl: _api.baseUrl)) {
_loadHistory();
}
final ApiRepository _api;
Future<void> _loadHistory() async {
try {
final conversations = await _api.listConversations();
if (!isClosed) {
emit(
state.copyWith(
conversations: conversations,
isLoadingHistory: false,
clearError: true,
),
);
}
} catch (error) {
if (!isClosed) {
emit(
state.copyWith(
isLoadingHistory: false,
error: 'تعذر تحميل المحادثات من FastAPI/SQLite: $error',
),
);
}
}
}
Future<void> _saveCurrent(List<ChatMessage> messages) async {
if (messages.isEmpty) return;
final firstQuestion = messages.firstWhere(
(message) => message.fromUser,
orElse: () => const ChatMessage('محادثة جديدة', fromUser: true),
);
final id = state.activeConversationId ?? _api.newId();
final title =
firstQuestion.text.length > 42
? '${firstQuestion.text.substring(0, 42)}…'
: firstQuestion.text;
final item = SavedConversation(
id: id,
title: title,
updatedAt: DateTime.now(),
messages: List.unmodifiable(messages),
);
final conversations = [
item,
...state.conversations.where((conversation) => conversation.id != id),
];
emit(
state.copyWith(
messages: messages,
conversations: conversations,
activeConversationId: id,
),
);
await _api.saveConversation(item);
}
Future<void> send(String text) async {
final prompt = text.trim();
if (prompt.isEmpty || state.isSending || state.isLoadingHistory) return;
final messages = [...state.messages, ChatMessage(prompt, fromUser: true)];
var answer = '';
emit(state.copyWith(messages: messages, isSending: true, clearError: true));
try {
await _saveCurrent(messages);
if (isClosed) return;
emit(
state.copyWith(
messages: [...messages, const ChatMessage('', fromUser: false)],
),
);
await for (final delta in _api.completeStream(messages)) {
if (isClosed) return;
answer += delta;
emit(
state.copyWith(
messages: [...messages, ChatMessage(answer, fromUser: false)],
),
);
}
if (isClosed) return;
if (answer.trim().isEmpty) throw Exception('لم يصل نص في الإجابة.');
final updated = [...messages, ChatMessage(answer, fromUser: false)];
emit(state.copyWith(messages: updated, isSending: false));
await _saveCurrent(updated);
} catch (error) {
if (isClosed) return;
final partial =
answer.isEmpty
? messages
: [...messages, ChatMessage(answer, fromUser: false)];
emit(
state.copyWith(
messages: partial,
isSending: false,
error: error.toString(),
),
);
if (answer.isNotEmpty) {
try {
await _saveCurrent(partial);
} catch (_) {
// The partial answer remains visible even if SQLite is unavailable.
}
}
}
}
Future<void> openConversation(SavedConversation conversation) async {
if (state.isSending) return;
try {
final fullConversation = await _api.getConversation(conversation.id);
emit(
state.copyWith(
messages: fullConversation.messages,
activeConversationId: fullConversation.id,
clearError: true,
),
);
} catch (error) {
emit(state.copyWith(error: error.toString()));
}
}
void newConversation() {
if (state.isSending) return;
emit(
state.copyWith(
messages: const [],
clearActiveConversation: true,
clearError: true,
),
);
}
Future<void> deleteConversation(String id) async {
if (state.isSending) return;
try {
await _api.deleteConversation(id);
final conversations =
state.conversations
.where((conversation) => conversation.id != id)
.toList();
if (state.activeConversationId == id) {
emit(
state.copyWith(
conversations: conversations,
messages: const [],
clearActiveConversation: true,
),
);
} else {
emit(state.copyWith(conversations: conversations));
}
} catch (error) {
emit(state.copyWith(error: error.toString()));
}
}
void setApiBaseUrl(String value) {
final url = value.trim();
if (url.isEmpty) return;
_api.setBaseUrl(url);
emit(state.copyWith(apiBaseUrl: _api.baseUrl, clearError: true));
}
}
@@ -0,0 +1,96 @@
class ChatMessage {
const ChatMessage(this.text, {required this.fromUser});
final String text;
final bool fromUser;
Map<String, dynamic> toJson() => {'text': text, 'fromUser': fromUser};
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
ChatMessage(json['text'] as String, fromUser: json['fromUser'] as bool);
}
class SavedConversation {
const SavedConversation({
required this.id,
required this.title,
required this.updatedAt,
required this.messages,
});
final String id;
final String title;
final DateTime updatedAt;
final List<ChatMessage> messages;
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'updatedAt': updatedAt.toIso8601String(),
'messages': messages.map((message) => message.toJson()).toList(),
};
factory SavedConversation.fromJson(Map<String, dynamic> json) =>
SavedConversation(
id: json['id'] as String,
title: json['title'] as String,
updatedAt: DateTime.parse(json['updatedAt'] as String),
messages:
(json['messages'] as List<dynamic>)
.map(
(item) => ChatMessage.fromJson(item as Map<String, dynamic>),
)
.toList(),
);
factory SavedConversation.fromSummaryJson(Map<String, dynamic> json) =>
SavedConversation(
id: json['id'] as String,
title: json['title'] as String,
updatedAt: DateTime.parse(json['updated_at'] as String).toLocal(),
messages: const [],
);
}
class ChatState {
const ChatState({
this.messages = const [],
this.conversations = const [],
this.activeConversationId,
this.isLoadingHistory = true,
this.isSending = false,
this.error,
required this.apiBaseUrl,
});
final List<ChatMessage> messages;
final List<SavedConversation> conversations;
final String? activeConversationId;
final bool isLoadingHistory;
final bool isSending;
final String? error;
final String apiBaseUrl;
ChatState copyWith({
List<ChatMessage>? messages,
List<SavedConversation>? conversations,
String? activeConversationId,
bool clearActiveConversation = false,
bool? isLoadingHistory,
bool? isSending,
String? error,
bool clearError = false,
String? apiBaseUrl,
}) => ChatState(
messages: messages ?? this.messages,
conversations: conversations ?? this.conversations,
activeConversationId:
clearActiveConversation
? null
: (activeConversationId ?? this.activeConversationId),
isLoadingHistory: isLoadingHistory ?? this.isLoadingHistory,
isSending: isSending ?? this.isSending,
error: clearError ? null : (error ?? this.error),
apiBaseUrl: apiBaseUrl ?? this.apiBaseUrl,
);
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:record/record.dart';
import 'core/network/api_repository.dart';
import 'features/audio/presentation/cubit/voice_cubit.dart';
import 'features/chat/presentation/cubit/chat_cubit.dart';
import 'features/chat/presentation/pages/chat_page.dart';
const _green = Color(0xFF167C68);
const _paper = Color(0xFFF6F5F0);
void main() {
final api = ApiRepository(
baseUrl: const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://127.0.0.1:8000',
),
);
runApp(
MultiRepositoryProvider(
providers: [RepositoryProvider.value(value: api)],
child: MultiBlocProvider(
providers: [
BlocProvider(create: (_) => ChatCubit(api)),
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
],
child: const SovereignApp(),
),
),
);
}
class SovereignApp extends StatelessWidget {
const SovereignApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Sovereign AI',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: _paper,
colorScheme: ColorScheme.fromSeed(seedColor: _green, surface: _paper),
fontFamily: 'Segoe UI',
),
home: const ChatPage(),
);
}