Add file analysis and answer feedback
This commit is contained in:
@@ -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,
|
||||
|
||||
+65
-6
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+182
-74
@@ -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 ? 'إيقاف التوليد' : 'إرسال',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user