import "dart:convert"; import "package:dio/dio.dart"; import "package:easy_localization/easy_localization.dart"; import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:gap/gap.dart"; import "package:riverpod_annotation/riverpod_annotation.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:island/models/thought.dart"; import "package:island/pods/network.dart"; import "package:island/pods/userinfo.dart"; import "package:island/widgets/alert.dart"; import "package:island/widgets/app_scaffold.dart"; import "package:island/widgets/response.dart"; import "package:island/widgets/thought/thought_sequence_list.dart"; import "package:island/widgets/thought/shared_widgets.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:super_sliver_list/super_sliver_list.dart"; import "package:collection/collection.dart"; part 'think.g.dart'; @riverpod Future> thoughtSequence( Ref ref, String sequenceId, ) async { final apiClient = ref.watch(apiClientProvider); final response = await apiClient.get( '/insight/thought/sequences/$sequenceId', ); return (response.data as List) .map((e) => SnThinkingThought.fromJson(e)) .toList(); } class ThoughtScreen extends HookConsumerWidget { const ThoughtScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final selectedSequenceId = useState(null); final thoughts = selectedSequenceId.value != null ? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!)) : const AsyncValue>.data([]); final localThoughts = useState>([]); final currentTopic = useState('aiThought'.tr()); final messageController = useTextEditingController(); final scrollController = useScrollController(); final isStreaming = useState(false); final streamingText = useState(''); final functionCalls = useState>([]); final reasoningChunks = useState>([]); final listController = useMemoized(() => ListController(), []); // Update local thoughts when provider data changes useEffect(() { thoughts.whenData((data) { // Server returns messages in DESC order (newest first), keep as-is for UI localThoughts.value = data; // Update topic from the first thought's sequence if (data.isNotEmpty && data.first.sequence?.topic != null) { currentTopic.value = data.first.sequence!.topic; } else { currentTopic.value = 'aiThought'.tr(); } }); return null; }, [thoughts]); // Scroll to bottom when thoughts change or streaming state changes useEffect(() { if (localThoughts.value.isNotEmpty || isStreaming.value) { WidgetsBinding.instance.addPostFrameCallback((_) { scrollController.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }); } return null; }, [localThoughts.value.length, isStreaming.value]); void sendMessage() async { if (messageController.text.trim().isEmpty) return; final userMessage = messageController.text.trim(); // Add user message to local thoughts final userInfo = ref.read(userInfoProvider); final now = DateTime.now(); final userThought = SnThinkingThought( id: 'user-${DateTime.now().millisecondsSinceEpoch}', content: userMessage, files: [], role: ThinkingThoughtRole.user, sequenceId: selectedSequenceId.value ?? '', createdAt: now, updatedAt: now, sequence: selectedSequenceId.value != null ? thoughts.value?.firstOrNull?.sequence ?? SnThinkingSequence( id: selectedSequenceId.value!, accountId: '', createdAt: now, updatedAt: now, ) : SnThinkingSequence( id: '', accountId: userInfo.value!.id, createdAt: now, updatedAt: now, ), ); localThoughts.value = [userThought, ...localThoughts.value]; final request = StreamThinkingRequest( userMessage: userMessage, sequenceId: selectedSequenceId.value, accpetProposals: ['post_create'], attachedMessages: [], // Message datas attachedPosts: [], // ID list for posts ); try { isStreaming.value = true; streamingText.value = ''; functionCalls.value = []; reasoningChunks.value = []; final apiClient = ref.read(apiClientProvider); final response = await apiClient.post( '/insight/thought', data: request.toJson(), options: Options( responseType: ResponseType.stream, sendTimeout: Duration(minutes: 1), receiveTimeout: Duration(minutes: 1), ), ); final stream = response.data.stream; final lineBuffer = StringBuffer(); stream.listen( (data) { final chunk = utf8.decode(data); lineBuffer.write(chunk); final lines = lineBuffer.toString().split('\n'); lineBuffer.clear(); lineBuffer.write(lines.last); // keep incomplete line for (final line in lines.sublist(0, lines.length - 1)) { if (line.trim().isEmpty) continue; try { if (line.startsWith('data: ')) { final jsonStr = line.substring(6); final event = jsonDecode(jsonStr); final type = event['type']; final eventData = event['data']; if (type == 'text') { streamingText.value += eventData; } else if (type == 'function_call') { functionCalls.value = [ ...functionCalls.value, JsonEncoder.withIndent(' ').convert(eventData), ]; } else if (type == 'reasoning') { reasoningChunks.value = [ ...reasoningChunks.value, eventData, ]; } } else if (line.startsWith('topic: ')) { final jsonStr = line.substring(7); final event = jsonDecode(jsonStr); currentTopic.value = event['data']; } else if (line.startsWith('thought: ')) { final jsonStr = line.substring(9); final event = jsonDecode(jsonStr); final aiThought = SnThinkingThought.fromJson(event['data']); localThoughts.value = [aiThought, ...localThoughts.value]; if (selectedSequenceId.value == null && aiThought.sequenceId.isNotEmpty) { selectedSequenceId.value = aiThought.sequenceId; } isStreaming.value = false; } } catch (e) { // Ignore parsing errors for individual events } } }, onDone: () { if (isStreaming.value) { isStreaming.value = false; showErrorAlert('thoughtParseError'.tr()); } }, onError: (error) { isStreaming.value = false; if (error is DioException && error.response?.data is ResponseBody) { showErrorAlert('toughtParseError'.tr()); } else { showErrorAlert(error); } }, ); messageController.clear(); FocusManager.instance.primaryFocus?.unfocus(); } catch (error) { isStreaming.value = false; showErrorAlert(error); } } return AppScaffold( isNoBackground: false, appBar: AppBar( title: Text(currentTopic.value ?? 'aiThought'.tr()), actions: [ IconButton( icon: const Icon(Symbols.history), onPressed: () { // Show sequence selector showModalBottomSheet( context: context, builder: (context) => ThoughtSequenceSelector( onSequenceSelected: (sequenceId) { selectedSequenceId.value = sequenceId; }, ), ); }, ), if (localThoughts.value.isNotEmpty && !isStreaming.value && localThoughts.value.last.role == ThinkingThoughtRole.assistant) IconButton( icon: const Icon(Symbols.add), tooltip: 'thoughtNewConversation'.tr(), onPressed: () { // Clear current conversation and start new one selectedSequenceId.value = null; localThoughts.value = []; currentTopic.value = 'aiThought'.tr(); messageController.clear(); }, ), const Gap(8), ], ), body: Center( child: Container( constraints: BoxConstraints(maxWidth: 640), child: Column( children: [ Expanded( child: thoughts.when( data: (thoughtList) => SuperListView.builder( listController: listController, controller: scrollController, padding: const EdgeInsets.only(top: 16, bottom: 16), reverse: true, itemCount: localThoughts.value.length + (isStreaming.value ? 1 : 0), itemBuilder: (context, index) { if (isStreaming.value && index == 0) { return streamingThoughtItem( streamingText.value, reasoningChunks.value, functionCalls.value, context, ); } final thoughtIndex = isStreaming.value ? index - 1 : index; final thought = localThoughts.value[thoughtIndex]; return thoughtItem(thought, thoughtIndex, context); }, ), loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => ResponseErrorWidget( error: error, onRetry: () => selectedSequenceId.value != null ? ref.invalidate( thoughtSequenceProvider( selectedSequenceId.value!, ), ) : null, ), ), ), Container( margin: EdgeInsets.only( left: 16, right: 16, bottom: 16 + MediaQuery.of(context).padding.bottom, ), child: Material( elevation: 2, color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(32), child: Padding( padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 8, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( controller: messageController, keyboardType: TextInputType.multiline, enabled: !isStreaming.value, decoration: InputDecoration( hintText: isStreaming.value ? 'thoughtStreamingHint'.tr() : 'thoughtInputHint'.tr(), border: InputBorder.none, isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 12, ), ), maxLines: 5, minLines: 1, textInputAction: TextInputAction.send, onSubmitted: (_) => sendMessage(), ), ), IconButton( icon: Icon( isStreaming.value ? Symbols.stop : Icons.send, ), color: Theme.of(context).colorScheme.primary, onPressed: sendMessage, ), ], ), ), ), ), ], ), ), ), ); } }