TTS bot 101 with ElevenLabs text-to-dialogue-v3
This commit is contained in:
parent
e30b01a3bb
commit
5d4c0e159c
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
|
|
@ -1 +1 @@
|
|||
enum AiModel { chatGPT, gemini, grok, qwen }
|
||||
enum AiModel { chatGPT, gemini, claude, grok, qwen }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -103,21 +103,54 @@ class AiApiService {
|
|||
}
|
||||
|
||||
Future<Stream<List<int>>> getResponse(http.MultipartRequest req) async {
|
||||
// تا 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(req);
|
||||
final response = await http.Client().send(reqToSend);
|
||||
if (response.statusCode != 200) {
|
||||
final errorBody = await response.stream.bytesToString();
|
||||
print(
|
||||
'body: ${req.url} sc: ${response.statusCode}');
|
||||
print('body: ${reqToSend.url} sc: ${response.statusCode}');
|
||||
print('res: $errorBody');
|
||||
throw Exception(
|
||||
'sc is ${response.statusCode}');
|
||||
throw Exception('sc is ${response.statusCode}');
|
||||
} else {
|
||||
return response.stream.asBroadcastStream();
|
||||
}
|
||||
} catch (e) {
|
||||
print('req ${req.url}: $e');
|
||||
throw Exception('Failed to load data due to an exception.');
|
||||
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(
|
||||
'req ${reqToSend.url} attempt $attempt/3 failed (transient=$isTransient): $e');
|
||||
if (!isTransient || attempt == 3) break;
|
||||
// backoff قبل از retry
|
||||
await Future.delayed(Duration(milliseconds: 500 * attempt));
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
try {
|
||||
if (context.read<HistoryAiChatState>().refresh) {
|
||||
context.read<HistoryAiChatState>().getChats();
|
||||
context.read<HistoryAiChatState>().refresh = false;
|
||||
}
|
||||
} catch (_) {/* ignore */}
|
||||
try {
|
||||
context.read<AiState>().endChat();
|
||||
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('endChat error: $e');
|
||||
}
|
||||
// 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 {
|
||||
try {
|
||||
if (context.read<HistoryAiChatState>().refresh) {
|
||||
context.read<HistoryAiChatState>().getChats();
|
||||
context.read<HistoryAiChatState>().refresh = false;
|
||||
}
|
||||
} catch (_) {/* ignore */}
|
||||
try {
|
||||
context.read<AiState>().endChat();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
if (context.mounted) {
|
||||
} catch (e) {
|
||||
debugPrint('endChat error: $e');
|
||||
}
|
||||
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(() {
|
||||
|
|
@ -1220,11 +1271,16 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
|
|||
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);
|
||||
}
|
||||
|
|
@ -1246,14 +1302,24 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
|
|||
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);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,19 +96,28 @@ 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}");
|
||||
}
|
||||
|
||||
// ⚠️ اگر backend چیزی نداد (یا empty array بود)، fallback ثابت
|
||||
// نشان دهیم تا کاربر «سوالی برای پیشنهاد وجود ندارد» نبیند.
|
||||
suggestedQuestions = result.isNotEmpty ? result : _fallbackQuestions;
|
||||
isLoadingQuestions = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> getAllMessages(int chatId) async {
|
||||
loading = true;
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint("--- InfoAI Final URL with Token: $imageUrl");
|
||||
debugPrint("--- InfoAI Final URL: $imageUrl");
|
||||
|
||||
humanMessageId = jsonData['HUMAN_MESSAGE_ID'];
|
||||
aiMessageId = jsonData['AI_MESSAGE_ID'];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
// آیکن مدل بزرگتر — کاربر میخواست
|
||||
// در حالت بسته، آیکن واضحتر دیده شود.
|
||||
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)
|
||||
? const Color
|
||||
.fromARGB(255,
|
||||
195, 195, 196)
|
||||
: null,
|
||||
height: 17,
|
||||
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,
|
||||
),
|
||||
)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// bot ساخت عکس همیشه id=103 (infoai/Nano Banana 2). دیگر
|
||||
// وابستهٔ `tools![0]` نیستیم — حتی اگر backend tools را نفرستد
|
||||
// (یا فرستد ولی bot 103 در index 0 نباشد)، چت ساخت عکس باز
|
||||
// میشود و موقع send درخواست به /103/infoai میرود.
|
||||
BotsModel? infoBot;
|
||||
try {
|
||||
final tool = aiState.tools![0];
|
||||
if (tool.bots != null && tool.bots!.isNotEmpty) {
|
||||
aiState.startChat(AiChatArgs(bot: tool.bots!.first));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('خطا در یافتن ابزار تصویرساز: $e');
|
||||
for (final t in (aiState.tools ?? const [])) {
|
||||
for (final b in (t.bots ?? const [])) {
|
||||
if (b.id == 103) {
|
||||
infoBot = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
// مثل ساخت عکس: اول از tools fetched شده دنبال bot id=104
|
||||
// میگردیم؛ اگر نبود یک hardcoded میسازیم تا navigate همیشه
|
||||
// کار کند حتی قبل از آپدیت backend.
|
||||
BotsModel? translateBot;
|
||||
try {
|
||||
final tool = aiState.tools![2];
|
||||
if (tool.bots != null && tool.bots!.isNotEmpty) {
|
||||
aiState.startChat(AiChatArgs(bot: tool.bots!.first));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('خطا در یافتن ابزار ترجمه: $e');
|
||||
for (final t in (aiState.tools ?? const [])) {
|
||||
for (final b in (t.bots ?? const [])) {
|
||||
if (b.id == 104) {
|
||||
translateBot = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue