💄 Optimize thinking UI
This commit is contained in:
		| @@ -1298,6 +1298,7 @@ | ||||
|     "thoughtInputHint": "Ask sn-chan anything...", | ||||
|     "thoughtNewConversation": "Start New Conversation", | ||||
|     "thoughtParseError": "Failed to parse AI response", | ||||
|     "thoughtFunctionCall": "Function Call", | ||||
|     "aiThought": "AI Thought", | ||||
|     "aiThoughtTitle": "Let sn-chan think" | ||||
| } | ||||
|   | ||||
| @@ -707,15 +707,15 @@ class ChatRoomScreen extends HookConsumerWidget { | ||||
|                           top: 8, | ||||
|                           right: 8, | ||||
|                           child: Container( | ||||
|                             width: 24, | ||||
|                             height: 24, | ||||
|                             width: 16, | ||||
|                             height: 16, | ||||
|                             decoration: BoxDecoration( | ||||
|                               color: Theme.of(context).colorScheme.primary, | ||||
|                               shape: BoxShape.circle, | ||||
|                             ), | ||||
|                             child: Icon( | ||||
|                               Icons.check, | ||||
|                               size: 16, | ||||
|                               size: 12, | ||||
|                               color: Theme.of(context).colorScheme.onPrimary, | ||||
|                             ), | ||||
|                           ), | ||||
|   | ||||
| @@ -13,7 +13,7 @@ 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:island/widgets/thought/thought_shared.dart"; | ||||
| import "package:material_symbols_icons/material_symbols_icons.dart"; | ||||
| import "package:super_sliver_list/super_sliver_list.dart"; | ||||
| import "package:collection/collection.dart"; | ||||
| @@ -276,17 +276,20 @@ class ThoughtScreen extends HookConsumerWidget { | ||||
|                             (isStreaming.value ? 1 : 0), | ||||
|                         itemBuilder: (context, index) { | ||||
|                           if (isStreaming.value && index == 0) { | ||||
|                             return streamingThoughtItem( | ||||
|                               streamingText.value, | ||||
|                               reasoningChunks.value, | ||||
|                               functionCalls.value, | ||||
|                               context, | ||||
|                             return ThoughtItem( | ||||
|                               isStreaming: true, | ||||
|                               streamingText: streamingText.value, | ||||
|                               reasoningChunks: reasoningChunks.value, | ||||
|                               streamingFunctionCalls: functionCalls.value, | ||||
|                             ); | ||||
|                           } | ||||
|                           final thoughtIndex = | ||||
|                               isStreaming.value ? index - 1 : index; | ||||
|                           final thought = localThoughts.value[thoughtIndex]; | ||||
|                           return thoughtItem(thought, thoughtIndex, context); | ||||
|                           return ThoughtItem( | ||||
|                             thought: thought, | ||||
|                             thoughtIndex: thoughtIndex, | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|                   loading: | ||||
| @@ -306,58 +309,10 @@ class ThoughtScreen extends HookConsumerWidget { | ||||
|                       ), | ||||
|                 ), | ||||
|               ), | ||||
|               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, | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ThoughtInput( | ||||
|                 messageController: messageController, | ||||
|                 isStreaming: isStreaming.value, | ||||
|                 onSend: sendMessage, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|   | ||||
| @@ -3,15 +3,13 @@ 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: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/content/sheet.dart"; | ||||
| import "package:island/widgets/thought/shared_widgets.dart"; | ||||
| import "package:material_symbols_icons/material_symbols_icons.dart"; | ||||
| import "package:island/widgets/thought/thought_shared.dart"; | ||||
| import "package:super_sliver_list/super_sliver_list.dart"; | ||||
|  | ||||
| class ThoughtSheet extends HookConsumerWidget { | ||||
| @@ -208,145 +206,28 @@ class ThoughtSheet extends HookConsumerWidget { | ||||
|                       localThoughts.value.length + (isStreaming.value ? 1 : 0), | ||||
|                   itemBuilder: (context, index) { | ||||
|                     if (isStreaming.value && index == 0) { | ||||
|                       return streamingThoughtItem( | ||||
|                         streamingText.value, | ||||
|                         reasoningChunks.value, | ||||
|                         functionCalls.value, | ||||
|                         context, | ||||
|                       return ThoughtItem( | ||||
|                         isStreaming: true, | ||||
|                         streamingText: streamingText.value, | ||||
|                         reasoningChunks: reasoningChunks.value, | ||||
|                         streamingFunctionCalls: functionCalls.value, | ||||
|                       ); | ||||
|                     } | ||||
|                     final thoughtIndex = isStreaming.value ? index - 1 : index; | ||||
|                     final thought = localThoughts.value[thoughtIndex]; | ||||
|                     return thoughtItem(thought, thoughtIndex, context); | ||||
|                     return ThoughtItem( | ||||
|                       thought: thought, | ||||
|                       thoughtIndex: thoughtIndex, | ||||
|                     ); | ||||
|                   }, | ||||
|                 ), | ||||
|               ), | ||||
|               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: Column( | ||||
|                       children: [ | ||||
|                         if (attachedMessages.isNotEmpty || | ||||
|                             attachedPosts.isNotEmpty) | ||||
|                           Container( | ||||
|                             key: ValueKey( | ||||
|                               'attachments-${attachedMessages.length}-${attachedPosts.length}', | ||||
|                             ), | ||||
|                             padding: const EdgeInsets.symmetric( | ||||
|                               horizontal: 8, | ||||
|                               vertical: 4, | ||||
|                             ), | ||||
|                             margin: const EdgeInsets.only( | ||||
|                               left: 4, | ||||
|                               right: 4, | ||||
|                               top: 4, | ||||
|                               bottom: 4, | ||||
|                             ), | ||||
|                             decoration: BoxDecoration( | ||||
|                               color: | ||||
|                                   Theme.of( | ||||
|                                     context, | ||||
|                                   ).colorScheme.surfaceContainerHigh, | ||||
|                               borderRadius: BorderRadius.circular(16), | ||||
|                               border: Border.all( | ||||
|                                 color: Theme.of( | ||||
|                                   context, | ||||
|                                 ).colorScheme.outline.withOpacity(0.2), | ||||
|                                 width: 1, | ||||
|                               ), | ||||
|                             ), | ||||
|                             child: Row( | ||||
|                               mainAxisSize: MainAxisSize.max, | ||||
|                               children: [ | ||||
|                                 Icon( | ||||
|                                   Symbols.attach_file, | ||||
|                                   size: 14, | ||||
|                                   color: Theme.of(context).colorScheme.primary, | ||||
|                                 ), | ||||
|                                 const Gap(4), | ||||
|                                 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( | ||||
|                                     fontWeight: FontWeight.w500, | ||||
|                                     fontSize: 12, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                                 const Spacer(), | ||||
|                                 SizedBox( | ||||
|                                   width: 24, | ||||
|                                   height: 24, | ||||
|                                   child: IconButton( | ||||
|                                     padding: EdgeInsets.zero, | ||||
|                                     icon: const Icon(Icons.close, size: 14), | ||||
|                                     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 | ||||
|                                     }, | ||||
|                                     tooltip: 'clear'.tr(), | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ), | ||||
|                         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, | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ThoughtInput( | ||||
|                 messageController: messageController, | ||||
|                 isStreaming: isStreaming.value, | ||||
|                 onSend: sendMessage, | ||||
|                 attachedMessages: attachedMessages, | ||||
|                 attachedPosts: attachedPosts, | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|   | ||||
| @@ -1,535 +0,0 @@ | ||||
| import "dart:convert"; | ||||
| import "package:easy_localization/easy_localization.dart"; | ||||
| import "package:flutter/material.dart"; | ||||
| import "package:flutter/services.dart"; | ||||
| import "package:gap/gap.dart"; | ||||
| import "package:google_fonts/google_fonts.dart"; | ||||
| import "package:island/models/thought.dart"; | ||||
| import "package:island/services/time.dart"; | ||||
| import "package:island/widgets/alert.dart"; | ||||
| import "package:island/widgets/content/markdown.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:markdown/markdown.dart" as markdown; | ||||
| import "package:markdown_widget/markdown_widget.dart"; | ||||
|  | ||||
| // Common functions | ||||
| List<Map<String, String>> extractProposals(String content) { | ||||
|   final proposalRegex = RegExp( | ||||
|     r'<proposal\s+type="([^"]+)">(.*?)<\/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<String, String> 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']}'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Common widgets | ||||
| Widget buildChunkTiles(List<SnThinkingChunk> chunks, BuildContext context) { | ||||
|   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, BuildContext context) { | ||||
|   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, context), | ||||
|           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<double>( | ||||
|     key: key, | ||||
|     tween: Tween<double>(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( | ||||
|   String streamingText, | ||||
|   List<String> reasoningChunks, | ||||
|   List<String> functionCalls, | ||||
|   BuildContext context, | ||||
| ) { | ||||
|   return 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, | ||||
|           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.isNotEmpty || functionCalls.isNotEmpty) ...[ | ||||
|           const Gap(8), | ||||
|           Column( | ||||
|             children: [ | ||||
|               ...reasoningChunks.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.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(), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ], | ||||
|       ], | ||||
|     ), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| class ProposalBlockSyntax extends markdown.BlockSyntax { | ||||
|   @override | ||||
|   RegExp get pattern => RegExp(r'^<proposal', caseSensitive: false); | ||||
|  | ||||
|   @override | ||||
|   bool canParse(markdown.BlockParser parser) { | ||||
|     return pattern.hasMatch(parser.current.content); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool canEndBlock(markdown.BlockParser parser) { | ||||
|     return parser.current.content.contains('</proposal>'); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   markdown.Node parse(markdown.BlockParser parser) { | ||||
|     final childLines = <String>[]; | ||||
|  | ||||
|     // Extract type from opening tag | ||||
|     final openingLine = parser.current.content; | ||||
|     final attrsMatch = RegExp( | ||||
|       r'<proposal(\s[^>]*)?>', | ||||
|       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'<proposal[^>]*>(.*?)</proposal>', | ||||
|       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, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										137
									
								
								lib/widgets/thought/thought_proposal.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								lib/widgets/thought/thought_proposal.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| import "package:flutter/material.dart"; | ||||
| import "package:material_symbols_icons/material_symbols_icons.dart"; | ||||
| import "package:styled_widget/styled_widget.dart"; | ||||
| import "package:markdown/markdown.dart" as markdown; | ||||
| import "package:markdown_widget/markdown_widget.dart"; | ||||
|  | ||||
| class ProposalBlockSyntax extends markdown.BlockSyntax { | ||||
|   @override | ||||
|   RegExp get pattern => RegExp(r'^<proposal', caseSensitive: false); | ||||
|  | ||||
|   @override | ||||
|   bool canParse(markdown.BlockParser parser) { | ||||
|     return pattern.hasMatch(parser.current.content); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool canEndBlock(markdown.BlockParser parser) { | ||||
|     return parser.current.content.contains('</proposal>'); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   markdown.Node parse(markdown.BlockParser parser) { | ||||
|     final childLines = <String>[]; | ||||
|  | ||||
|     // Extract type from opening tag | ||||
|     final openingLine = parser.current.content; | ||||
|     final attrsMatch = RegExp( | ||||
|       r'<proposal(\s[^>]*)?>', | ||||
|       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'<proposal[^>]*>(.*?)</proposal>', | ||||
|       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, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										574
									
								
								lib/widgets/thought/thought_shared.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										574
									
								
								lib/widgets/thought/thought_shared.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,574 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| 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:google_fonts/google_fonts.dart'; | ||||
| import 'package:island/models/thought.dart'; | ||||
| import 'package:island/screens/posts/compose.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:island/widgets/content/markdown.dart'; | ||||
| import 'package:island/widgets/post/compose_dialog.dart'; | ||||
| import 'package:island/widgets/thought/thought_proposal.dart'; | ||||
| import 'package:material_symbols_icons/material_symbols_icons.dart'; | ||||
|  | ||||
| List<Map<String, String>> _extractProposals(String content) { | ||||
|   final proposalRegex = RegExp( | ||||
|     r'<proposal\s+type="([^"]+)">(.*?)<\/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<String, String> 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']}'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class ThoughtInput extends HookWidget { | ||||
|   final TextEditingController messageController; | ||||
|   final bool isStreaming; | ||||
|   final VoidCallback onSend; | ||||
|   final List<Map<String, dynamic>>? attachedMessages; | ||||
|   final List<String>? attachedPosts; | ||||
|  | ||||
|   const ThoughtInput({ | ||||
|     super.key, | ||||
|     required this.messageController, | ||||
|     required this.isStreaming, | ||||
|     required this.onSend, | ||||
|     this.attachedMessages, | ||||
|     this.attachedPosts, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return 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: Column( | ||||
|             children: [ | ||||
|               if ((attachedMessages?.isNotEmpty ?? false) || | ||||
|                   (attachedPosts?.isNotEmpty ?? false)) | ||||
|                 Container( | ||||
|                   key: ValueKey( | ||||
|                     'attachments-${attachedMessages?.length ?? 0}-${attachedPosts?.length ?? 0}', | ||||
|                   ), | ||||
|                   padding: const EdgeInsets.symmetric( | ||||
|                     horizontal: 8, | ||||
|                     vertical: 4, | ||||
|                   ), | ||||
|                   margin: const EdgeInsets.only( | ||||
|                     left: 4, | ||||
|                     right: 4, | ||||
|                     top: 4, | ||||
|                     bottom: 4, | ||||
|                   ), | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|                     borderRadius: BorderRadius.circular(16), | ||||
|                     border: Border.all( | ||||
|                       color: Theme.of( | ||||
|                         context, | ||||
|                       ).colorScheme.outline.withOpacity(0.2), | ||||
|                       width: 1, | ||||
|                     ), | ||||
|                   ), | ||||
|                   child: Row( | ||||
|                     mainAxisSize: MainAxisSize.max, | ||||
|                     children: [ | ||||
|                       Icon( | ||||
|                         Symbols.attach_file, | ||||
|                         size: 16, | ||||
|                         color: Theme.of(context).colorScheme.primary, | ||||
|                       ), | ||||
|                       const Gap(4), | ||||
|                       Text( | ||||
|                         [ | ||||
|                           if (attachedMessages?.isNotEmpty ?? false) | ||||
|                             '${attachedMessages!.length} message${attachedMessages!.length > 1 ? 's' : ''}', | ||||
|                           if (attachedPosts?.isNotEmpty ?? false) | ||||
|                             '${attachedPosts!.length} post${attachedPosts!.length > 1 ? 's' : ''}', | ||||
|                         ].join(', '), | ||||
|                         style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||
|                           fontWeight: FontWeight.w500, | ||||
|                           fontSize: 12, | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Spacer(), | ||||
|                       SizedBox( | ||||
|                         width: 24, | ||||
|                         height: 24, | ||||
|                         child: IconButton( | ||||
|                           padding: EdgeInsets.zero, | ||||
|                           icon: const Icon(Icons.close, size: 14), | ||||
|                           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 | ||||
|                           }, | ||||
|                           tooltip: 'clear', | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               Row( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   Expanded( | ||||
|                     child: TextField( | ||||
|                       controller: messageController, | ||||
|                       keyboardType: TextInputType.multiline, | ||||
|                       enabled: !isStreaming, | ||||
|                       decoration: InputDecoration( | ||||
|                         hintText: | ||||
|                             (isStreaming | ||||
|                                     ? 'thoughtStreamingHint' | ||||
|                                     : 'thoughtInputHint') | ||||
|                                 .tr(), | ||||
|                         border: InputBorder.none, | ||||
|                         isDense: true, | ||||
|                         contentPadding: const EdgeInsets.symmetric( | ||||
|                           horizontal: 12, | ||||
|                           vertical: 12, | ||||
|                         ), | ||||
|                       ), | ||||
|                       maxLines: 5, | ||||
|                       minLines: 1, | ||||
|                       textInputAction: TextInputAction.send, | ||||
|                       onSubmitted: (_) => onSend(), | ||||
|                     ), | ||||
|                   ), | ||||
|                   IconButton( | ||||
|                     icon: Icon(isStreaming ? Symbols.stop : Icons.send), | ||||
|                     color: Theme.of(context).colorScheme.primary, | ||||
|                     onPressed: onSend, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Unified thought item widget | ||||
| class ThoughtItem extends StatelessWidget { | ||||
|   const ThoughtItem({ | ||||
|     super.key, | ||||
|     this.thought, | ||||
|     this.thoughtIndex, | ||||
|     this.isStreaming = false, | ||||
|     this.streamingText = '', | ||||
|     this.reasoningChunks = const [], | ||||
|     this.streamingFunctionCalls = const [], | ||||
|   }) : assert( | ||||
|          (thought != null && !isStreaming) || (thought == null && isStreaming), | ||||
|          'Either thought or streaming parameters must be provided', | ||||
|        ); | ||||
|  | ||||
|   final SnThinkingThought? thought; | ||||
|   final int? thoughtIndex; | ||||
|   final bool isStreaming; | ||||
|   final String streamingText; | ||||
|   final List<String> reasoningChunks; | ||||
|   final List<String> streamingFunctionCalls; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final isUser = !isStreaming && thought!.role == ThinkingThoughtRole.user; | ||||
|     final isAI = | ||||
|         isStreaming || | ||||
|         (!isStreaming && thought!.role == ThinkingThoughtRole.assistant); | ||||
|  | ||||
|     final proposals = | ||||
|         !isStreaming && thought!.content != null | ||||
|             ? _extractProposals(thought!.content!) | ||||
|             : []; | ||||
|  | ||||
|     return Container( | ||||
|       margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|       child: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           // Header | ||||
|           if (!isStreaming) ...[ | ||||
|             Row( | ||||
|               spacing: 6, | ||||
|               children: [ | ||||
|                 Icon( | ||||
|                   isUser ? Symbols.person : Symbols.smart_toy, | ||||
|                   size: 16, | ||||
|                   color: | ||||
|                       isUser | ||||
|                           ? Theme.of(context).colorScheme.primary | ||||
|                           : Theme.of(context).colorScheme.secondary, | ||||
|                   fill: 1, | ||||
|                 ), | ||||
|                 Text( | ||||
|                   isUser ? 'thoughtUserName'.tr() : 'aiThought'.tr(), | ||||
|                   style: Theme.of(context).textTheme.titleSmall!.copyWith( | ||||
|                     fontWeight: FontWeight.w600, | ||||
|                     color: | ||||
|                         isUser | ||||
|                             ? Theme.of(context).colorScheme.primary | ||||
|                             : Theme.of(context).colorScheme.secondary, | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|             const Gap(8), | ||||
|           ] else ...[ | ||||
|             Text( | ||||
|               'aiThought'.tr(), | ||||
|               style: Theme.of(context).textTheme.titleSmall!.copyWith( | ||||
|                 fontWeight: FontWeight.w600, | ||||
|                 color: Theme.of(context).colorScheme.primary, | ||||
|               ), | ||||
|             ), | ||||
|             const Gap(8), | ||||
|           ], | ||||
|           // Content | ||||
|           Container( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), | ||||
|             decoration: BoxDecoration( | ||||
|               color: Theme.of(context).colorScheme.surfaceContainerHighest, | ||||
|               borderRadius: BorderRadius.circular(12), | ||||
|               border: Border.all( | ||||
|                 color: Theme.of(context).colorScheme.outline.withOpacity(0.2), | ||||
|                 width: 1, | ||||
|               ), | ||||
|             ), | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 // Main content | ||||
|                 if (isStreaming) ...[ | ||||
|                   // Streaming text with spinner | ||||
|                   if (streamingText.isNotEmpty) | ||||
|                     Row( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Expanded( | ||||
|                           child: SelectableText( | ||||
|                             streamingText, | ||||
|                             style: TextStyle( | ||||
|                               color: Theme.of(context).colorScheme.onSurface, | ||||
|                               height: 1.4, | ||||
|                             ), | ||||
|                           ), | ||||
|                         ), | ||||
|                         const SizedBox(width: 8), | ||||
|                         SizedBox( | ||||
|                           width: 16, | ||||
|                           height: 16, | ||||
|                           child: CircularProgressIndicator( | ||||
|                             strokeWidth: 2, | ||||
|                             valueColor: AlwaysStoppedAnimation<Color>( | ||||
|                               Theme.of(context).colorScheme.primary, | ||||
|                             ), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                 ] else ...[ | ||||
|                   // Regular thought content | ||||
|                   if (thought!.content != null && thought!.content!.isNotEmpty) | ||||
|                     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, | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                 ], | ||||
|  | ||||
|                 // Reasoning chunks (streaming only) | ||||
|                 if (isStreaming && reasoningChunks.isNotEmpty) ...[ | ||||
|                   const Gap(12), | ||||
|                   Container( | ||||
|                     padding: const EdgeInsets.all(8), | ||||
|                     decoration: BoxDecoration( | ||||
|                       color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                     ), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Row( | ||||
|                           children: [ | ||||
|                             Icon( | ||||
|                               Symbols.psychology, | ||||
|                               size: 14, | ||||
|                               color: Theme.of(context).colorScheme.primary, | ||||
|                             ), | ||||
|                             const Gap(4), | ||||
|                             Text( | ||||
|                               'reasoning'.tr(), | ||||
|                               style: Theme.of( | ||||
|                                 context, | ||||
|                               ).textTheme.bodySmall!.copyWith( | ||||
|                                 fontWeight: FontWeight.w600, | ||||
|                                 color: Theme.of(context).colorScheme.primary, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                         const Gap(4), | ||||
|                         ...reasoningChunks.map( | ||||
|                           (chunk) => Padding( | ||||
|                             padding: const EdgeInsets.only(bottom: 4), | ||||
|                             child: Text( | ||||
|                               chunk, | ||||
|                               style: TextStyle( | ||||
|                                 color: | ||||
|                                     Theme.of( | ||||
|                                       context, | ||||
|                                     ).colorScheme.onSurfaceVariant, | ||||
|                                 fontSize: 12, | ||||
|                                 height: 1.3, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|  | ||||
|                 // Function calls | ||||
|                 if ((isStreaming && streamingFunctionCalls.isNotEmpty) || | ||||
|                     (!isStreaming && | ||||
|                         isAI && | ||||
|                         thought!.chunks.isNotEmpty && | ||||
|                         thought!.chunks.any( | ||||
|                           (chunk) => | ||||
|                               chunk.type == ThinkingChunkType.functionCall, | ||||
|                         ))) ...[ | ||||
|                   const Gap(12), | ||||
|                   Container( | ||||
|                     padding: const EdgeInsets.all(8), | ||||
|                     decoration: BoxDecoration( | ||||
|                       color: Theme.of(context).colorScheme.tertiaryContainer, | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                     ), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Row( | ||||
|                           children: [ | ||||
|                             Icon( | ||||
|                               Symbols.code, | ||||
|                               size: 14, | ||||
|                               color: Theme.of(context).colorScheme.tertiary, | ||||
|                             ), | ||||
|                             const Gap(4), | ||||
|                             Text( | ||||
|                               'functionCalls'.tr(), | ||||
|                               style: Theme.of( | ||||
|                                 context, | ||||
|                               ).textTheme.bodySmall!.copyWith( | ||||
|                                 fontWeight: FontWeight.w600, | ||||
|                                 color: Theme.of(context).colorScheme.tertiary, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                         const Gap(4), | ||||
|                         if (isStreaming) ...[ | ||||
|                           ...streamingFunctionCalls.map( | ||||
|                             (call) => Container( | ||||
|                               width: double.infinity, | ||||
|                               padding: const EdgeInsets.all(8), | ||||
|                               margin: const EdgeInsets.only(bottom: 4), | ||||
|                               decoration: BoxDecoration( | ||||
|                                 color: Theme.of(context).colorScheme.surface, | ||||
|                                 borderRadius: BorderRadius.circular(4), | ||||
|                                 border: Border.all( | ||||
|                                   color: Theme.of( | ||||
|                                     context, | ||||
|                                   ).colorScheme.outline.withOpacity(0.3), | ||||
|                                   width: 1, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               child: SelectableText( | ||||
|                                 call, | ||||
|                                 style: GoogleFonts.robotoMono( | ||||
|                                   fontSize: 11, | ||||
|                                   color: | ||||
|                                       Theme.of(context).colorScheme.onSurface, | ||||
|                                   height: 1.3, | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ), | ||||
|                           ), | ||||
|                         ] else ...[ | ||||
|                           ...thought!.chunks | ||||
|                               .where( | ||||
|                                 (chunk) => | ||||
|                                     chunk.type == | ||||
|                                     ThinkingChunkType.functionCall, | ||||
|                               ) | ||||
|                               .map( | ||||
|                                 (chunk) => Container( | ||||
|                                   width: double.infinity, | ||||
|                                   padding: const EdgeInsets.all(8), | ||||
|                                   margin: const EdgeInsets.only(bottom: 4), | ||||
|                                   decoration: BoxDecoration( | ||||
|                                     color: | ||||
|                                         Theme.of(context).colorScheme.surface, | ||||
|                                     borderRadius: BorderRadius.circular(4), | ||||
|                                     border: Border.all( | ||||
|                                       color: Theme.of( | ||||
|                                         context, | ||||
|                                       ).colorScheme.outline.withOpacity(0.3), | ||||
|                                       width: 1, | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                   child: SelectableText( | ||||
|                                     JsonEncoder.withIndent( | ||||
|                                       '  ', | ||||
|                                     ).convert(chunk.data), | ||||
|                                     style: GoogleFonts.robotoMono( | ||||
|                                       fontSize: 11, | ||||
|                                       color: | ||||
|                                           Theme.of( | ||||
|                                             context, | ||||
|                                           ).colorScheme.onSurface, | ||||
|                                       height: 1.3, | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ), | ||||
|                         ], | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|  | ||||
|                   // Token count and model name (for completed AI thoughts only) | ||||
|                   if (!isStreaming && | ||||
|                       isAI && | ||||
|                       (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, | ||||
|                               ), | ||||
|                             ), | ||||
|                           ]), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ], | ||||
|                 ], | ||||
|                 // Proposals (for completed AI thoughts only) | ||||
|                 if (!isStreaming && proposals.isNotEmpty && isAI) ...[ | ||||
|                   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(), | ||||
|                   ), | ||||
|                 ], | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user