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; String? get welcomeMessage => _welcomeMessage;
bool get isLoadingWelcome => _isLoadingWelcome; 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) { set unreadMessageCount(int value) {
if (value < 0) { if (value < 0) {
return; return;
@ -54,10 +78,10 @@ class UserProvider extends CoreProvier {
static final List<Map> _itemMarkQueue = []; static final List<Map> _itemMarkQueue = [];
Future<void> fetchWelcomeMessage() async { Future<void> fetchWelcomeMessage() async {
if (!_isLoadingWelcome) { // دیگر _isLoadingWelcome = true نمیگذاریم چون constructor قبلاً
_isLoadingWelcome = true; // یک greeting بدون نام seed کرده. اگر دوباره loading=true بشود،
notifyListeners(); // UI خالی flicker میکند تا fetch بشود. بهجای آن مستقیم نتیجه را
} // refine میکنیم.
if (!AuthConfig.useLegacyApi) { if (!AuthConfig.useLegacyApi) {
final hour = DateTime.now().hour; final hour = DateTime.now().hour;
@ -82,6 +106,11 @@ class UserProvider extends CoreProvier {
return; return;
} }
if (!_isLoadingWelcome) {
_isLoadingWelcome = true;
notifyListeners();
}
try { try {
const String url = 'https://api.didvan.app/ai/aiwellcom'; const String url = 'https://api.didvan.app/ai/aiwellcom';
if (RequestService.token == null) { if (RequestService.token == null) {

View File

@ -103,21 +103,54 @@ class AiApiService {
} }
Future<Stream<List<int>>> getResponse(http.MultipartRequest req) async { Future<Stream<List<int>>> getResponse(http.MultipartRequest req) async {
try { // تا 2 بار retry روی خطاهای transient شبکه (HandshakeException،
final response = await http.Client().send(req); // SocketException، connection closed). request باید برای هر تلاش
if (response.statusCode != 200) { // از نو کلون شود چون MultipartRequest قابل reuse نیست.
final errorBody = await response.stream.bytesToString(); 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( print(
'body: ${req.url} sc: ${response.statusCode}'); 'req ${reqToSend.url} attempt $attempt/3 failed (transient=$isTransient): $e');
print('res: $errorBody'); if (!isTransient || attempt == 3) break;
throw Exception( // backoff قبل از retry
'sc is ${response.statusCode}'); await Future.delayed(Duration(milliseconds: 500 * attempt));
} else {
return response.stream.asBroadcastStream();
} }
} 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 bool _isAudioSessionConfigured = false;
static Future<void> _configureAudioSession() async { static Future<void> _configureAudioSession() async {
if (_isAudioSessionConfigured) return; // همیشه reconfigure میکنیم تا اگر ماژول دیگری session را روی
// voiceCall ست کرده، روی media برگردد. در غیر این صورت صدای TTS از
// earpiece (نه speaker) پخش میشود.
try { try {
final session = await AudioSession.instance; final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration( await session.configure(AudioSessionConfiguration(
// iOS: playback + spokenAudio روتر را به speaker میفرستد.
avAudioSessionCategory: AVAudioSessionCategory.playback, avAudioSessionCategory: AVAudioSessionCategory.playback,
avAudioSessionMode: AVAudioSessionMode.spokenAudio, avAudioSessionMode: AVAudioSessionMode.spokenAudio,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.defaultToSpeaker,
// Android: usage=media + contentType=music routed to MEDIA
// speaker (نه earpiece/call).
androidAudioAttributes: const AndroidAudioAttributes( androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech, contentType: AndroidAudioContentType.music,
flags: AndroidAudioFlags.none, flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.media, usage: AndroidAudioUsage.media,
), ),

View File

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

View File

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

View File

@ -76,6 +76,15 @@ class AiChatState extends CoreProvier {
return service; return service;
} }
/// لیست fallback برای زمانی که backend/Gemini سؤالها را نمیدهد.
/// با همان موضوع backend (صنعت فولاد) همخوانی دارد تا UX یکسان بماند.
static const List<String> _fallbackQuestions = [
'وضعیت بازار فولاد ایران چطور است؟',
'تکنولوژی روز در تولید فولاد چیست؟',
'چالش‌های صادرات فولاد را توضیح بده',
'استارتاپ‌های فعال صنعت فولاد را معرفی کن',
];
Future<void> fetchSuggestedQuestions() async { Future<void> fetchSuggestedQuestions() async {
isLoadingQuestions = true; isLoadingQuestions = true;
suggestedQuestions.clear(); suggestedQuestions.clear();
@ -87,18 +96,27 @@ class AiChatState extends CoreProvier {
await service.post(); await service.post();
List<String> result = const [];
if (service.isSuccess) { if (service.isSuccess) {
final List<dynamic> questionsList = service.data('Questions'); // backend جدید پاسخ را در `result` میگذارد (سازگار با بقیه endpoint
if (questionsList.isNotEmpty) { // های legacy)؛ legacy قدیم آن را در root میگذاشت. هر دو شکل را
suggestedQuestions = questionsList.map((q) => q.toString()).toList(); // ساپورت میکنیم.
} final raw = service.result['Questions'] ?? service.data('Questions');
isLoadingQuestions = false; final List<dynamic> questionsList = (raw is List) ? raw : const [];
update(); // فیلتر کن string های خالی را
result = questionsList
.map((q) => q.toString().trim())
.where((q) => q.isNotEmpty)
.toList();
} else { } else {
print("Error fetching suggested questions: ${service.errorMessage}"); 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 { Future<void> getAllMessages(int chatId) async {
@ -376,18 +394,21 @@ class AiChatState extends CoreProvier {
debugPrint("--- InfoAI Extracted URL: $imageUrl"); debugPrint("--- InfoAI Extracted URL: $imageUrl");
if (imageUrl != null) { 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"; imageUrl = "${RequestHelper.baseUrl}$imageUrl";
} if (!imageUrl.contains('accessToken=')) {
final separator = imageUrl.contains('?') ? '&' : '?';
if (!imageUrl.contains('accessToken=')) { imageUrl =
final separator = imageUrl.contains('?') ? '&' : '?'; "$imageUrl${separator}accessToken=${RequestService.token}";
imageUrl = }
"$imageUrl${separator}accessToken=${RequestService.token}";
} }
} }
debugPrint("--- InfoAI Final URL with Token: $imageUrl"); debugPrint("--- InfoAI Final URL: $imageUrl");
humanMessageId = jsonData['HUMAN_MESSAGE_ID']; humanMessageId = jsonData['HUMAN_MESSAGE_ID'];
aiMessageId = jsonData['AI_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'; 'lib/assets/icons/Google-gemini-icon.svg';
label = 'Gemini'; label = 'Gemini';
break; break;
case AiModel.claude:
icon =
'lib/assets/icons/Claude_AI_symbol.svg.png';
label = 'Claude';
break;
case AiModel.grok: case AiModel.grok:
icon = 'lib/assets/icons/grok.svg'; icon = 'lib/assets/icons/grok.svg';
label = 'Grok'; label = 'Grok';
@ -357,87 +362,82 @@ class _AiMessageBarState extends State<AiMessageBar> {
borderRadius: borderRadius:
BorderRadius.circular(12.0), BorderRadius.circular(12.0),
), ),
// عرض dropdown باز را محدود کنیم تا کوچک
// و جمعوجور به نظر برسد.
constraints: const BoxConstraints(
minWidth: 110,
maxWidth: 130,
),
itemBuilder: (BuildContext context) => [ itemBuilder: (BuildContext context) => [
PopupMenuItem<AiModel>( PopupMenuItem<AiModel>(
value: AiModel.chatGPT, value: AiModel.chatGPT,
height: 36,
padding: const EdgeInsets.symmetric(
horizontal: 10),
child: Row( child: Row(
children: [ children: [
SvgPicture.asset( SvgPicture.asset(
'lib/assets/icons/ChatGPT-Logo.svg', 'lib/assets/icons/ChatGPT-Logo.svg',
height: 17, height: 14,
color: DesignConfig.isDark color: DesignConfig.isDark
? const Color.fromARGB( ? const Color.fromARGB(
255, 195, 195, 196) 255, 195, 195, 196)
: null, : null,
), ),
const SizedBox(width: 12.0), const SizedBox(width: 8.0),
const DidvanText( const DidvanText(
'ChatGPT', 'ChatGPT',
fontSize: 14, fontSize: 12,
), ),
], ],
), ),
), ),
PopupMenuItem<AiModel>( PopupMenuItem<AiModel>(
value: AiModel.gemini, value: AiModel.gemini,
height: 36,
padding: const EdgeInsets.symmetric(
horizontal: 10),
child: Row( child: Row(
children: [ children: [
SvgPicture.asset( SvgPicture.asset(
'lib/assets/icons/Google-gemini-icon.svg', 'lib/assets/icons/Google-gemini-icon.svg',
height: 17, height: 14,
), ),
const SizedBox(width: 12.0), const SizedBox(width: 8.0),
const DidvanText( const DidvanText(
'Gemini', 'Gemini',
fontSize: 14, fontSize: 12,
), ),
], ],
), ),
), ),
PopupMenuItem<AiModel>( PopupMenuItem<AiModel>(
value: AiModel.grok, value: AiModel.claude,
height: 36,
padding: const EdgeInsets.symmetric(
horizontal: 10),
child: Row( child: Row(
children: [ children: [
SvgPicture.asset( Image.asset(
'lib/assets/icons/grok.svg', 'lib/assets/icons/Claude_AI_symbol.svg.png',
color: DesignConfig.isDark height: 14,
? const Color.fromARGB(
255, 195, 195, 196)
: null,
height: 17,
), ),
const SizedBox(width: 12.0), const SizedBox(width: 8.0),
const DidvanText( const DidvanText(
'Grok', 'Claude',
fontSize: 14, fontSize: 12,
),
],
),
),
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,
), ),
], ],
), ),
), ),
// Grok و Qwen در backend جدید
// (kie.ai) قابل دسترسی نیستند، پس
// از منو حذف شدهاند. فقط ChatGPT
// و Gemini باقی میمانند.
], ],
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10.0, vertical: 6.0), horizontal: 6.0, vertical: 4.0),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: borderRadius:
BorderRadius.circular(100.0), BorderRadius.circular(100.0),
@ -445,29 +445,42 @@ class _AiMessageBarState extends State<AiMessageBar> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SvgPicture.asset( // آیکن مدل بزرگتر کاربر میخواست
icon, // در حالت بسته، آیکن واضحتر دیده شود.
color: DesignConfig.isDark SizedBox(
? const Color.fromARGB( width: 20,
255, 195, 195, 196) height: 20,
: null, child: icon
height: 17, .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( DidvanText(
label, label,
fontSize: 14, fontSize: 12,
color: DesignConfig.isDark color: DesignConfig.isDark
? const Color.fromARGB( ? const Color.fromARGB(
255, 195, 195, 196) 255, 195, 195, 196)
: const Color.fromARGB( : const Color.fromARGB(
255, 61, 61, 61), 255, 61, 61, 61),
), ),
const SizedBox(width: 4.0), const SizedBox(width: 2.0),
Center( Center(
child: SvgPicture.asset( child: SvgPicture.asset(
'lib/assets/icons/eva_arrow-down-fill.svg', '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) { onTap: (context) {
final aiState = context.read<AiState>(); final aiState = context.read<AiState>();
aiState.endChat(); aiState.endChat();
if (aiState.tools != null && aiState.tools!.isNotEmpty) { // bot ساخت عکس همیشه id=103 (infoai/Nano Banana 2). دیگر
try { // وابستهٔ `tools![0]` نیستیم حتی اگر backend tools را نفرستد
final tool = aiState.tools![0]; // (یا فرستد ولی bot 103 در index 0 نباشد)، چت ساخت عکس باز
if (tool.bots != null && tool.bots!.isNotEmpty) { // میشود و موقع send درخواست به /103/infoai میرود.
aiState.startChat(AiChatArgs(bot: tool.bots!.first)); 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) { if (infoBot != null) break;
debugPrint('خطا در یافتن ابزار تصویرساز: $e');
} }
} } 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( _AiSectionGridItem(
title: 'ترجمه', title: 'ترجمه',
description: 'ترجمه متون به زبان‌های مختلف', description: 'ترجمه فارسی ↔ انگلیسی',
iconPath: 'lib/assets/icons/Translate2.svg', iconPath: 'lib/assets/icons/Translate2.svg',
onTap: (context) { onTap: (context) {
final aiState = context.read<AiState>(); final aiState = context.read<AiState>();
aiState.endChat(); aiState.endChat();
if (aiState.tools != null && aiState.tools!.length > 2) { // مثل ساخت عکس: اول از tools fetched شده دنبال bot id=104
try { // میگردیم؛ اگر نبود یک hardcoded میسازیم تا navigate همیشه
final tool = aiState.tools![2]; // کار کند حتی قبل از آپدیت backend.
if (tool.bots != null && tool.bots!.isNotEmpty) { BotsModel? translateBot;
aiState.startChat(AiChatArgs(bot: tool.bots!.first)); try {
for (final t in (aiState.tools ?? const [])) {
for (final b in (t.bots ?? const [])) {
if (b.id == 104) {
translateBot = b;
break;
}
} }
} catch (e) { if (translateBot != null) break;
debugPrint('خطا در یافتن ابزار ترجمه: $e');
} }
} } catch (_) {/* fall back to hardcoded */}
translateBot ??= (BotsModel(
id: 104,
name: 'translate',
responseType: 'text',
attachmentType: [],
attachment: 0,
)..short = 'مترجم');
aiState.startChat(AiChatArgs(bot: translateBot));
}, },
), ),
_AiSectionGridItem( _AiSectionGridItem(
@ -139,8 +166,9 @@ class _AiSectionPageState extends State<AiSectionPage>
id: 100, id: 100,
name: 'Aisummery', name: 'Aisummery',
responseType: 'text', responseType: 'text',
attachmentType: ['pdf', 'image', 'audio'], // attach موقتاً غیرفعال فقط متن.
attachment: 1, attachmentType: [],
attachment: 0,
); );
aiState.startChat(AiChatArgs(bot: aisummeryBot)); aiState.startChat(AiChatArgs(bot: aisummeryBot));
@ -275,7 +303,10 @@ class _AiSectionPageState extends State<AiSectionPage>
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith( 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, fontWeight: FontWeight.bold,
fontSize: 13, fontSize: 13,
), ),
@ -298,7 +329,7 @@ class _AiSectionPageState extends State<AiSectionPage>
if (!aiState.isChatting) const HoshanHomeAppBar(), if (!aiState.isChatting) const HoshanHomeAppBar(),
Expanded( Expanded(
child: aiState.isChatting child: aiState.isChatting
? AiChatPage(args: aiState.currentChatArgs!) ? AiChatPage(args: aiState.currentChatArgs!, inline: true)
: SingleChildScrollView( : SingleChildScrollView(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,