From 62da279c71ece40958c98705d0b301634f8d10bc Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 26 Oct 2025 22:23:17 +0800 Subject: [PATCH] :sparkles: Think based the post --- lib/models/thought.dart | 2 + lib/models/thought.freezed.dart | 62 +- lib/models/thought.g.dart | 10 + lib/screens/posts/post_detail.dart | 11 + lib/screens/thought/think.dart | 2 + lib/screens/thought/think_sheet.dart | 866 +++++++++++++++++++++++++++ 6 files changed, 933 insertions(+), 20 deletions(-) create mode 100644 lib/screens/thought/think_sheet.dart diff --git a/lib/models/thought.dart b/lib/models/thought.dart index c702e044..1af77a14 100644 --- a/lib/models/thought.dart +++ b/lib/models/thought.dart @@ -44,6 +44,8 @@ sealed class StreamThinkingRequest with _$StreamThinkingRequest { required String userMessage, String? sequenceId, @Default([]) List accpetProposals, + List? attachedPosts, + List>? attachedMessages, }) = _StreamThinkingRequest; factory StreamThinkingRequest.fromJson(Map json) => diff --git a/lib/models/thought.freezed.dart b/lib/models/thought.freezed.dart index 2d568fde..f7696552 100644 --- a/lib/models/thought.freezed.dart +++ b/lib/models/thought.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$StreamThinkingRequest { - String get userMessage; String? get sequenceId; List get accpetProposals; + String get userMessage; String? get sequenceId; List get accpetProposals; List? get attachedPosts; List>? get attachedMessages; /// Create a copy of StreamThinkingRequest /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $StreamThinkingRequestCopyWith get copyWith => _$StreamTh @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other.accpetProposals, accpetProposals)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other.accpetProposals, accpetProposals)&&const DeepCollectionEquality().equals(other.attachedPosts, attachedPosts)&&const DeepCollectionEquality().equals(other.attachedMessages, attachedMessages)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(accpetProposals)); +int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(accpetProposals),const DeepCollectionEquality().hash(attachedPosts),const DeepCollectionEquality().hash(attachedMessages)); @override String toString() { - return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals)'; + return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals, attachedPosts: $attachedPosts, attachedMessages: $attachedMessages)'; } @@ -48,7 +48,7 @@ abstract mixin class $StreamThinkingRequestCopyWith<$Res> { factory $StreamThinkingRequestCopyWith(StreamThinkingRequest value, $Res Function(StreamThinkingRequest) _then) = _$StreamThinkingRequestCopyWithImpl; @useResult $Res call({ - String userMessage, String? sequenceId, List accpetProposals + String userMessage, String? sequenceId, List accpetProposals, List? attachedPosts, List>? attachedMessages }); @@ -65,12 +65,14 @@ class _$StreamThinkingRequestCopyWithImpl<$Res> /// Create a copy of StreamThinkingRequest /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,Object? attachedPosts = freezed,Object? attachedMessages = freezed,}) { return _then(_self.copyWith( userMessage: null == userMessage ? _self.userMessage : userMessage // ignore: cast_nullable_to_non_nullable as String,sequenceId: freezed == sequenceId ? _self.sequenceId : sequenceId // ignore: cast_nullable_to_non_nullable as String?,accpetProposals: null == accpetProposals ? _self.accpetProposals : accpetProposals // ignore: cast_nullable_to_non_nullable -as List, +as List,attachedPosts: freezed == attachedPosts ? _self.attachedPosts : attachedPosts // ignore: cast_nullable_to_non_nullable +as List?,attachedMessages: freezed == attachedMessages ? _self.attachedMessages : attachedMessages // ignore: cast_nullable_to_non_nullable +as List>?, )); } @@ -152,10 +154,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String userMessage, String? sequenceId, List accpetProposals)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String userMessage, String? sequenceId, List accpetProposals, List? attachedPosts, List>? attachedMessages)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _StreamThinkingRequest() when $default != null: -return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals);case _: +return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.attachedPosts,_that.attachedMessages);case _: return orElse(); } @@ -173,10 +175,10 @@ return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals);case _ /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String userMessage, String? sequenceId, List accpetProposals) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String userMessage, String? sequenceId, List accpetProposals, List? attachedPosts, List>? attachedMessages) $default,) {final _that = this; switch (_that) { case _StreamThinkingRequest(): -return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals);} +return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.attachedPosts,_that.attachedMessages);} } /// A variant of `when` that fallback to returning `null` /// @@ -190,10 +192,10 @@ return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String userMessage, String? sequenceId, List accpetProposals)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String userMessage, String? sequenceId, List accpetProposals, List? attachedPosts, List>? attachedMessages)? $default,) {final _that = this; switch (_that) { case _StreamThinkingRequest() when $default != null: -return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals);case _: +return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals,_that.attachedPosts,_that.attachedMessages);case _: return null; } @@ -205,7 +207,7 @@ return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals);case _ @JsonSerializable() class _StreamThinkingRequest implements StreamThinkingRequest { - const _StreamThinkingRequest({required this.userMessage, this.sequenceId, final List accpetProposals = const []}): _accpetProposals = accpetProposals; + const _StreamThinkingRequest({required this.userMessage, this.sequenceId, final List accpetProposals = const [], final List? attachedPosts, final List>? attachedMessages}): _accpetProposals = accpetProposals,_attachedPosts = attachedPosts,_attachedMessages = attachedMessages; factory _StreamThinkingRequest.fromJson(Map json) => _$StreamThinkingRequestFromJson(json); @override final String userMessage; @@ -217,6 +219,24 @@ class _StreamThinkingRequest implements StreamThinkingRequest { return EqualUnmodifiableListView(_accpetProposals); } + final List? _attachedPosts; +@override List? get attachedPosts { + final value = _attachedPosts; + if (value == null) return null; + if (_attachedPosts is EqualUnmodifiableListView) return _attachedPosts; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + + final List>? _attachedMessages; +@override List>? get attachedMessages { + final value = _attachedMessages; + if (value == null) return null; + if (_attachedMessages is EqualUnmodifiableListView) return _attachedMessages; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + /// Create a copy of StreamThinkingRequest /// with the given fields replaced by the non-null parameter values. @@ -231,16 +251,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other._accpetProposals, _accpetProposals)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other._accpetProposals, _accpetProposals)&&const DeepCollectionEquality().equals(other._attachedPosts, _attachedPosts)&&const DeepCollectionEquality().equals(other._attachedMessages, _attachedMessages)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(_accpetProposals)); +int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(_accpetProposals),const DeepCollectionEquality().hash(_attachedPosts),const DeepCollectionEquality().hash(_attachedMessages)); @override String toString() { - return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals)'; + return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals, attachedPosts: $attachedPosts, attachedMessages: $attachedMessages)'; } @@ -251,7 +271,7 @@ abstract mixin class _$StreamThinkingRequestCopyWith<$Res> implements $StreamThi factory _$StreamThinkingRequestCopyWith(_StreamThinkingRequest value, $Res Function(_StreamThinkingRequest) _then) = __$StreamThinkingRequestCopyWithImpl; @override @useResult $Res call({ - String userMessage, String? sequenceId, List accpetProposals + String userMessage, String? sequenceId, List accpetProposals, List? attachedPosts, List>? attachedMessages }); @@ -268,12 +288,14 @@ class __$StreamThinkingRequestCopyWithImpl<$Res> /// Create a copy of StreamThinkingRequest /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,Object? attachedPosts = freezed,Object? attachedMessages = freezed,}) { return _then(_StreamThinkingRequest( userMessage: null == userMessage ? _self.userMessage : userMessage // ignore: cast_nullable_to_non_nullable as String,sequenceId: freezed == sequenceId ? _self.sequenceId : sequenceId // ignore: cast_nullable_to_non_nullable as String?,accpetProposals: null == accpetProposals ? _self._accpetProposals : accpetProposals // ignore: cast_nullable_to_non_nullable -as List, +as List,attachedPosts: freezed == attachedPosts ? _self._attachedPosts : attachedPosts // ignore: cast_nullable_to_non_nullable +as List?,attachedMessages: freezed == attachedMessages ? _self._attachedMessages : attachedMessages // ignore: cast_nullable_to_non_nullable +as List>?, )); } diff --git a/lib/models/thought.g.dart b/lib/models/thought.g.dart index 5dce045d..4ac2ae65 100644 --- a/lib/models/thought.g.dart +++ b/lib/models/thought.g.dart @@ -16,6 +16,14 @@ _StreamThinkingRequest _$StreamThinkingRequestFromJson( ?.map((e) => e as String) .toList() ?? const [], + attachedPosts: + (json['attached_posts'] as List?) + ?.map((e) => e as String) + .toList(), + attachedMessages: + (json['attached_messages'] as List?) + ?.map((e) => e as Map) + .toList(), ); Map _$StreamThinkingRequestToJson( @@ -24,6 +32,8 @@ Map _$StreamThinkingRequestToJson( 'user_message': instance.userMessage, 'sequence_id': instance.sequenceId, 'accpet_proposals': instance.accpetProposals, + 'attached_posts': instance.attachedPosts, + 'attached_messages': instance.attachedMessages, }; _SnThinkingChunk _$SnThinkingChunkFromJson(Map json) => diff --git a/lib/screens/posts/post_detail.dart b/lib/screens/posts/post_detail.dart index 33bb8bea..4e7870e3 100644 --- a/lib/screens/posts/post_detail.dart +++ b/lib/screens/posts/post_detail.dart @@ -22,6 +22,7 @@ import 'package:island/widgets/response.dart'; import 'package:island/utils/share_utils.dart'; import 'package:island/widgets/safety/abuse_report_helper.dart'; import 'package:island/widgets/share/share_sheet.dart'; +import 'package:island/screens/thought/think_sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -297,6 +298,16 @@ class PostActionButtons extends HookConsumerWidget { ), ); + actions.add( + FilledButton.tonalIcon( + onPressed: () { + ThoughtSheet.show(context, attachedPosts: [post.id]); + }, + icon: const Icon(Symbols.smart_toy), + label: Text('aiThought'.tr()), + ), + ); + actions.add( Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/thought/think.dart b/lib/screens/thought/think.dart index e8b7156b..2e257e6d 100644 --- a/lib/screens/thought/think.dart +++ b/lib/screens/thought/think.dart @@ -164,6 +164,8 @@ class ThoughtScreen extends HookConsumerWidget { userMessage: userMessage, sequenceId: selectedSequenceId.value, accpetProposals: ['post_create'], + attachedMessages: [], // Message datas + attachedPosts: [], // ID list for posts ); try { diff --git a/lib/screens/thought/think_sheet.dart b/lib/screens/thought/think_sheet.dart new file mode 100644 index 00000000..88c735ea --- /dev/null +++ b/lib/screens/thought/think_sheet.dart @@ -0,0 +1,866 @@ +import "dart:convert"; +import "package:dio/dio.dart"; +import "package:easy_localization/easy_localization.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:gap/gap.dart"; +import "package:google_fonts/google_fonts.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/services/time.dart"; +import "package:island/widgets/alert.dart"; +import "package:island/widgets/content/markdown.dart"; +import "package:island/widgets/content/sheet.dart"; +import "package:island/widgets/post/compose_dialog.dart"; +import "package:island/screens/posts/compose.dart"; +import "package:material_symbols_icons/material_symbols_icons.dart"; +import "package:styled_widget/styled_widget.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; +import "package:markdown/markdown.dart" as markdown; +import "package:markdown_widget/markdown_widget.dart"; + +class ThoughtSheet extends HookConsumerWidget { + final List> attachedMessages; + final List attachedPosts; + + const ThoughtSheet({ + super.key, + this.attachedMessages = const [], + this.attachedPosts = const [], + }); + + static Future show( + BuildContext context, { + List> attachedMessages = const [], + List attachedPosts = const [], + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: + (context) => ThoughtSheet( + attachedMessages: attachedMessages, + attachedPosts: attachedPosts, + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Extract proposals from text content + List> extractProposals(String content) { + final proposalRegex = RegExp( + r'(.*?)<\/proposal>', + dotAll: true, + ); + final matches = proposalRegex.allMatches(content); + return matches.map((match) { + return {'type': match.group(1)!, 'content': match.group(2)!}; + }).toList(); + } + + void handleProposalAction( + BuildContext context, + Map proposal, + ) { + switch (proposal['type']) { + case 'post_create': + // Show post creation dialog with the proposal content + PostComposeDialog.show( + context, + initialState: PostComposeInitialState( + content: (proposal['content'] ?? '').trim(), + ), + ); + break; + default: + // Show a snackbar for unsupported proposal types + showSnackBar('Unsupported proposal type: ${proposal['type']}'); + } + } + + 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(), []); + + // 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: '', + createdAt: now, + updatedAt: now, + sequence: SnThinkingSequence( + id: '', + accountId: userInfo.value!.id, + createdAt: now, + updatedAt: now, + ), + ); + localThoughts.value = [userThought, ...localThoughts.value]; + + final request = StreamThinkingRequest( + userMessage: userMessage, + sequenceId: null, + accpetProposals: ['post_create'], + attachedMessages: attachedMessages, + attachedPosts: attachedPosts, + ); + + 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]; + 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); + } + } + + Widget buildChunkTiles(List chunks) { + return Column( + children: [ + ...chunks + .where((chunk) => chunk.type == ThinkingChunkType.reasoning) + .map( + (chunk) => Card( + margin: const EdgeInsets.only(bottom: 8), + child: Theme( + data: Theme.of( + context, + ).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + title: Text( + 'Reasoning', + style: Theme.of(context).textTheme.titleSmall, + ), + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + chunk.data?['content'] ?? '', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ), + ), + ...chunks + .where((chunk) => chunk.type == ThinkingChunkType.functionCall) + .map( + (chunk) => Card( + margin: const EdgeInsets.only(bottom: 8), + child: Theme( + data: Theme.of( + context, + ).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + visualDensity: VisualDensity.compact, + title: Text( + 'Function Call', + style: Theme.of(context).textTheme.titleSmall, + ), + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: SelectableText( + JsonEncoder.withIndent(' ').convert(chunk.data), + style: GoogleFonts.robotoMono(), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget thoughtItem(SnThinkingThought thought, int index) { + final key = Key('thought-${thought.id}'); + + // Extract proposals from thought content + final proposals = + thought.content != null ? extractProposals(thought.content!) : []; + + final thoughtWidget = Container( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + thought.role == ThinkingThoughtRole.assistant + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + thought.role == ThinkingThoughtRole.assistant + ? Symbols.smart_toy + : Symbols.person, + size: 20, + ), + const Gap(8), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.ideographic, + spacing: 8, + children: [ + Text( + thought.role == ThinkingThoughtRole.assistant + ? 'thoughtAiName'.tr() + : 'thoughtUserName'.tr(), + style: Theme.of(context).textTheme.titleSmall, + ), + Tooltip( + message: thought.createdAt.formatSystem(), + child: Text( + thought.createdAt.formatRelative(context), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + if (thought.role == ThinkingThoughtRole.assistant) + SizedBox( + height: 20, + width: 20, + child: IconButton( + visualDensity: VisualDensity( + horizontal: -4, + vertical: -4, + ), + padding: EdgeInsets.zero, + iconSize: 16, + icon: Icon(Symbols.content_copy), + onPressed: () { + Clipboard.setData( + ClipboardData(text: thought.content ?? ''), + ); + showSnackBar('copiedToClipboard'.tr()); + }, + ), + ), + ], + ), + const Gap(8), + if (thought.chunks.isNotEmpty) ...[ + buildChunkTiles(thought.chunks), + const Gap(8), + ], + if (thought.content != null) + MarkdownTextContent( + isSelectable: true, + content: thought.content!, + extraBlockSyntaxList: [ProposalBlockSyntax()], + textStyle: Theme.of(context).textTheme.bodyMedium, + extraGenerators: [ + ProposalGenerator( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + borderColor: Theme.of(context).colorScheme.outline, + ), + ], + ), + if (thought.role == ThinkingThoughtRole.assistant && + (thought.tokenCount != null || thought.modelName != null)) ...[ + const Gap(8), + Row( + children: [ + if (thought.modelName != null) ...[ + const Icon(Symbols.neurology, size: 16), + const Gap(4), + Text( + '${thought.modelName}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const Gap(16), + ], + if (thought.tokenCount != null) + ...([ + const Icon(Symbols.token, size: 16), + const Gap(4), + Text( + '${thought.tokenCount} tokens', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ]), + ], + ), + ], + if (proposals.isNotEmpty && + thought.role == ThinkingThoughtRole.assistant) ...[ + const Gap(12), + Wrap( + spacing: 8, + runSpacing: 8, + children: + proposals.map((proposal) { + return ElevatedButton.icon( + onPressed: + () => handleProposalAction(context, proposal), + icon: Icon(switch (proposal['type']) { + 'post_create' => Symbols.add, + _ => Symbols.lightbulb, + }, size: 16), + label: Text(switch (proposal['type']) { + 'post_create' => 'Create Post', + _ => proposal['type'] ?? 'Action', + }), + style: ElevatedButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ); + + return TweenAnimationBuilder( + key: key, + tween: Tween(begin: 0.0, end: 1.0), + duration: Duration( + milliseconds: 400 + (index % 5) * 50, + ), // Staggered delay + curve: Curves.easeOutCubic, + builder: (context, animationValue, child) { + return Transform.translate( + offset: Offset( + 0, + 20 * (1 - animationValue), + ), // Slide up from bottom + child: Opacity(opacity: animationValue, child: child), + ); + }, + child: thoughtWidget, + ); + } + + Widget streamingThoughtItem() => Container( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Symbols.smart_toy, size: 20), + const Gap(8), + Text( + 'thoughtAiName'.tr(), + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const Gap(8), + MarkdownTextContent( + content: streamingText.value, + textStyle: Theme.of(context).textTheme.bodyMedium, + extraBlockSyntaxList: [ProposalBlockSyntax()], + extraGenerators: [ + ProposalGenerator( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + borderColor: Theme.of(context).colorScheme.outline, + ), + ], + ), + if (reasoningChunks.value.isNotEmpty || + functionCalls.value.isNotEmpty) ...[ + const Gap(8), + Column( + children: [ + ...reasoningChunks.value.map( + (chunk) => Card( + margin: const EdgeInsets.only(bottom: 8), + child: Theme( + data: Theme.of( + context, + ).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + title: Text( + 'Reasoning', + style: Theme.of(context).textTheme.titleSmall, + ), + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + chunk, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ), + ), + ...functionCalls.value.map( + (call) => Card( + margin: const EdgeInsets.only(bottom: 8), + child: Theme( + data: Theme.of( + context, + ).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + visualDensity: VisualDensity.compact, + title: Text( + 'Function Call', + style: Theme.of(context).textTheme.titleSmall, + ), + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: SelectableText( + call, + style: GoogleFonts.robotoMono(), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ], + ], + ), + ); + + return SheetScaffold( + titleText: currentTopic.value ?? 'aiThought'.tr(), + child: Center( + child: Container( + constraints: BoxConstraints(maxWidth: 640), + child: Column( + children: [ + Expanded( + child: 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(); + } + final thoughtIndex = isStreaming.value ? index - 1 : index; + final thought = localThoughts.value[thoughtIndex]; + return thoughtItem(thought, thoughtIndex); + }, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (attachedMessages.isNotEmpty || attachedPosts.isNotEmpty) + Container( + margin: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 8, + ), + child: Row( + children: [ + Icon( + Symbols.attach_file, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const Gap(8), + Expanded( + child: Text( + [ + if (attachedMessages.isNotEmpty) + '${attachedMessages.length} message${attachedMessages.length > 1 ? 's' : ''}', + if (attachedPosts.isNotEmpty) + '${attachedPosts.length} post${attachedPosts.length > 1 ? 's' : ''}', + ].join(', '), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + IconButton( + icon: const Icon(Symbols.close, size: 16), + onPressed: () { + // Note: Since these are final parameters, we can't modify them directly + // This would require making the sheet stateful or using a callback + // For now, just show the indicator without remove functionality + }, + style: IconButton.styleFrom( + minimumSize: const Size(24, 24), + padding: EdgeInsets.zero, + ), + ), + ], + ), + ), + 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, + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class ProposalBlockSyntax extends markdown.BlockSyntax { + @override + RegExp get pattern => RegExp(r'^'); + } + + @override + markdown.Node parse(markdown.BlockParser parser) { + final childLines = []; + + // Extract type from opening tag + final openingLine = parser.current.content; + final attrsMatch = RegExp( + r']*)?>', + caseSensitive: false, + ).firstMatch(openingLine); + final attrs = attrsMatch?.group(1) ?? ''; + final typeMatch = RegExp(r'type="([^"]*)"').firstMatch(attrs); + final type = typeMatch?.group(1) ?? ''; + + // Collect all lines until closing tag + while (!parser.isDone) { + childLines.add(parser.current.content); + if (canEndBlock(parser)) { + parser.advance(); + break; + } + parser.advance(); + } + + // Extract content between tags + final fullContent = childLines.join('\n'); + final contentMatch = RegExp( + r']*>(.*?)', + dotAll: true, + caseSensitive: false, + ).firstMatch(fullContent); + final content = contentMatch?.group(1)?.trim() ?? ''; + + final element = markdown.Element('proposal', [markdown.Text(content)]) + ..attributes['type'] = type; + + return element; + } +} + +class ProposalGenerator extends SpanNodeGeneratorWithTag { + ProposalGenerator({ + required Color backgroundColor, + required Color foregroundColor, + required Color borderColor, + }) : super( + tag: 'proposal', + generator: ( + markdown.Element element, + MarkdownConfig config, + WidgetVisitor visitor, + ) { + return ProposalSpanNode( + text: element.textContent, + type: element.attributes['type'] ?? '', + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + borderColor: borderColor, + ); + }, + ); +} + +class ProposalSpanNode extends SpanNode { + final String text; + final String type; + final Color backgroundColor; + final Color foregroundColor; + final Color borderColor; + + ProposalSpanNode({ + required this.text, + required this.type, + required this.backgroundColor, + required this.foregroundColor, + required this.borderColor, + }); + + @override + InlineSpan build() { + return WidgetSpan( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all(color: borderColor, width: 1), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + Row( + spacing: 6, + children: [ + Icon(Symbols.lightbulb, size: 16, color: foregroundColor), + Text( + 'SN-chan suggest you to ${type.split('_').reversed.join(' ')}', + ).fontSize(13).opacity(0.8), + ], + ).padding(top: 3, bottom: 4), + Flexible( + child: Text( + text, + style: TextStyle( + color: foregroundColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } +}