TTS bot 101 with ElevenLabs text-to-dialogue-v3

This commit is contained in:
Mohamad Mahdi Jebeli 2026-06-28 14:00:59 +03:30
parent e30b01a3bb
commit 5d4c0e159c
10 changed files with 366 additions and 167 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

View File

@ -1 +1 @@
enum AiModel { chatGPT, gemini, grok, qwen }
enum AiModel { chatGPT, gemini, claude, grok, qwen }

View File

@ -40,6 +40,30 @@ class UserProvider extends CoreProvier {
String? get welcomeMessage => _welcomeMessage;
bool get isLoadingWelcome => _isLoadingWelcome;
UserProvider() {
// greeting فقط به ساعت بستگی دارد، نه به اطلاعات کاربر پس
// سریعترین کار این است که موقع ساخت provider همان لحظه «صبح/ظهر/...
// بخیر 👋» را seed کنیم (بدون نام). وقتی user info اومد،
// fetchWelcomeMessage آن را با نام refine میکند.
_seedWelcomeMessage();
}
void _seedWelcomeMessage() {
final hour = DateTime.now().hour;
String period;
if (hour < 12) {
period = 'صبح';
} else if (hour < 17) {
period = 'ظهر';
} else if (hour < 20) {
period = 'عصر';
} else {
period = 'شب';
}
_welcomeMessage = '$period بخیر 👋';
_isLoadingWelcome = false;
}
set unreadMessageCount(int value) {
if (value < 0) {
return;
@ -54,10 +78,10 @@ class UserProvider extends CoreProvier {
static final List<Map> _itemMarkQueue = [];
Future<void> fetchWelcomeMessage() async {
if (!_isLoadingWelcome) {
_isLoadingWelcome = true;
notifyListeners();
}
// دیگر _isLoadingWelcome = true نمیگذاریم چون constructor قبلاً
// یک greeting بدون نام seed کرده. اگر دوباره loading=true بشود،
// UI خالی flicker میکند تا fetch بشود. بهجای آن مستقیم نتیجه را
// refine میکنیم.
if (!AuthConfig.useLegacyApi) {
final hour = DateTime.now().hour;
@ -82,6 +106,11 @@ class UserProvider extends CoreProvier {
return;
}
if (!_isLoadingWelcome) {
_isLoadingWelcome = true;
notifyListeners();
}
try {
const String url = 'https://api.didvan.app/ai/aiwellcom';
if (RequestService.token == null) {

View File

@ -103,21 +103,54 @@ class AiApiService {
}
Future<Stream<List<int>>> getResponse(http.MultipartRequest req) async {
try {
final response = await http.Client().send(req);
if (response.statusCode != 200) {
final errorBody = await response.stream.bytesToString();
// تا 2 بار retry روی خطاهای transient شبکه (HandshakeException،
// SocketException، connection closed). request باید برای هر تلاش
// از نو کلون شود چون MultipartRequest قابل reuse نیست.
Exception? lastErr;
for (int attempt = 1; attempt <= 3; attempt++) {
// برای attempt های بعدی، یک کپی جدید از request میسازیم.
final reqToSend = attempt == 1 ? req : _cloneRequest(req);
try {
final response = await http.Client().send(reqToSend);
if (response.statusCode != 200) {
final errorBody = await response.stream.bytesToString();
print('body: ${reqToSend.url} sc: ${response.statusCode}');
print('res: $errorBody');
throw Exception('sc is ${response.statusCode}');
} else {
return response.stream.asBroadcastStream();
}
} catch (e) {
lastErr = Exception('$e');
final msg = e.toString().toLowerCase();
final isTransient = msg.contains('handshake') ||
msg.contains('socketexception') ||
msg.contains('connection terminated') ||
msg.contains('connection closed') ||
msg.contains('connection reset');
print(
'body: ${req.url} sc: ${response.statusCode}');
print('res: $errorBody');
throw Exception(
'sc is ${response.statusCode}');
} else {
return response.stream.asBroadcastStream();
'req ${reqToSend.url} attempt $attempt/3 failed (transient=$isTransient): $e');
if (!isTransient || attempt == 3) break;
// backoff قبل از retry
await Future.delayed(Duration(milliseconds: 500 * attempt));
}
} catch (e) {
print('req ${req.url}: $e');
throw Exception('Failed to load data due to an exception.');
}
throw lastErr ??
Exception('Failed to load data due to an exception.');
}
/// MultipartRequest قابل reuse مستقیم نیست (stream یکبار خوانده میشود).
/// برای retry یک کپی تازه با همان fields/files میسازیم.
static http.MultipartRequest _cloneRequest(http.MultipartRequest original) {
final copy = http.MultipartRequest(original.method, original.url);
copy.headers.addAll(original.headers);
copy.fields.addAll(original.fields);
for (final f in original.files) {
// finalize نشده باشد، چون file یکبار قابل خواندن است. ما
// bytes را قبلاً در fromBytes ذخیره کردهایم پس از همان buffer
// استفاده میکنیم.
copy.files.add(f);
}
return copy;
}
}

View File

@ -28,15 +28,21 @@ class VoiceService {
static bool _isAudioSessionConfigured = false;
static Future<void> _configureAudioSession() async {
if (_isAudioSessionConfigured) return;
// همیشه reconfigure میکنیم تا اگر ماژول دیگری session را روی
// voiceCall ست کرده، روی media برگردد. در غیر این صورت صدای TTS از
// earpiece (نه speaker) پخش میشود.
try {
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration(
// iOS: playback + spokenAudio روتر را به speaker میفرستد.
avAudioSessionCategory: AVAudioSessionCategory.playback,
avAudioSessionMode: AVAudioSessionMode.spokenAudio,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.defaultToSpeaker,
// Android: usage=media + contentType=music routed to MEDIA
// speaker (نه earpiece/call).
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
contentType: AndroidAudioContentType.music,
flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.media,
),

View File

@ -303,7 +303,7 @@ class NotificationService {
payload: payloadJson,
);
if (notifType.contains('2')) {
if (notifType.contains('2')) {
await StorageService.setValue(
key: 'notification${AppInitializer.createNotificationId(data)}',
value: data.body);

View File

@ -44,7 +44,14 @@ import 'package:provider/provider.dart';
class AiChatPage extends StatefulWidget {
final AiChatArgs args;
const AiChatPage({Key? key, required this.args}) : super(key: key);
/// اگر این widget بهعنوان child داخل AiSectionPage (تب هوشان) نمایش
/// داده شود، `inline=true` میدهیم در این حالت WillPopScope فقط
/// `endChat()` میکند و `return false` میدهد تا Navigator صفحهٔ
/// والد را pop نکند. در حالت route مستقل (push از hoshan_drawer،
/// history، یا app bar) باید Navigator.pop شود تا کاربر برگردد.
final bool inline;
const AiChatPage({Key? key, required this.args, this.inline = false})
: super(key: key);
@override
_AiChatPageState createState() => _AiChatPageState();
}
@ -54,6 +61,7 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
FocusNode focusNode = FocusNode();
BotsModel? _searchBot;
BotsModel? _geminiBot;
BotsModel? _claudeBot;
BotsModel? _grokBot;
BotsModel? _qwenBot;
bool _isSearchMode = false;
@ -165,6 +173,16 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
responseType: 'text',
);
}
try {
_claudeBot = bots.firstWhere(
(b) => (b.name?.toLowerCase().contains('claude') ?? false));
} catch (e) {
_claudeBot = BotsModel(
id: 37,
name: 'claude',
responseType: 'text',
);
}
try {
_qwenBot = bots.firstWhere((b) =>
b.id == 35 &&
@ -318,6 +336,8 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
switch (_selectedModel) {
case AiModel.gemini:
return _geminiBot ?? _currentBot;
case AiModel.claude:
return _claudeBot ?? _currentBot;
case AiModel.grok:
return _grokBot ?? _currentBot;
case AiModel.qwen:
@ -331,13 +351,21 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
String _getBotTitle() {
final name = _currentBot.name?.toLowerCase() ?? '';
if (name.contains('dall-e') ||
// bot 103/infoai حالا Nano Banana 2 است (نه اینفوگرافی). title و
// subtitle و icon آن هم باید "ساخت عکس" باشد، نه "اینفوگرافی". پس
// این bot مثل بقیه image-gen ها هندل میشود.
if (_currentBot.id == 103 ||
name.contains('dall-e') ||
name.contains('dalle') ||
name.contains('image')) {
name.contains('image') ||
name.contains('infoai') ||
name.contains('nano-banana') ||
name.contains('nanobanana')) {
return 'ساخت عکس';
} else if (name.contains('veo') || name.contains('video')) {
return 'ساخت ویدیو';
} else if (name.contains('translate') ||
} else if (_currentBot.id == 104 ||
name.contains('translate') ||
name.contains('translator') ||
name.contains('ترجمه')) {
return 'ترجمه';
@ -355,24 +383,27 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
name.contains('نمودار') ||
name.contains('تحلیل')) {
return 'تحلیل نمودار';
} else if (name.contains('infoai')) {
return 'اینفوگرافی';
}
return _currentBot.short ?? _currentBot.name ?? 'هوش مصنوعی';
}
String _getBotSubtitle() {
final name = _currentBot.name?.toLowerCase() ?? '';
if (name.contains('dall-e') ||
if (_currentBot.id == 103 ||
name.contains('dall-e') ||
name.contains('dalle') ||
name.contains('image')) {
return 'با استفاده از این بات، می‌توانید تصاویر با کیفیت ایجاد کنید. پس از توصیف ایده‌ خود در قابل چند عبارت، بات، تصویری نزدیک به درخواست شما تولید می‌کند. در صورت نیاز، می‌توانید توضیح خود را اصلاح کرده و دوباره امتحان کنید تا به نتیجه دلخواه برسید.';
name.contains('image') ||
name.contains('infoai') ||
name.contains('nano-banana') ||
name.contains('nanobanana')) {
return 'با استفاده از این بات، می‌توانید تصاویر با کیفیت ایجاد کنید. پس از توصیف ایده‌ خود در قالب چند عبارت، بات، تصویری نزدیک به درخواست شما تولید می‌کند. در صورت نیاز، می‌توانید توضیح خود را اصلاح کرده و دوباره امتحان کنید تا به نتیجه دلخواه برسید.';
} else if (name.contains('veo') || name.contains('video')) {
return 'با استفاده از این بات، می‌توانید ویدیوهای کوتاه و ساده را از ایده‌ها، متن‌ها یا سناریوی موردنظر خود بسازید. کافی است توضیح دهید چه می‌خواهید، تا بات نسخه اولیه ویدیو را تولید کند. ';
} else if (name.contains('translate') ||
} else if (_currentBot.id == 104 ||
name.contains('translate') ||
name.contains('translator') ||
name.contains('ترجمه')) {
return 'این بات به شما کمک می‌کند متن‌های خود را به‌صورت سریع و دقیق بین زبان‌های فارسی و انگلیسی ترجمه کنید. تنها کافی است جمله یا پاراگراف خود را وارد کنید تا ترجمه روان و قابل اتکا دریافت کنید. ';
return 'این بات متن شما را به‌صورت خودکار بین فارسی و انگلیسی ترجمه می‌کند. هر متنی بفرستید، فقط خود ترجمه برمی‌گردد بدون توضیح اضافه.';
} else if (name.contains('summarize') ||
name.contains('aisummery') ||
name.contains('خلاصه')) {
@ -387,21 +418,24 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
name.contains('نمودار') ||
name.contains('تحلیل')) {
return 'این بات امکان تولید انواع نمودارها و ترسیم‌های تحلیلی را برای شما فراهم می‌کند. تنها کافی است داده‌ها و نوع نمودار موردنظر را توضیح دهید تا خروجی تمیز و قابل استفاده دریافت کنید. اگر نتیجه مطابق نیازتان نبود، می‌توانید داده‌ها یا سبک نمودار را اصلاح و دوباره تولید کنید.';
} else if (name.contains('infoai')) {
return 'این بات به شما کمک می‌کند ایده‌ها و داده‌های خود را به‌سرعت و به‌صورت دقیق به اینفوگرافیک تبدیل کنید. کافی است آنچه را کد مد نظر دارید، وارد کنید تا تصویری جذاب دریافت نمایید.';
}
return _currentBot.description ?? '';
}
String? _getBotIcon() {
final name = _currentBot.name?.toLowerCase() ?? '';
if (name.contains('dall-e') ||
if (_currentBot.id == 103 ||
name.contains('dall-e') ||
name.contains('dalle') ||
name.contains('image')) {
name.contains('image') ||
name.contains('infoai') ||
name.contains('nano-banana') ||
name.contains('nanobanana')) {
return 'lib/assets/icons/CrateImage.svg';
} else if (name.contains('veo') || name.contains('video')) {
return 'lib/assets/icons/VideoBot.svg';
} else if (name.contains('translate') ||
} else if (_currentBot.id == 104 ||
name.contains('translate') ||
name.contains('translator') ||
name.contains('ترجمه')) {
return 'lib/assets/icons/TranslateBot.svg';
@ -419,8 +453,6 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
name.contains('نمودار') ||
name.contains('تحلیل')) {
return 'lib/assets/icons/ChartBot.svg';
} else if (name.contains('infoai')) {
return 'lib/assets/icons/Info.svg';
}
return null;
}
@ -429,29 +461,39 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (context.read<HistoryAiChatState>().refresh) {
context.read<HistoryAiChatState>().getChats();
context.read<HistoryAiChatState>().refresh = false;
try {
if (context.read<HistoryAiChatState>().refresh) {
context.read<HistoryAiChatState>().getChats();
context.read<HistoryAiChatState>().refresh = false;
}
} catch (_) {/* ignore */}
try {
context.read<AiState>().endChat();
} catch (e) {
debugPrint('endChat error: $e');
}
context.read<AiState>().endChat();
await Future.delayed(Duration.zero);
return true;
// inline return false: Navigator صفحه را pop نمیکند و
// AiSectionPage بعد از notifyListeners به grid برمیگردد.
// standalone return true: Navigator صفحه را pop میکند تا
// کاربر به صفحهٔ قبلی برود.
return !widget.inline;
},
child: Consumer<AiChatState>(
builder: (context, state, child) => Scaffold(
appBar: HoshanAppBar(
onBack: () async {
if (context.read<HistoryAiChatState>().refresh) {
context.read<HistoryAiChatState>().getChats();
context.read<HistoryAiChatState>().refresh = false;
try {
if (context.read<HistoryAiChatState>().refresh) {
context.read<HistoryAiChatState>().getChats();
context.read<HistoryAiChatState>().refresh = false;
}
} catch (_) {/* ignore */}
try {
context.read<AiState>().endChat();
} catch (e) {
debugPrint('endChat error: $e');
}
context.read<AiState>().endChat();
await Future.delayed(const Duration(milliseconds: 50));
if (context.mounted) {
if (!widget.inline && context.mounted) {
Navigator.pop(context);
}
},
@ -809,9 +851,19 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
),
),
const Padding(
// قبلاً LTR
// (left=110, right=20)
// بود که در RTL
// فارسی به سمت
// راست میچسبید.
// الان متقارن
// برای center.
padding: EdgeInsets
.fromLTRB(110,
5, 20, 5),
.symmetric(
horizontal:
16,
vertical:
5),
child: Divider(
height: 1,
color: Color
@ -896,13 +948,12 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
_isSearchMode = isSearchOn;
});
},
// فقط داشتن Gemini کافی است تا selector فعال شود.
// Grok/Qwen در backend جدید نیستند ولی UI آنها را در
// PopupMenu نگه میدارد (انتخاب میشوند ولی به همان
// GPT مپ میشوند چون bot model در DB انتخاب میشود
// نه از UI).
// همیشه برای bot 35/36 (GPT/Gemini) نشان داده شود تا
// اپ موقع لود اول از کوچک به بزرگ نپرد. fallback های
// PopupMenu خود را مدیریت میکنند اگر bot ها هنوز null
// باشند.
showModelSelector:
_currentBot.id == 35 && _geminiBot != null,
_currentBot.id == 35 || _currentBot.id == 36,
selectedModel: _selectedModel,
onModelChanged: (AiModel model) {
setState(() {
@ -1213,18 +1264,23 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
height: 20,
)),
),
if ((message.file != null ||
(file != null && (file.isAudio() || file.isVideo() || file.isImage()))) &&
if ((message.file != null ||
(file != null && (file.isAudio() || file.isVideo() || file.isImage()))) &&
kIsWeb)
Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () async {
final url = message.file != null
? '${RequestHelper.baseUrl + message.file.toString()}?accessToken=${RequestService.token}'
: file != null && file.isNetwork()
? '${RequestHelper.baseUrl + file.path}?accessToken=${RequestService.token}'
: file?.path ?? '';
// اگر URL کامل (با http) ذخیره شده مثل عکس
// Nano Banana 2 از kie.ai مستقیم استفاده کنیم.
// در غیر این صورت روال قدیمی (baseUrl + token).
final raw = message.file ??
(file != null && file.isNetwork()
? file.path
: (file?.path ?? ''));
final url = raw.startsWith('http')
? raw
: '${RequestHelper.baseUrl + raw}?accessToken=${RequestService.token}';
if (url.isNotEmpty) {
MediaService.downloadFileFromWeb(url);
}
@ -1238,22 +1294,32 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
),
),
),
if ((message.file != null ||
(file != null && (file.isAudio() || file.isVideo() || file.isImage()))) &&
if ((message.file != null ||
(file != null && (file.isAudio() || file.isVideo() || file.isImage()))) &&
!kIsWeb)
Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () async {
debugPrint("Download button tapped");
final url = message.file != null
? '${RequestHelper.baseUrl + message.file.toString()}?accessToken=${RequestService.token}'
: file != null && file.isNetwork()
? '${RequestHelper.baseUrl + file.path}?accessToken=${RequestService.token}'
: file?.path ?? '';
final fileName = message.fileName ??
(file != null ? file.name : null);
if (url.isNotEmpty && fileName != null) {
final raw = message.file ??
(file != null && file.isNetwork()
? file.path
: (file?.path ?? ''));
final url = raw.startsWith('http')
? raw
: '${RequestHelper.baseUrl + raw}?accessToken=${RequestService.token}';
// fileName ممکن است null باشد از URL یا
// timestamp استخراج کنیم تا دکمه دانلود کار کند.
String fileName = message.fileName ??
(file != null ? (file.name ?? '') : '');
if (fileName.isEmpty && url.isNotEmpty) {
final seg = Uri.parse(url).pathSegments;
fileName = seg.isNotEmpty
? seg.last
: 'image_${DateTime.now().millisecondsSinceEpoch}.png';
}
if (url.isNotEmpty && fileName.isNotEmpty) {
await MediaService.downloadFile(url, name: fileName);
}
},

View File

@ -76,6 +76,15 @@ class AiChatState extends CoreProvier {
return service;
}
/// لیست fallback برای زمانی که backend/Gemini سؤالها را نمیدهد.
/// با همان موضوع backend (صنعت فولاد) همخوانی دارد تا UX یکسان بماند.
static const List<String> _fallbackQuestions = [
'وضعیت بازار فولاد ایران چطور است؟',
'تکنولوژی روز در تولید فولاد چیست؟',
'چالش‌های صادرات فولاد را توضیح بده',
'استارتاپ‌های فعال صنعت فولاد را معرفی کن',
];
Future<void> fetchSuggestedQuestions() async {
isLoadingQuestions = true;
suggestedQuestions.clear();
@ -87,18 +96,27 @@ class AiChatState extends CoreProvier {
await service.post();
List<String> result = const [];
if (service.isSuccess) {
final List<dynamic> questionsList = service.data('Questions');
if (questionsList.isNotEmpty) {
suggestedQuestions = questionsList.map((q) => q.toString()).toList();
}
isLoadingQuestions = false;
update();
// backend جدید پاسخ را در `result` میگذارد (سازگار با بقیه endpoint
// های legacy)؛ legacy قدیم آن را در root میگذاشت. هر دو شکل را
// ساپورت میکنیم.
final raw = service.result['Questions'] ?? service.data('Questions');
final List<dynamic> questionsList = (raw is List) ? raw : const [];
// فیلتر کن string های خالی را
result = questionsList
.map((q) => q.toString().trim())
.where((q) => q.isNotEmpty)
.toList();
} else {
print("Error fetching suggested questions: ${service.errorMessage}");
isLoadingQuestions = false;
update();
}
// اگر backend چیزی نداد (یا empty array بود)، fallback ثابت
// نشان دهیم تا کاربر «سوالی برای پیشنهاد وجود ندارد» نبیند.
suggestedQuestions = result.isNotEmpty ? result : _fallbackQuestions;
isLoadingQuestions = false;
update();
}
Future<void> getAllMessages(int chatId) async {
@ -376,18 +394,21 @@ class AiChatState extends CoreProvier {
debugPrint("--- InfoAI Extracted URL: $imageUrl");
if (imageUrl != null) {
if (!imageUrl.startsWith('http')) {
if (imageUrl.startsWith('http')) {
// URL کامل از سرویس خارجی (مثل kie.ai برای Nano Banana 2).
// accessToken دیدوان را به URL خارجی پیوست نکنیم
// باعث میشود image load fail شود و دانلود کار نکند.
} else {
imageUrl = "${RequestHelper.baseUrl}$imageUrl";
}
if (!imageUrl.contains('accessToken=')) {
final separator = imageUrl.contains('?') ? '&' : '?';
imageUrl =
"$imageUrl${separator}accessToken=${RequestService.token}";
if (!imageUrl.contains('accessToken=')) {
final separator = imageUrl.contains('?') ? '&' : '?';
imageUrl =
"$imageUrl${separator}accessToken=${RequestService.token}";
}
}
}
debugPrint("--- InfoAI Final URL with Token: $imageUrl");
debugPrint("--- InfoAI Final URL: $imageUrl");
humanMessageId = jsonData['HUMAN_MESSAGE_ID'];
aiMessageId = jsonData['AI_MESSAGE_ID'];

View File

@ -332,6 +332,11 @@ class _AiMessageBarState extends State<AiMessageBar> {
'lib/assets/icons/Google-gemini-icon.svg';
label = 'Gemini';
break;
case AiModel.claude:
icon =
'lib/assets/icons/Claude_AI_symbol.svg.png';
label = 'Claude';
break;
case AiModel.grok:
icon = 'lib/assets/icons/grok.svg';
label = 'Grok';
@ -357,87 +362,82 @@ class _AiMessageBarState extends State<AiMessageBar> {
borderRadius:
BorderRadius.circular(12.0),
),
// عرض dropdown باز را محدود کنیم تا کوچک
// و جمعوجور به نظر برسد.
constraints: const BoxConstraints(
minWidth: 110,
maxWidth: 130,
),
itemBuilder: (BuildContext context) => [
PopupMenuItem<AiModel>(
value: AiModel.chatGPT,
height: 36,
padding: const EdgeInsets.symmetric(
horizontal: 10),
child: Row(
children: [
SvgPicture.asset(
'lib/assets/icons/ChatGPT-Logo.svg',
height: 17,
height: 14,
color: DesignConfig.isDark
? const Color.fromARGB(
255, 195, 195, 196)
: null,
),
const SizedBox(width: 12.0),
const SizedBox(width: 8.0),
const DidvanText(
'ChatGPT',
fontSize: 14,
fontSize: 12,
),
],
),
),
PopupMenuItem<AiModel>(
value: AiModel.gemini,
height: 36,
padding: const EdgeInsets.symmetric(
horizontal: 10),
child: Row(
children: [
SvgPicture.asset(
'lib/assets/icons/Google-gemini-icon.svg',
height: 17,
height: 14,
),
const SizedBox(width: 12.0),
const SizedBox(width: 8.0),
const DidvanText(
'Gemini',
fontSize: 14,
fontSize: 12,
),
],
),
),
PopupMenuItem<AiModel>(
value: AiModel.grok,
value: AiModel.claude,
height: 36,
padding: const EdgeInsets.symmetric(
horizontal: 10),
child: Row(
children: [
SvgPicture.asset(
'lib/assets/icons/grok.svg',
color: DesignConfig.isDark
? const Color.fromARGB(
255, 195, 195, 196)
: null,
height: 17,
Image.asset(
'lib/assets/icons/Claude_AI_symbol.svg.png',
height: 14,
),
const SizedBox(width: 12.0),
const SizedBox(width: 8.0),
const DidvanText(
'Grok',
fontSize: 14,
),
],
),
),
PopupMenuItem<AiModel>(
value: AiModel.qwen,
child: Row(
children: [
SvgPicture.asset(
'lib/assets/icons/Qwen_logo.svg',
color: DesignConfig.isDark
? const Color.fromARGB(
255, 195, 195, 196)
: null,
height: 17,
),
const SizedBox(width: 12.0),
const DidvanText(
'Qwen',
fontSize: 14,
'Claude',
fontSize: 12,
),
],
),
),
// Grok و Qwen در backend جدید
// (kie.ai) قابل دسترسی نیستند، پس
// از منو حذف شدهاند. فقط ChatGPT
// و Gemini باقی میمانند.
],
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10.0, vertical: 6.0),
horizontal: 6.0, vertical: 4.0),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(100.0),
@ -445,29 +445,42 @@ class _AiMessageBarState extends State<AiMessageBar> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
icon,
color: DesignConfig.isDark
? const Color.fromARGB(
255, 195, 195, 196)
: null,
height: 17,
// آیکن مدل بزرگتر کاربر میخواست
// در حالت بسته، آیکن واضحتر دیده شود.
SizedBox(
width: 20,
height: 20,
child: icon
.toLowerCase()
.endsWith('.png')
? Image.asset(icon,
width: 20, height: 20)
: SvgPicture.asset(
icon,
color: DesignConfig.isDark
? const Color
.fromARGB(255,
195, 195, 196)
: null,
width: 20,
height: 20,
),
),
const SizedBox(width: 8.0),
const SizedBox(width: 4.0),
DidvanText(
label,
fontSize: 14,
fontSize: 12,
color: DesignConfig.isDark
? const Color.fromARGB(
255, 195, 195, 196)
: const Color.fromARGB(
255, 61, 61, 61),
),
const SizedBox(width: 4.0),
const SizedBox(width: 2.0),
Center(
child: SvgPicture.asset(
'lib/assets/icons/eva_arrow-down-fill.svg',
height: 18,
height: 14,
),
)
],

View File

@ -96,35 +96,62 @@ class _AiSectionPageState extends State<AiSectionPage>
onTap: (context) {
final aiState = context.read<AiState>();
aiState.endChat();
if (aiState.tools != null && aiState.tools!.isNotEmpty) {
try {
final tool = aiState.tools![0];
if (tool.bots != null && tool.bots!.isNotEmpty) {
aiState.startChat(AiChatArgs(bot: tool.bots!.first));
// bot ساخت عکس همیشه id=103 (infoai/Nano Banana 2). دیگر
// وابستهٔ `tools![0]` نیستیم حتی اگر backend tools را نفرستد
// (یا فرستد ولی bot 103 در index 0 نباشد)، چت ساخت عکس باز
// میشود و موقع send درخواست به /103/infoai میرود.
BotsModel? infoBot;
try {
for (final t in (aiState.tools ?? const [])) {
for (final b in (t.bots ?? const [])) {
if (b.id == 103) {
infoBot = b;
break;
}
}
} catch (e) {
debugPrint('خطا در یافتن ابزار تصویرساز: $e');
if (infoBot != null) break;
}
}
} catch (_) {/* fall back to hardcoded */}
infoBot ??= (BotsModel(
id: 103,
name: 'infoai',
responseType: 'image',
attachmentType: [],
attachment: 0,
)..short = 'Nano Banana 2');
aiState.startChat(AiChatArgs(bot: infoBot));
},
),
_AiSectionGridItem(
title: 'ترجمه',
description: 'ترجمه متون به زبان‌های مختلف',
description: 'ترجمه فارسی ↔ انگلیسی',
iconPath: 'lib/assets/icons/Translate2.svg',
onTap: (context) {
final aiState = context.read<AiState>();
aiState.endChat();
if (aiState.tools != null && aiState.tools!.length > 2) {
try {
final tool = aiState.tools![2];
if (tool.bots != null && tool.bots!.isNotEmpty) {
aiState.startChat(AiChatArgs(bot: tool.bots!.first));
// مثل ساخت عکس: اول از tools fetched شده دنبال bot id=104
// میگردیم؛ اگر نبود یک hardcoded میسازیم تا navigate همیشه
// کار کند حتی قبل از آپدیت backend.
BotsModel? translateBot;
try {
for (final t in (aiState.tools ?? const [])) {
for (final b in (t.bots ?? const [])) {
if (b.id == 104) {
translateBot = b;
break;
}
}
} catch (e) {
debugPrint('خطا در یافتن ابزار ترجمه: $e');
if (translateBot != null) break;
}
}
} catch (_) {/* fall back to hardcoded */}
translateBot ??= (BotsModel(
id: 104,
name: 'translate',
responseType: 'text',
attachmentType: [],
attachment: 0,
)..short = 'مترجم');
aiState.startChat(AiChatArgs(bot: translateBot));
},
),
_AiSectionGridItem(
@ -139,8 +166,9 @@ class _AiSectionPageState extends State<AiSectionPage>
id: 100,
name: 'Aisummery',
responseType: 'text',
attachmentType: ['pdf', 'image', 'audio'],
attachment: 1,
// attach موقتاً غیرفعال فقط متن.
attachmentType: [],
attachment: 0,
);
aiState.startChat(AiChatArgs(bot: aisummeryBot));
@ -275,7 +303,10 @@ class _AiSectionPageState extends State<AiSectionPage>
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: const Color.fromARGB(255, 27, 60, 89),
// در dark mode رنگ روشن، در light mode رنگ تیرهٔ برند.
color: isDark
? const Color.fromARGB(255, 230, 230, 232)
: const Color.fromARGB(255, 27, 60, 89),
fontWeight: FontWeight.bold,
fontSize: 13,
),
@ -298,7 +329,7 @@ class _AiSectionPageState extends State<AiSectionPage>
if (!aiState.isChatting) const HoshanHomeAppBar(),
Expanded(
child: aiState.isChatting
? AiChatPage(args: aiState.currentChatArgs!)
? AiChatPage(args: aiState.currentChatArgs!, inline: true)
: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,