diff --git a/lib/providers/user.dart b/lib/providers/user.dart index 218fcc9..70f243a 100644 --- a/lib/providers/user.dart +++ b/lib/providers/user.dart @@ -19,7 +19,18 @@ import 'package:didvan/services/storage/storage.dart'; import 'package:didvan/utils/action_sheet.dart'; class UserProvider extends CoreProvier { - late User user; + /// قبلاً `late User user` بود — اگر صفحه‌ای قبل از کامل شدن + /// `getUserInfo()` به `user` دسترسی می‌گرفت، `LateInitializationError` + /// می‌داد. حالا با یک User تهی initialize می‌شود تا UI placeholder + /// نشان دهد بدون کرش. + User user = const User( + id: 0, + username: null, + phoneNumber: '', + photo: null, + fullName: '', + email: null, + ); bool isAuthenticated = false; int _unreadMessageCount = 0; diff --git a/lib/services/ai/ai_api_service.dart b/lib/services/ai/ai_api_service.dart index 2e2837c..d1f8259 100644 --- a/lib/services/ai/ai_api_service.dart +++ b/lib/services/ai/ai_api_service.dart @@ -10,14 +10,17 @@ import 'package:http_parser/http_parser.dart'; import 'package:mime/mime.dart'; class AiApiService { - static const String baseUrl = 'https://api.didvan.app/ai'; + // backend جدید (api_gateway) — legacy روی api.didvan.app (Liara) برای + // نسخه‌های قدیم اپ زنده می‌ماند. + static const String baseUrl = 'https://api2.didvan.com/ai'; static Future initial( {required final String url, required final String message, final int? chatId, final FilesModel? file, - final bool? edite}) async { + final bool? edite, + final bool? webSearch}) async { final headers = { "Authorization": "Bearer ${await StorageService.getValue(key: 'token')}", 'Content-Type': 'multipart/form-data' @@ -33,6 +36,12 @@ class AiApiService { if (edite != null) { request.fields['edit'] = edite.toString().toLowerCase(); } + // وقتی toggle «جستجو در وب» در UI روشن است، این فلگ به backend + // فرستاده می‌شود تا در request به kie.ai مدل را با tools:[web_search] + // فرا بخواند. + if (webSearch == true) { + request.fields['webSearch'] = 'true'; + } if (file != null) { if (file.duration != null) { diff --git a/lib/services/network/request_helper.dart b/lib/services/network/request_helper.dart index 82273b0..31b543f 100644 --- a/lib/services/network/request_helper.dart +++ b/lib/services/network/request_helper.dart @@ -7,6 +7,12 @@ import 'package:didvan/models/requests/studio.dart'; class RequestHelper { static const String baseUrl = 'https://api.didvan.app'; static const String baseUrl2 = 'https://opportunity-threat.didvan.com'; + + /// Base URL برای backend جدید (api_gateway در ubuntu server). + /// AI endpoints هوشان به این می‌روند — `api.didvan.app` همچنان به Liara + /// (نسخهٔ legacy AI) می‌رود تا نسخهٔ قدیم اپ که در Play هست همچنان + /// کار کند. + static const String newApiBaseUrl = 'https://api2.didvan.com'; static const String storiesV1 = '$baseUrl2/api/v1/stories'; static const String storyActivity = '$baseUrl2/api/v1/stories/activity'; static const String _baseUserUrl = '$baseUrl/user'; @@ -214,34 +220,37 @@ class RequestHelper { static String deleteComment(int id) => '$baseUrl/comment/$id'; static String reportComment(int id) => '$baseUrl/comment/$id/report'; static String widgetNews() => '$baseUrl/user/widget'; - static String aiChats() => '$baseUrl/ai/chat/v2'; - static String aiArchived() => '$baseUrl/ai/chat/v2${_urlConcatGenerator([ + // === AI / هوشان endpoints — به backend جدید (api2.didvan.com) می‌روند === + // نسخهٔ legacy روی api.didvan.app (Liara) هنوز برای اپ‌های قدیم در دسترس + // است؛ اپ جدید فقط با backend جدید کار می‌کند. + static String aiChats() => '$newApiBaseUrl/ai/chat/v2'; + static String aiArchived() => '$newApiBaseUrl/ai/chat/v2${_urlConcatGenerator([ const MapEntry('archived', true), ])}'; - static String aiBots() => '$baseUrl/ai/bot/v2'; + static String aiBots() => '$newApiBaseUrl/ai/bot/v2'; static String aiSearchBots(String q) => - '$baseUrl/ai/bot${_urlConcatGenerator([ + '$newApiBaseUrl/ai/bot${_urlConcatGenerator([ MapEntry('q', q), ])}'; static String aiSearchChats(String q) => - '$baseUrl/ai/chat${_urlConcatGenerator([ + '$newApiBaseUrl/ai/chat${_urlConcatGenerator([ MapEntry('q', q), ])}'; static String aiSearchArchived(String q) => - '$baseUrl/ai/chat${_urlConcatGenerator([ + '$newApiBaseUrl/ai/chat${_urlConcatGenerator([ MapEntry('q', q), const MapEntry('archived', true), ])}'; - static String aiAChat(int id) => '$baseUrl/ai/chat/$id/v2'; - static String aiChatId() => '$baseUrl/ai/chat/id'; - static String aiDeleteChats() => '$baseUrl/ai/chat'; - static String aiChangeChats(int id) => '$baseUrl/ai/chat/$id/title'; - static String deleteChat(int id) => '$baseUrl/ai/chat/$id'; + static String aiAChat(int id) => '$newApiBaseUrl/ai/chat/$id/v2'; + static String aiChatId() => '$newApiBaseUrl/ai/chat/id'; + static String aiDeleteChats() => '$newApiBaseUrl/ai/chat'; + static String aiChangeChats(int id) => '$newApiBaseUrl/ai/chat/$id/title'; + static String deleteChat(int id) => '$newApiBaseUrl/ai/chat/$id'; static String deleteMessage(int chatId, int messageId) => - '$baseUrl/ai/chat/$chatId/message/$messageId'; - static String deleteAllChats() => '$baseUrl/ai/chat/all'; - static String archivedChat(int id) => '$baseUrl/ai/chat/$id/archive'; - static String placeholder(int id) => '$baseUrl/ai/chat/$id/placeholder'; + '$newApiBaseUrl/ai/chat/$chatId/message/$messageId'; + static String deleteAllChats() => '$newApiBaseUrl/ai/chat/all'; + static String archivedChat(int id) => '$newApiBaseUrl/ai/chat/$id/archive'; + static String placeholder(int id) => '$newApiBaseUrl/ai/chat/$id/placeholder'; static String aiSummery() => '$baseUrl/ai/aisummery'; static String aiAudio() => '$baseUrl/ai/101/aiaudio'; static String chartAnalysis() => '$baseUrl/ai/27/chart-analysis'; diff --git a/lib/views/ai/ai_chat_page.dart b/lib/views/ai/ai_chat_page.dart index 303eae7..f20556e 100644 --- a/lib/views/ai/ai_chat_page.dart +++ b/lib/views/ai/ai_chat_page.dart @@ -95,7 +95,8 @@ class _AiChatPageState extends State with TickerProviderStateMixin { prompts: [widget.args.prompts!])); state.message.clear(); state.update(); - state.postMessage(widget.args.bot, widget.args.assistantsName != null); + state.postMessage(widget.args.bot, widget.args.assistantsName != null, + webSearch: _isSearchMode); } } } @@ -298,7 +299,8 @@ class _AiChatPageState extends State with TickerProviderStateMixin { state.message.clear(); state.update(); await state.postMessage( - widget.args.bot, widget.args.assistantsName != null); + widget.args.bot, widget.args.assistantsName != null, + webSearch: _isSearchMode); Future.delayed(const Duration(milliseconds: 100), () { state.scrollController.animateTo( state.scrollController.position.maxScrollExtent, @@ -768,7 +770,9 @@ class _AiChatPageState extends State with TickerProviderStateMixin { _currentBot, widget.args .assistantsName != - null); + null, + webSearch: + _isSearchMode); Future.delayed( const Duration( milliseconds: @@ -892,10 +896,13 @@ class _AiChatPageState extends State with TickerProviderStateMixin { _isSearchMode = isSearchOn; }); }, - showModelSelector: _currentBot.id == 35 && - _geminiBot != null && - _grokBot != null && - _qwenBot != null, + // فقط داشتن Gemini کافی است تا selector فعال شود. + // Grok/Qwen در backend جدید نیستند ولی UI آن‌ها را در + // PopupMenu نگه می‌دارد (انتخاب می‌شوند ولی به همان + // GPT مپ می‌شوند چون bot model در DB انتخاب می‌شود + // نه از UI). + showModelSelector: + _currentBot.id == 35 && _geminiBot != null, selectedModel: _selectedModel, onModelChanged: (AiModel model) { setState(() { diff --git a/lib/views/ai/ai_chat_state.dart b/lib/views/ai/ai_chat_state.dart index be7e32b..bbb9bf4 100644 --- a/lib/views/ai/ai_chat_state.dart +++ b/lib/views/ai/ai_chat_state.dart @@ -162,7 +162,8 @@ class AiChatState extends CoreProvier { update(); } - Future postMessage(BotsModel bot, bool isAssistants) async { + Future postMessage(BotsModel bot, bool isAssistants, + {bool webSearch = false}) async { onResponsing = true; final uploadedFile = file; file = null; @@ -202,7 +203,8 @@ class AiChatState extends CoreProvier { message: message, chatId: chatId, file: uploadedFile, - edite: isEdite); + edite: isEdite, + webSearch: webSearch); file = null; isRecorded = false; update(); @@ -218,16 +220,32 @@ class AiChatState extends CoreProvier { final r = res.listen((value) async { var str = utf8.decode(value); + print( + '[CHAT_STREAM] chunk len=${str.length} hasTrailer=${str.contains('{{{')} preview="${str.substring(0, str.length.clamp(0, 80))}"'); if (!kIsWeb) { - if (bot.responseType != 'text') { - responseMessgae += str.split('{{{').first; - } if (str.contains('{{{')) { - dataMessgae += "{{{${str.split('{{{').last}"; + // اگر text و trailer در یک chunk merge شدند (که در پشت nginx + // معمولاً اتفاق می‌افتد)، باید **هر دو** را بگیریم — قسمت قبل از + // `{{{` متن پاسخ است، قسمت بعد از آن trailer شناسه‌هاست. + final parts = str.split('{{{'); + if (bot.responseType == 'text') { + responseMessgae += parts.first; + } else { + // برای bot های فایل‌محور هم همان منطق قبلی. + responseMessgae += parts.first; + } + dataMessgae += "{{{${parts.last}"; + print( + '[CHAT_STREAM] merged chunk - response.len=${responseMessgae.length} data.len=${dataMessgae.length}'); update(); return; } + // chunk فقط متن دارد (هیچ trailer ندارد) — برای bot های file روی + // مسیر old حرکت می‌کنیم تا با اولین chunk file URL را بگیریم. + if (bot.responseType != 'text') { + responseMessgae += str; + } } if (bot.id == 101) { @@ -269,6 +287,8 @@ class AiChatState extends CoreProvier { }); r.onDone(() async { + print( + '[CHAT_STREAM] onDone - responseMessgae.len=${responseMessgae.length} dataMessgae.len=${dataMessgae.length}'); if (chatId == null) { final service = await getChatId(); navigatorKey.currentContext!.read().getChats(); @@ -279,6 +299,7 @@ class AiChatState extends CoreProvier { } onResponsing = false; if (responseMessgae.isEmpty) { + print('[CHAT_STREAM] responseMessgae empty - showing error'); messages.last.prompts.removeLast(); messages.last.prompts.last = messages.last.prompts.last.copyWith(error: true); diff --git a/lib/views/ai/history_ai_chat_state.dart b/lib/views/ai/history_ai_chat_state.dart index 552c3f4..b3f0cdd 100644 --- a/lib/views/ai/history_ai_chat_state.dart +++ b/lib/views/ai/history_ai_chat_state.dart @@ -76,6 +76,7 @@ class HistoryAiChatState extends CoreProvier { } Future getBots() async { + print('[GET_BOTS] start'); appState = AppState.busy; loadingBots = true; update(); @@ -83,12 +84,16 @@ class HistoryAiChatState extends CoreProvier { RequestHelper.aiBots(), ); await service.httpGet(); + print( + '[GET_BOTS] success=${service.isSuccess} status=${service.statusCode}'); if (service.isSuccess) { bots.clear(); final messages = service.result['bots']; + print('[GET_BOTS] messages type=${messages.runtimeType} length=${messages?.length}'); for (var i = 0; i < messages.length; i++) { bots.add(BotsModel.fromJson(messages[i])); } + print('[GET_BOTS] bots populated, length=${bots.length}'); bot = bots.first; appState = AppState.idle; loadingBots = false; @@ -97,6 +102,8 @@ class HistoryAiChatState extends CoreProvier { return; } + print( + '[GET_BOTS] FAILED — result=${service.result?.toString().substring(0, service.result?.toString().length.clamp(0, 200))}'); appState = AppState.failed; loadingBots = false; update(); diff --git a/lib/views/widgets/hoshan_home_app_bar.dart b/lib/views/widgets/hoshan_home_app_bar.dart index 84bdf01..d9d4454 100644 --- a/lib/views/widgets/hoshan_home_app_bar.dart +++ b/lib/views/widgets/hoshan_home_app_bar.dart @@ -4,6 +4,7 @@ import 'package:didvan/config/design_config.dart'; import 'package:didvan/config/theme_data.dart'; import 'package:didvan/constants/assets.dart'; import 'package:didvan/models/ai/ai_chat_args.dart'; +import 'package:didvan/models/ai/bots_model.dart'; import 'package:didvan/providers/user.dart'; import 'package:didvan/views/ai/history_ai_chat_state.dart'; import 'package:didvan/views/widgets/didvan/text.dart'; @@ -24,6 +25,96 @@ class HoshanHomeAppBar extends StatefulWidget { } class _HoshanHomeAppBarState extends State { + /// مدلی که کاربر برای شروع چت بعدی انتخاب کرده. اگر null باشد، اولین + /// bot لیست (پیش‌فرض = GPT 5.5) استفاده می‌شود. + BotsModel? _selectedBot; + + /// منوی انتخاب مدل را به‌صورت BottomSheet نشان می‌دهد و انتخاب کاربر + /// را در state ذخیره می‌کند. + Future _pickBot(BuildContext context) async { + final historyState = context.read(); + if (historyState.bots.isEmpty) { + await historyState.getBots(); + } + if (historyState.bots.isEmpty || !context.mounted) return; + + final selected = await showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 8), + DidvanText( + 'انتخاب مدل هوش مصنوعی', + fontSize: 16, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.title, + ), + const SizedBox(height: 8), + for (final bot in historyState.bots) + ListTile( + leading: CircleAvatar( + radius: 18, + backgroundColor: Colors.transparent, + backgroundImage: (bot.image ?? '').isNotEmpty + ? NetworkImage(bot.image!) + : null, + child: (bot.image ?? '').isEmpty + ? const Icon(Icons.smart_toy) + : null, + ), + title: DidvanText( + bot.short ?? bot.name ?? 'مدل', + color: Theme.of(context).colorScheme.title, + ), + subtitle: bot.description != null + ? DidvanText( + bot.description!, + fontSize: 12, + color: Theme.of(context).colorScheme.caption, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: (_selectedBot?.id ?? historyState.bots.first.id) == + bot.id + ? const Icon(Icons.check_circle, color: Colors.green) + : null, + onTap: () => Navigator.of(ctx).pop(bot), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + if (selected != null && mounted) { + setState(() => _selectedBot = selected); + } + } + + /// مدل فعلی (انتخاب کاربر یا اولین bot موجود). + BotsModel? _currentBot(HistoryAiChatState state) { + if (_selectedBot != null) return _selectedBot; + if (state.bots.isNotEmpty) return state.bots.first; + return null; + } + // String? _welcomeMessage; // bool _isLoadingWelcome = true; @@ -60,27 +151,27 @@ class _HoshanHomeAppBarState extends State { ); } - void _startNewChat(BuildContext context) { + /// "سوال خود را بپرسید" → ایجاد چت جدید با مدل انتخاب‌شدهٔ کاربر + /// (یا اولین bot لیست به‌عنوان پیش‌فرض). + Future _startNewChat(BuildContext context) async { final historyState = context.read(); - - void navigateToNewChat() { - if (historyState.bots.isNotEmpty) { - Navigator.of(context).pushNamed( - Routes.aiChat, - arguments: AiChatArgs( - bot: historyState.bots.first, - isTool: historyState.bots, - ), - ); - } - } + final navigator = Navigator.of(context); if (historyState.bots.isEmpty) { - historyState.getBots(); - Future.delayed(const Duration(milliseconds: 500), navigateToNewChat); - } else { - navigateToNewChat(); + await historyState.getBots(); } + if (historyState.bots.isEmpty || !context.mounted) return; + + final bot = _selectedBot ?? historyState.bots.first; + try { + navigator.pushNamed( + Routes.aiChat, + arguments: AiChatArgs( + bot: bot, + isTool: historyState.bots, + ), + ); + } catch (_) {} } @override diff --git a/pubspec.yaml b/pubspec.yaml index dff28d6..ee82d0c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 5.2.0+8008 +version: 5.5.0+8008 environment: sdk: ">=3.0.0 <4.0.0"