From 481190811b4bd612d6f88663553a4cb00dc864e4 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 27 Oct 2025 00:56:21 +0800 Subject: [PATCH] :lipstick: Optimize thinking UI --- assets/i18n/en-US.json | 1 + lib/screens/chat/room.dart | 6 +- lib/screens/thought/think.dart | 73 +-- lib/screens/thought/think_sheet.dart | 151 +----- lib/widgets/thought/shared_widgets.dart | 535 -------------------- lib/widgets/thought/thought_proposal.dart | 137 ++++++ lib/widgets/thought/thought_shared.dart | 574 ++++++++++++++++++++++ 7 files changed, 745 insertions(+), 732 deletions(-) delete mode 100644 lib/widgets/thought/shared_widgets.dart create mode 100644 lib/widgets/thought/thought_proposal.dart create mode 100644 lib/widgets/thought/thought_shared.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 61aa7d61..c02c07c1 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -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" } diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 1c21c921..b8da3281 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -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, ), ), diff --git a/lib/screens/thought/think.dart b/lib/screens/thought/think.dart index 6bff2a06..2e9ef6a2 100644 --- a/lib/screens/thought/think.dart +++ b/lib/screens/thought/think.dart @@ -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, ), ], ), diff --git a/lib/screens/thought/think_sheet.dart b/lib/screens/thought/think_sheet.dart index cdc80cd9..4a47658b 100644 --- a/lib/screens/thought/think_sheet.dart +++ b/lib/screens/thought/think_sheet.dart @@ -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, ), ], ), diff --git a/lib/widgets/thought/shared_widgets.dart b/lib/widgets/thought/shared_widgets.dart deleted file mode 100644 index ee54e924..00000000 --- a/lib/widgets/thought/shared_widgets.dart +++ /dev/null @@ -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> 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']}'); - } -} - -// Common widgets -Widget buildChunkTiles(List 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( - 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( - String streamingText, - List reasoningChunks, - List 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'^'); - } - - @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, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/thought/thought_proposal.dart b/lib/widgets/thought/thought_proposal.dart new file mode 100644 index 00000000..02f60e0b --- /dev/null +++ b/lib/widgets/thought/thought_proposal.dart @@ -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'^'); + } + + @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, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/thought/thought_shared.dart b/lib/widgets/thought/thought_shared.dart new file mode 100644 index 00000000..4551c8f9 --- /dev/null +++ b/lib/widgets/thought/thought_shared.dart @@ -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> _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']}'); + } +} + +class ThoughtInput extends HookWidget { + final TextEditingController messageController; + final bool isStreaming; + final VoidCallback onSend; + final List>? attachedMessages; + final List? 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 reasoningChunks; + final List 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( + 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(), + ), + ], + ], + ), + ), + ], + ), + ); + } +}