Add file analysis and answer feedback

This commit is contained in:
Hamza Ayed
2026-10-01 15:13:07 +03:00
parent dc4d5bad0f
commit 3563a104a3
10 changed files with 586 additions and 89 deletions
@@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:file_selector/file_selector.dart';
import 'package:uuid/uuid.dart';
import '../../features/chat/presentation/cubit/chat_state.dart';
@@ -110,6 +111,10 @@ class ApiRepository {
? const []
: (versions.isEmpty ? [content] : versions),
selectedVersion: message['selected_version'] as int? ?? 0,
feedbackVersions:
(message['feedback_versions'] as List<dynamic>? ?? const [])
.map((value) => value as int?)
.toList(),
);
}).toList(),
);
@@ -244,6 +249,61 @@ class ApiRepository {
}
}
Future<String> analyzeFiles(
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/files/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,
int versionIndex,
int rating,
) async {
final response = await http.put(
Uri.parse(
'$_baseUrl/v1/conversations/$conversationId/messages/$messageIndex/feedback',
),
headers: await _userHeaders(),
body: jsonEncode({'version_index': versionIndex, 'rating': rating}),
);
_checkStatus(response);
}
Stream<String> completeStream(
List<ChatMessage> messages, {
String? model,
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_selector/file_selector.dart';
import '../../../../core/network/api_repository.dart';
import 'chat_state.dart';
@@ -162,8 +163,8 @@ class ChatCubit extends Cubit<ChatState> {
await _api.saveConversation(item);
}
Future<void> send(String text) async {
await _sendWithHistory(text, state.messages);
Future<void> send(String text, {List<XFile> attachments = const []}) async {
await _sendWithHistory(text, state.messages, attachments: attachments);
}
Future<void> regenerateLastAnswer() async {
@@ -185,6 +186,7 @@ class ChatCubit extends Cubit<ChatState> {
history,
appendUserMessage: false,
previousAnswers: versions,
previousFeedback: previousAnswer.feedbackVersions,
);
}
@@ -206,6 +208,7 @@ class ChatCubit extends Cubit<ChatState> {
versions[versionIndex],
fromUser: false,
answerVersions: versions,
feedbackVersions: message.feedbackVersions,
selectedVersion: versionIndex,
);
try {
@@ -216,6 +219,43 @@ class ChatCubit extends Cubit<ChatState> {
}
}
Future<void> rateAnswer(int messageIndex, int rating) async {
if (state.isSending || (rating != -1 && rating != 1)) return;
final conversationId = state.activeConversationId;
if (conversationId == null ||
messageIndex < 0 ||
messageIndex >= state.messages.length) {
return;
}
final messages = List<ChatMessage>.from(state.messages);
final message = messages[messageIndex];
if (message.fromUser || message.answerVersions.isEmpty) return;
try {
await _api.rateAnswer(
conversationId,
messageIndex,
message.selectedVersion,
rating,
);
final feedback = List<int?>.generate(
message.answerVersions.length,
(index) => index < message.feedbackVersions.length
? message.feedbackVersions[index]
: null,
)..[message.selectedVersion] = rating;
messages[messageIndex] = ChatMessage(
message.text,
fromUser: false,
answerVersions: message.answerVersions,
feedbackVersions: feedback,
selectedVersion: message.selectedVersion,
);
emit(state.copyWith(messages: messages, 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;
@@ -235,12 +275,21 @@ class ChatCubit extends Cubit<ChatState> {
List<ChatMessage> history, {
bool appendUserMessage = true,
List<String> previousAnswers = const [],
List<int?> previousFeedback = const [],
List<XFile> attachments = const [],
}) async {
final prompt = text.trim();
final prompt =
text.trim().isEmpty && attachments.isNotEmpty
? 'حلّل الملفات المرفقة واشرح وظيفتها.'
: text.trim();
if (prompt.isEmpty || state.isSending || state.isLoadingHistory) return;
final displayPrompt =
attachments.isEmpty
? prompt
: '$prompt\n\n[ملفات مرفقة: ${attachments.map((file) => file.name).join('، ')}]';
final messages =
appendUserMessage
? [...history, ChatMessage(prompt, fromUser: true)]
? [...history, ChatMessage(displayPrompt, fromUser: true)]
: List<ChatMessage>.from(history);
final originalMessages = state.messages;
_messagesBeforeRequest = originalMessages;
@@ -267,9 +316,15 @@ class ChatCubit extends Cubit<ChatState> {
),
);
_startElapsedTimer(generation);
if (state.isWorkspaceMode || state.isWebSearchMode) {
if (attachments.isNotEmpty || state.isWorkspaceMode || state.isWebSearchMode) {
answer =
state.isWorkspaceMode
attachments.isNotEmpty
? await _api.analyzeFiles(
attachments,
prompt.isEmpty ? 'حلّل الملفات المرفقة واشرح وظيفتها.' : prompt,
model: state.selectedModel,
)
: state.isWorkspaceMode
? await _api.askWorkspaceAgent(prompt, model: state.selectedModel)
: await _api.searchWeb(prompt, model: state.selectedModel);
if (isClosed || generation != _requestGeneration) return;
@@ -283,6 +338,7 @@ class ChatCubit extends Cubit<ChatState> {
answer,
fromUser: false,
answerVersions: answerVersions,
feedbackVersions: [...previousFeedback, null],
selectedVersion: answerVersions.length - 1,
),
];
@@ -315,6 +371,7 @@ class ChatCubit extends Cubit<ChatState> {
answer,
fromUser: false,
answerVersions: answerVersions,
feedbackVersions: [...previousFeedback, null],
selectedVersion: answerVersions.length - 1,
),
],
@@ -330,6 +387,7 @@ class ChatCubit extends Cubit<ChatState> {
answer,
fromUser: false,
answerVersions: answerVersions,
feedbackVersions: [...previousFeedback, null],
selectedVersion: answerVersions.length - 1,
),
];
@@ -358,6 +416,7 @@ class ChatCubit extends Cubit<ChatState> {
answer,
fromUser: false,
answerVersions: [...previousAnswers, answer],
feedbackVersions: [...previousFeedback, null],
selectedVersion: previousAnswers.length,
),
];
@@ -7,18 +7,21 @@ class ChatMessage {
this.text, {
required this.fromUser,
this.answerVersions = const [],
this.feedbackVersions = const [],
this.selectedVersion = 0,
});
final String text;
final bool fromUser;
final List<String> answerVersions;
final List<int?> feedbackVersions;
final int selectedVersion;
Map<String, dynamic> toJson() => {
'text': text,
'fromUser': fromUser,
'answerVersions': answerVersions,
'feedbackVersions': feedbackVersions,
'selectedVersion': selectedVersion,
};
@@ -27,6 +30,10 @@ class ChatMessage {
fromUser: json['fromUser'] as bool,
answerVersions:
(json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(),
feedbackVersions:
(json['feedbackVersions'] as List<dynamic>? ?? const [])
.map((value) => value as int?)
.toList(),
selectedVersion: json['selectedVersion'] as int? ?? 0,
);
}
@@ -5,6 +5,7 @@ import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/monokai-sublime.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import 'package:file_selector/file_selector.dart';
import '../../../audio/presentation/cubit/voice_cubit.dart';
import '../../../audio/presentation/cubit/voice_state.dart';
@@ -14,6 +15,11 @@ import '../cubit/chat_state.dart';
const _ink = Color(0xFF152A2A);
const _green = Color(0xFF167C68);
const _codeFileExtensions = [
'py', 'dart', 'js', 'ts', 'tsx', 'jsx', 'html', 'css', 'json', 'yaml', 'yml',
'toml', 'md', 'txt', 'sh', 'ps1', 'sql', 'java', 'kt', 'go', 'rs', 'c', 'h',
'cpp', 'hpp',
];
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@@ -25,6 +31,7 @@ class ChatPage extends StatefulWidget {
class _ChatPageState extends State<ChatPage> {
final _input = TextEditingController();
final _scroll = ScrollController();
final List<XFile> _attachments = [];
bool _notificationsEnabled = false;
@override
@@ -46,12 +53,38 @@ class _ChatPageState extends State<ChatPage> {
void _send([String? suggested]) {
final text = (suggested ?? _input.text).trim();
if (text.isEmpty) return;
context.read<ChatCubit>().send(text);
if (text.isEmpty && _attachments.isEmpty) return;
context.read<ChatCubit>().send(
text,
attachments: List<XFile>.unmodifiable(_attachments),
);
setState(_attachments.clear);
_input.clear();
_jumpToBottom();
}
Future<void> _pickCodeFiles() async {
final selected = await openFiles(
acceptedTypeGroups: const [
XTypeGroup(label: 'ملفات برمجية ونصية', extensions: _codeFileExtensions),
],
);
if (!mounted || selected.isEmpty) return;
final remaining = 3 - _attachments.length;
if (remaining <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('الحد الأقصى 3 ملفات في الرسالة.')),
);
return;
}
setState(() => _attachments.addAll(selected.take(remaining)));
if (selected.length > remaining) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('أُضيفت أول 3 ملفات فقط.')),
);
}
}
void _jumpToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
@@ -751,6 +784,10 @@ class _ChatPageState extends State<ChatPage> {
itemBuilder: (context, index) {
if (index == chat.messages.length) return _typing(chat);
final message = chat.messages[index];
final selectedFeedback =
message.selectedVersion < message.feedbackVersions.length
? message.feedbackVersions[message.selectedVersion]
: null;
return Align(
alignment:
message.fromUser ? Alignment.centerLeft : Alignment.centerRight,
@@ -855,6 +892,34 @@ class _ChatPageState extends State<ChatPage> {
child: Wrap(
spacing: 2,
children: [
IconButton(
tooltip: 'إجابة مفيدة',
visualDensity: VisualDensity.compact,
onPressed: () => context
.read<ChatCubit>()
.rateAnswer(index, 1),
color: selectedFeedback == 1 ? _green : null,
icon: Icon(
selectedFeedback == 1
? Icons.thumb_up_alt_rounded
: Icons.thumb_up_alt_outlined,
size: 18,
),
),
IconButton(
tooltip: 'إجابة غير مفيدة',
visualDensity: VisualDensity.compact,
onPressed: () => context
.read<ChatCubit>()
.rateAnswer(index, -1),
color: selectedFeedback == -1 ? Colors.redAccent : null,
icon: Icon(
selectedFeedback == -1
? Icons.thumb_down_alt_rounded
: Icons.thumb_down_alt_outlined,
size: 18,
),
),
IconButton(
tooltip: 'نسخ الإجابة',
visualDensity: VisualDensity.compact,
@@ -934,96 +999,139 @@ class _ChatPageState extends State<ChatPage> {
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Center(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFE1E5DE)),
boxShadow: const [
BoxShadow(
color: Color(0x0A142A23),
blurRadius: 20,
offset: Offset(0, 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_attachments.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 0),
child: Wrap(
spacing: 6,
runSpacing: 4,
children: [
for (final file in _attachments)
InputChip(
avatar: const Icon(
Icons.description_outlined,
size: 16,
),
label: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: Text(
file.name,
overflow: TextOverflow.ellipsis,
),
),
onDeleted: () => setState(
() => _attachments.remove(file),
),
),
],
),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _input,
minLines: 1,
maxLines: 5,
textDirection: TextDirection.rtl,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
decoration: InputDecoration(
hintText:
chat.isWorkspaceMode
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFE1E5DE)),
boxShadow: const [
BoxShadow(
color: Color(0x0A142A23),
blurRadius: 20,
offset: Offset(0, 6),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _input,
minLines: 1,
maxLines: 5,
textDirection: TextDirection.rtl,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
decoration: InputDecoration(
hintText: _attachments.isNotEmpty
? 'اسأل عن الملفات المرفقة أو اضغط إرسال لتحليلها…'
: chat.isWorkspaceMode
? 'اسأل عن ملف كود أو عن مساحة العمل…'
: chat.isWebSearchMode
? 'ما الموضوع الذي تريد البحث عنه على الإنترنت؟'
: 'اكتب رسالتك هنا…',
hintStyle: TextStyle(color: Color(0xFF9BA59E)),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 18,
vertical: 16,
hintStyle: const TextStyle(
color: Color(0xFF9BA59E),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 16,
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(4),
child: IconButton(
onPressed:
voice.status == VoiceStatus.transcribing
Padding(
padding: const EdgeInsets.all(4),
child: IconButton(
onPressed: chat.isSending ? null : _pickCodeFiles,
tooltip: 'إضافة ملفات كود للتحليل (حتى 3)',
icon: const Icon(Icons.attach_file_rounded),
color: _green,
),
),
Padding(
padding: const EdgeInsets.all(4),
child: IconButton(
onPressed: voice.status == VoiceStatus.transcribing
? null
: () => _toggleRecording(voice),
tooltip:
voice.status == VoiceStatus.recording
tooltip: voice.status == VoiceStatus.recording
? 'إيقاف التسجيل'
: 'تسجيل صوت',
color:
voice.status == VoiceStatus.recording
color: voice.status == VoiceStatus.recording
? Colors.red
: _green,
icon:
voice.status == VoiceStatus.transcribing
icon: voice.status == VoiceStatus.transcribing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
voice.status == VoiceStatus.recording
? Icons.stop_circle_outlined
: Icons.mic_none_rounded,
),
),
),
Padding(
padding: const EdgeInsets.all(7),
child: IconButton.filled(
onPressed:
chat.isSending
? () => context.read<ChatCubit>().cancelGeneration()
voice.status == VoiceStatus.recording
? Icons.stop_circle_outlined
: Icons.mic_none_rounded,
),
),
),
Padding(
padding: const EdgeInsets.all(7),
child: IconButton.filled(
onPressed: chat.isSending
? () => context
.read<ChatCubit>()
.cancelGeneration()
: () => _send(),
style: IconButton.styleFrom(
backgroundColor: chat.isSending ? Colors.red : _green,
foregroundColor: Colors.white,
style: IconButton.styleFrom(
backgroundColor: chat.isSending ? Colors.red : _green,
foregroundColor: Colors.white,
),
icon: Icon(
chat.isSending
? Icons.stop_rounded
: Icons.arrow_upward_rounded,
),
tooltip: chat.isSending ? 'إيقاف التوليد' : 'إرسال',
),
),
icon: Icon(
chat.isSending
? Icons.stop_rounded
: Icons.arrow_upward_rounded,
),
tooltip: chat.isSending ? 'إيقاف التوليد' : 'إرسال',
),
],
),
],
),
),
],
),
),
),
+65 -1
View File
@@ -137,6 +137,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector:
dependency: "direct main"
description:
name: file_selector
sha256: "5f1d15a7f17115038f433d1b0ea57513cc9e29a9d5338d166cb0bef3fa90a7a0"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
file_selector_android:
dependency: transitive
description:
name: file_selector_android
sha256: "1ce58b609289551f8ec07265476720e77d19764339cc1d8e4df3c4d34dac6499"
url: "https://pub.dev"
source: hosted
version: "0.5.1+17"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: fe9f52123af16bba4ad65bd7e03defbbb4b172a38a8e6aaa2a869a0c56a5f5fb
url: "https://pub.dev"
source: hosted
version: "0.5.3+2"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
url: "https://pub.dev"
source: hosted
version: "0.9.3+2"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c"
url: "https://pub.dev"
source: hosted
version: "0.9.4+4"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_web:
dependency: transitive
description:
name: file_selector_web
sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7
url: "https://pub.dev"
source: hosted
version: "0.9.4+2"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
url: "https://pub.dev"
source: hosted
version: "0.9.3+4"
fixnum:
dependency: transitive
description:
@@ -679,4 +743,4 @@ packages:
version: "3.1.4"
sdks:
dart: ">=3.7.2 <4.0.0"
flutter: ">=3.27.0"
flutter: ">=3.29.0"
@@ -30,6 +30,7 @@ environment:
dependencies:
flutter:
sdk: flutter
file_selector: ^1.0.3
flutter_local_notifications: ^18.0.1
flutter_bloc: ^9.1.1
flutter_highlight: ^0.7.0