Persist regenerated answer versions

This commit is contained in:
Hamza Ayed
2026-10-01 12:51:48 +03:00
parent e1f981d29e
commit 7a042d036c
9 changed files with 445 additions and 27 deletions
@@ -91,9 +91,19 @@ class ApiRepository {
messages:
(data['messages'] as List<dynamic>).map((item) {
final message = item as Map<String, dynamic>;
final content = message['content'] as String;
final fromUser = message['role'] == 'user';
final versions =
(message['versions'] as List<dynamic>? ?? const [])
.cast<String>();
return ChatMessage(
message['content'] as String,
fromUser: message['role'] == 'user',
content,
fromUser: fromUser,
answerVersions:
fromUser
? const []
: (versions.isEmpty ? [content] : versions),
selectedVersion: message['selected_version'] as int? ?? 0,
);
}).toList(),
);
@@ -111,6 +121,13 @@ class ApiRepository {
(message) => {
'role': message.fromUser ? 'user' : 'assistant',
'content': message.text,
if (!message.fromUser) ...{
'versions':
message.answerVersions.isEmpty
? [message.text]
: message.answerVersions,
'selected_version': message.selectedVersion,
},
},
)
.toList(),
@@ -120,14 +120,46 @@ class ChatCubit extends Cubit<ChatState> {
!messages[messages.length - 2].fromUser) {
return;
}
final previousAnswer = messages.last;
final versions =
previousAnswer.answerVersions.isEmpty
? [previousAnswer.text]
: previousAnswer.answerVersions;
final history = messages.sublist(0, messages.length - 1);
await _sendWithHistory(
history.last.text,
history,
appendUserMessage: false,
previousAnswers: versions,
);
}
Future<void> selectAnswerVersion(int messageIndex, int versionIndex) async {
if (state.isSending ||
messageIndex < 0 ||
messageIndex >= state.messages.length) {
return;
}
final messages = List<ChatMessage>.from(state.messages);
final message = messages[messageIndex];
final versions = message.answerVersions;
if (message.fromUser || versionIndex < 0 || versionIndex >= versions.length) {
return;
}
messages[messageIndex] = ChatMessage(
versions[versionIndex],
fromUser: false,
answerVersions: versions,
selectedVersion: versionIndex,
);
try {
await _saveCurrent(messages);
emit(state.copyWith(clearError: true));
} catch (error) {
emit(state.copyWith(error: 'تعذر حفظ نسخة الإجابة المختارة: $error'));
}
}
Future<void> editLastQuestion(String text) async {
if (state.isSending || text.trim().isEmpty) return;
final messages = state.messages;
@@ -146,6 +178,7 @@ class ChatCubit extends Cubit<ChatState> {
String text,
List<ChatMessage> history, {
bool appendUserMessage = true,
List<String> previousAnswers = const [],
}) async {
final prompt = text.trim();
if (prompt.isEmpty || state.isSending || state.isLoadingHistory) return;
@@ -173,7 +206,16 @@ class ChatCubit extends Cubit<ChatState> {
if (answer.trim().isEmpty) {
throw Exception('لم يصل نص في إجابة الوكيل.');
}
final updated = [...messages, ChatMessage(answer, fromUser: false)];
final answerVersions = [...previousAnswers, answer];
final updated = [
...messages,
ChatMessage(
answer,
fromUser: false,
answerVersions: answerVersions,
selectedVersion: answerVersions.length - 1,
),
];
emit(state.copyWith(messages: updated, isSending: false));
await _saveCurrent(updated);
return;
@@ -184,15 +226,33 @@ class ChatCubit extends Cubit<ChatState> {
)) {
if (isClosed) return;
answer += delta;
final answerVersions = [...previousAnswers, answer];
emit(
state.copyWith(
messages: [...messages, ChatMessage(answer, fromUser: false)],
messages: [
...messages,
ChatMessage(
answer,
fromUser: false,
answerVersions: answerVersions,
selectedVersion: answerVersions.length - 1,
),
],
),
);
}
if (isClosed) return;
if (answer.trim().isEmpty) throw Exception('لم يصل نص في الإجابة.');
final updated = [...messages, ChatMessage(answer, fromUser: false)];
final answerVersions = [...previousAnswers, answer];
final updated = [
...messages,
ChatMessage(
answer,
fromUser: false,
answerVersions: answerVersions,
selectedVersion: answerVersions.length - 1,
),
];
emit(state.copyWith(messages: updated, isSending: false));
await _saveCurrent(updated);
} catch (error) {
@@ -202,7 +262,15 @@ class ChatCubit extends Cubit<ChatState> {
? originalMessages
: answer.isEmpty
? messages
: [...messages, ChatMessage(answer, fromUser: false)];
: [
...messages,
ChatMessage(
answer,
fromUser: false,
answerVersions: [...previousAnswers, answer],
selectedVersion: previousAnswers.length,
),
];
emit(
state.copyWith(
messages: partial,
@@ -1,13 +1,31 @@
class ChatMessage {
const ChatMessage(this.text, {required this.fromUser});
const ChatMessage(
this.text, {
required this.fromUser,
this.answerVersions = const [],
this.selectedVersion = 0,
});
final String text;
final bool fromUser;
final List<String> answerVersions;
final int selectedVersion;
Map<String, dynamic> toJson() => {'text': text, 'fromUser': fromUser};
Map<String, dynamic> toJson() => {
'text': text,
'fromUser': fromUser,
'answerVersions': answerVersions,
'selectedVersion': selectedVersion,
};
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
ChatMessage(json['text'] as String, fromUser: json['fromUser'] as bool);
ChatMessage(
json['text'] as String,
fromUser: json['fromUser'] as bool,
answerVersions:
(json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(),
selectedVersion: json['selectedVersion'] as int? ?? 0,
);
}
class SavedConversation {
@@ -710,6 +710,52 @@ class _ChatPageState extends State<ChatPage> {
)
else
_AssistantMarkdown(text: message.text),
if (!message.fromUser && message.answerVersions.length > 1)
Align(
alignment: AlignmentDirectional.centerStart,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'نسخة الإجابة السابقة',
visualDensity: VisualDensity.compact,
onPressed:
chat.isSending || message.selectedVersion == 0
? null
: () => context
.read<ChatCubit>()
.selectAnswerVersion(
index,
message.selectedVersion - 1,
),
icon: const Icon(Icons.chevron_right_rounded),
),
Text(
'النسخة ${message.selectedVersion + 1} من ${message.answerVersions.length}',
style: const TextStyle(
color: Color(0xFF718078),
fontSize: 11,
),
),
IconButton(
tooltip: 'نسخة الإجابة التالية',
visualDensity: VisualDensity.compact,
onPressed:
chat.isSending ||
message.selectedVersion >=
message.answerVersions.length - 1
? null
: () => context
.read<ChatCubit>()
.selectAnswerVersion(
index,
message.selectedVersion + 1,
),
icon: const Icon(Icons.chevron_left_rounded),
),
],
),
),
if (!chat.isSending &&
!showTyping &&
index == chat.messages.length - 1 &&
@@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app/core/network/api_repository.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
class _VersionTestApi extends ApiRepository {
_VersionTestApi() : super(baseUrl: 'http://127.0.0.1:8000');
final responses = <List<String>>[
['الجواب الأول'],
['الجواب الثاني'],
];
SavedConversation? lastSaved;
@override
Future<String> getModelName() async => 'gemma4:e2b';
@override
Future<List<String>> getAvailableModels() async => ['gemma4:e2b'];
@override
Future<List<SavedConversation>> listConversations() async => [];
@override
Future<void> saveConversation(SavedConversation conversation) async {
lastSaved = conversation;
}
@override
Stream<String> completeStream(
List<ChatMessage> messages, {
String? model,
}) => Stream<String>.fromIterable(responses.removeAt(0));
}
void main() {
test('regeneration keeps prior answers and selection is persisted', () async {
final api = _VersionTestApi();
final cubit = ChatCubit(api);
await Future<void>.delayed(Duration.zero);
await cubit.send('ما معنى API؟');
expect(cubit.state.messages.last.answerVersions, ['الجواب الأول']);
await cubit.regenerateLastAnswer();
expect(cubit.state.messages.last.text, 'الجواب الثاني');
expect(cubit.state.messages.last.answerVersions, ['الجواب الأول', 'الجواب الثاني']);
expect(cubit.state.messages.last.selectedVersion, 1);
await cubit.selectAnswerVersion(1, 0);
expect(cubit.state.messages.last.text, 'الجواب الأول');
expect(cubit.state.messages.last.selectedVersion, 0);
expect(api.lastSaved?.messages.last.answerVersions, ['الجواب الأول', 'الجواب الثاني']);
expect(api.lastSaved?.messages.last.selectedVersion, 0);
await cubit.close();
});
}