diff --git a/lib/screens/thought/think.dart b/lib/screens/thought/think.dart index 2e257e6d..6bff2a06 100644 --- a/lib/screens/thought/think.dart +++ b/lib/screens/thought/think.dart @@ -2,28 +2,20 @@ import "dart:convert"; import "package:dio/dio.dart"; import "package:easy_localization/easy_localization.dart"; import "package:flutter/material.dart"; -import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:gap/gap.dart"; -import "package:google_fonts/google_fonts.dart"; import "package:riverpod_annotation/riverpod_annotation.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:island/models/thought.dart"; import "package:island/pods/network.dart"; import "package:island/pods/userinfo.dart"; -import "package:island/services/time.dart"; import "package:island/widgets/alert.dart"; import "package:island/widgets/app_scaffold.dart"; -import "package:island/widgets/content/markdown.dart"; -import "package:island/widgets/post/compose_dialog.dart"; import "package:island/widgets/response.dart"; import "package:island/widgets/thought/thought_sequence_list.dart"; -import "package:island/screens/posts/compose.dart"; +import "package:island/widgets/thought/shared_widgets.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; -import "package:styled_widget/styled_widget.dart"; import "package:super_sliver_list/super_sliver_list.dart"; -import "package:markdown/markdown.dart" as markdown; -import "package:markdown_widget/markdown_widget.dart"; import "package:collection/collection.dart"; part 'think.g.dart'; @@ -47,38 +39,6 @@ class ThoughtScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Extract proposals from text content - List> extractProposals(String content) { - final proposalRegex = RegExp( - r'(.*?)<\/proposal>', - dotAll: true, - ); - final matches = proposalRegex.allMatches(content); - return matches.map((match) { - return {'type': match.group(1)!, 'content': match.group(2)!}; - }).toList(); - } - - void handleProposalAction( - BuildContext context, - Map proposal, - ) { - switch (proposal['type']) { - case 'post_create': - // Show post creation dialog with the proposal content - PostComposeDialog.show( - context, - initialState: PostComposeInitialState( - content: (proposal['content'] ?? '').trim(), - ), - ); - break; - default: - // Show a snackbar for unsupported proposal types - showSnackBar('Unsupported proposal type: ${proposal['type']}'); - } - } - final selectedSequenceId = useState(null); final thoughts = selectedSequenceId.value != null @@ -261,370 +221,6 @@ class ThoughtScreen extends HookConsumerWidget { } } - Widget buildChunkTiles(List chunks) { - return Column( - children: [ - ...chunks - .where((chunk) => chunk.type == ThinkingChunkType.reasoning) - .map( - (chunk) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - title: Text( - 'Reasoning', - style: Theme.of(context).textTheme.titleSmall, - ), - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - chunk.data?['content'] ?? '', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - ), - ), - ), - ...chunks - .where((chunk) => chunk.type == ThinkingChunkType.functionCall) - .map( - (chunk) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - visualDensity: VisualDensity.compact, - title: Text( - 'Function Call', - style: Theme.of(context).textTheme.titleSmall, - ), - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: SelectableText( - JsonEncoder.withIndent(' ').convert(chunk.data), - style: GoogleFonts.robotoMono(), - ), - ), - ], - ), - ), - ), - ), - ], - ); - } - - Widget thoughtItem(SnThinkingThought thought, int index) { - final key = Key('thought-${thought.id}'); - - // Extract proposals from thought content - final proposals = - thought.content != null ? extractProposals(thought.content!) : []; - - final thoughtWidget = Container( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: - thought.role == ThinkingThoughtRole.assistant - ? Theme.of(context).colorScheme.surfaceContainerHighest - : Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - thought.role == ThinkingThoughtRole.assistant - ? Symbols.smart_toy - : Symbols.person, - size: 20, - ), - const Gap(8), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.ideographic, - spacing: 8, - children: [ - Text( - thought.role == ThinkingThoughtRole.assistant - ? 'thoughtAiName'.tr() - : 'thoughtUserName'.tr(), - style: Theme.of(context).textTheme.titleSmall, - ), - Tooltip( - message: thought.createdAt.formatSystem(), - child: Text( - thought.createdAt.formatRelative(context), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith( - color: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - if (thought.role == ThinkingThoughtRole.assistant) - SizedBox( - height: 20, - width: 20, - child: IconButton( - visualDensity: VisualDensity( - horizontal: -4, - vertical: -4, - ), - padding: EdgeInsets.zero, - iconSize: 16, - icon: Icon(Symbols.content_copy), - onPressed: () { - Clipboard.setData( - ClipboardData(text: thought.content ?? ''), - ); - showSnackBar('copiedToClipboard'.tr()); - }, - ), - ), - ], - ), - const Gap(8), - if (thought.chunks.isNotEmpty) ...[ - buildChunkTiles(thought.chunks), - const Gap(8), - ], - if (thought.content != null) - MarkdownTextContent( - isSelectable: true, - content: thought.content!, - extraBlockSyntaxList: [ProposalBlockSyntax()], - textStyle: Theme.of(context).textTheme.bodyMedium, - extraGenerators: [ - ProposalGenerator( - backgroundColor: - Theme.of(context).colorScheme.secondaryContainer, - foregroundColor: - Theme.of(context).colorScheme.onSecondaryContainer, - borderColor: Theme.of(context).colorScheme.outline, - ), - ], - ), - if (thought.role == ThinkingThoughtRole.assistant && - (thought.tokenCount != null || thought.modelName != null)) ...[ - const Gap(8), - Row( - children: [ - if (thought.modelName != null) ...[ - const Icon(Symbols.neurology, size: 16), - const Gap(4), - Text( - '${thought.modelName}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const Gap(16), - ], - if (thought.tokenCount != null) - ...([ - const Icon(Symbols.token, size: 16), - const Gap(4), - Text( - '${thought.tokenCount} tokens', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ]), - ], - ), - ], - if (proposals.isNotEmpty && - thought.role == ThinkingThoughtRole.assistant) ...[ - const Gap(12), - Wrap( - spacing: 8, - runSpacing: 8, - children: - proposals.map((proposal) { - return ElevatedButton.icon( - onPressed: - () => handleProposalAction(context, proposal), - icon: Icon(switch (proposal['type']) { - 'post_create' => Symbols.add, - _ => Symbols.lightbulb, - }, size: 16), - label: Text(switch (proposal['type']) { - 'post_create' => 'Create Post', - _ => proposal['type'] ?? 'Action', - }), - style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primaryContainer, - foregroundColor: - Theme.of(context).colorScheme.onPrimaryContainer, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - ); - }).toList(), - ), - ], - ], - ), - ); - - return TweenAnimationBuilder( - key: key, - tween: Tween(begin: 0.0, end: 1.0), - duration: Duration( - milliseconds: 400 + (index % 5) * 50, - ), // Staggered delay - curve: Curves.easeOutCubic, - builder: (context, animationValue, child) { - return Transform.translate( - offset: Offset( - 0, - 20 * (1 - animationValue), - ), // Slide up from bottom - child: Opacity(opacity: animationValue, child: child), - ); - }, - child: thoughtWidget, - ); - } - - Widget streamingThoughtItem() => Container( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Symbols.smart_toy, size: 20), - const Gap(8), - Text( - 'thoughtAiName'.tr(), - style: Theme.of(context).textTheme.titleSmall, - ), - const Spacer(), - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - const Gap(8), - MarkdownTextContent( - content: streamingText.value, - textStyle: Theme.of(context).textTheme.bodyMedium, - extraBlockSyntaxList: [ProposalBlockSyntax()], - extraGenerators: [ - ProposalGenerator( - backgroundColor: - Theme.of(context).colorScheme.secondaryContainer, - foregroundColor: - Theme.of(context).colorScheme.onSecondaryContainer, - borderColor: Theme.of(context).colorScheme.outline, - ), - ], - ), - if (reasoningChunks.value.isNotEmpty || - functionCalls.value.isNotEmpty) ...[ - const Gap(8), - Column( - children: [ - ...reasoningChunks.value.map( - (chunk) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - title: Text( - 'Reasoning', - style: Theme.of(context).textTheme.titleSmall, - ), - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - chunk, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - ), - ), - ), - ...functionCalls.value.map( - (call) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - visualDensity: VisualDensity.compact, - title: Text( - 'Function Call', - style: Theme.of(context).textTheme.titleSmall, - ), - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: SelectableText( - call, - style: GoogleFonts.robotoMono(), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ], - ], - ), - ); - return AppScaffold( isNoBackground: false, appBar: AppBar( @@ -680,12 +276,17 @@ class ThoughtScreen extends HookConsumerWidget { (isStreaming.value ? 1 : 0), itemBuilder: (context, index) { if (isStreaming.value && index == 0) { - return streamingThoughtItem(); + return streamingThoughtItem( + streamingText.value, + reasoningChunks.value, + functionCalls.value, + context, + ); } final thoughtIndex = isStreaming.value ? index - 1 : index; final thought = localThoughts.value[thoughtIndex]; - return thoughtItem(thought, thoughtIndex); + return thoughtItem(thought, thoughtIndex, context); }, ), loading: @@ -765,135 +366,3 @@ class ThoughtScreen extends HookConsumerWidget { ); } } - -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/screens/thought/think_sheet.dart b/lib/screens/thought/think_sheet.dart index 88c735ea..cdc80cd9 100644 --- a/lib/screens/thought/think_sheet.dart +++ b/lib/screens/thought/think_sheet.dart @@ -2,25 +2,17 @@ import "dart:convert"; import "package:dio/dio.dart"; import "package:easy_localization/easy_localization.dart"; import "package:flutter/material.dart"; -import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:gap/gap.dart"; -import "package:google_fonts/google_fonts.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:island/models/thought.dart"; import "package:island/pods/network.dart"; import "package:island/pods/userinfo.dart"; -import "package:island/services/time.dart"; import "package:island/widgets/alert.dart"; -import "package:island/widgets/content/markdown.dart"; import "package:island/widgets/content/sheet.dart"; -import "package:island/widgets/post/compose_dialog.dart"; -import "package:island/screens/posts/compose.dart"; +import "package:island/widgets/thought/shared_widgets.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; -import "package:styled_widget/styled_widget.dart"; import "package:super_sliver_list/super_sliver_list.dart"; -import "package:markdown/markdown.dart" as markdown; -import "package:markdown_widget/markdown_widget.dart"; class ThoughtSheet extends HookConsumerWidget { final List> attachedMessages; @@ -51,38 +43,6 @@ class ThoughtSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Extract proposals from text content - List> extractProposals(String content) { - final proposalRegex = RegExp( - r'(.*?)<\/proposal>', - dotAll: true, - ); - final matches = proposalRegex.allMatches(content); - return matches.map((match) { - return {'type': match.group(1)!, 'content': match.group(2)!}; - }).toList(); - } - - void handleProposalAction( - BuildContext context, - Map proposal, - ) { - switch (proposal['type']) { - case 'post_create': - // Show post creation dialog with the proposal content - PostComposeDialog.show( - context, - initialState: PostComposeInitialState( - content: (proposal['content'] ?? '').trim(), - ), - ); - break; - default: - // Show a snackbar for unsupported proposal types - showSnackBar('Unsupported proposal type: ${proposal['type']}'); - } - } - final localThoughts = useState>([]); final currentTopic = useState('aiThought'.tr()); @@ -231,370 +191,6 @@ class ThoughtSheet extends HookConsumerWidget { } } - Widget buildChunkTiles(List chunks) { - return Column( - children: [ - ...chunks - .where((chunk) => chunk.type == ThinkingChunkType.reasoning) - .map( - (chunk) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - title: Text( - 'Reasoning', - style: Theme.of(context).textTheme.titleSmall, - ), - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - chunk.data?['content'] ?? '', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - ), - ), - ), - ...chunks - .where((chunk) => chunk.type == ThinkingChunkType.functionCall) - .map( - (chunk) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - visualDensity: VisualDensity.compact, - title: Text( - 'Function Call', - style: Theme.of(context).textTheme.titleSmall, - ), - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: SelectableText( - JsonEncoder.withIndent(' ').convert(chunk.data), - style: GoogleFonts.robotoMono(), - ), - ), - ], - ), - ), - ), - ), - ], - ); - } - - Widget thoughtItem(SnThinkingThought thought, int index) { - final key = Key('thought-${thought.id}'); - - // Extract proposals from thought content - final proposals = - thought.content != null ? extractProposals(thought.content!) : []; - - final thoughtWidget = Container( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: - thought.role == ThinkingThoughtRole.assistant - ? Theme.of(context).colorScheme.surfaceContainerHighest - : Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - thought.role == ThinkingThoughtRole.assistant - ? Symbols.smart_toy - : Symbols.person, - size: 20, - ), - const Gap(8), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.ideographic, - spacing: 8, - children: [ - Text( - thought.role == ThinkingThoughtRole.assistant - ? 'thoughtAiName'.tr() - : 'thoughtUserName'.tr(), - style: Theme.of(context).textTheme.titleSmall, - ), - Tooltip( - message: thought.createdAt.formatSystem(), - child: Text( - thought.createdAt.formatRelative(context), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith( - color: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - if (thought.role == ThinkingThoughtRole.assistant) - SizedBox( - height: 20, - width: 20, - child: IconButton( - visualDensity: VisualDensity( - horizontal: -4, - vertical: -4, - ), - padding: EdgeInsets.zero, - iconSize: 16, - icon: Icon(Symbols.content_copy), - onPressed: () { - Clipboard.setData( - ClipboardData(text: thought.content ?? ''), - ); - showSnackBar('copiedToClipboard'.tr()); - }, - ), - ), - ], - ), - const Gap(8), - if (thought.chunks.isNotEmpty) ...[ - buildChunkTiles(thought.chunks), - const Gap(8), - ], - if (thought.content != null) - MarkdownTextContent( - isSelectable: true, - content: thought.content!, - extraBlockSyntaxList: [ProposalBlockSyntax()], - textStyle: Theme.of(context).textTheme.bodyMedium, - extraGenerators: [ - ProposalGenerator( - backgroundColor: - Theme.of(context).colorScheme.secondaryContainer, - foregroundColor: - Theme.of(context).colorScheme.onSecondaryContainer, - borderColor: Theme.of(context).colorScheme.outline, - ), - ], - ), - if (thought.role == ThinkingThoughtRole.assistant && - (thought.tokenCount != null || thought.modelName != null)) ...[ - const Gap(8), - Row( - children: [ - if (thought.modelName != null) ...[ - const Icon(Symbols.neurology, size: 16), - const Gap(4), - Text( - '${thought.modelName}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const Gap(16), - ], - if (thought.tokenCount != null) - ...([ - const Icon(Symbols.token, size: 16), - const Gap(4), - Text( - '${thought.tokenCount} tokens', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ]), - ], - ), - ], - if (proposals.isNotEmpty && - thought.role == ThinkingThoughtRole.assistant) ...[ - const Gap(12), - Wrap( - spacing: 8, - runSpacing: 8, - children: - proposals.map((proposal) { - return ElevatedButton.icon( - onPressed: - () => handleProposalAction(context, proposal), - icon: Icon(switch (proposal['type']) { - 'post_create' => Symbols.add, - _ => Symbols.lightbulb, - }, size: 16), - label: Text(switch (proposal['type']) { - 'post_create' => 'Create Post', - _ => proposal['type'] ?? 'Action', - }), - style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primaryContainer, - foregroundColor: - Theme.of(context).colorScheme.onPrimaryContainer, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - ); - }).toList(), - ), - ], - ], - ), - ); - - return TweenAnimationBuilder( - key: key, - tween: Tween(begin: 0.0, end: 1.0), - duration: Duration( - milliseconds: 400 + (index % 5) * 50, - ), // Staggered delay - curve: Curves.easeOutCubic, - builder: (context, animationValue, child) { - return Transform.translate( - offset: Offset( - 0, - 20 * (1 - animationValue), - ), // Slide up from bottom - child: Opacity(opacity: animationValue, child: child), - ); - }, - child: thoughtWidget, - ); - } - - Widget streamingThoughtItem() => Container( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Symbols.smart_toy, size: 20), - const Gap(8), - Text( - 'thoughtAiName'.tr(), - style: Theme.of(context).textTheme.titleSmall, - ), - const Spacer(), - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - const Gap(8), - MarkdownTextContent( - content: streamingText.value, - textStyle: Theme.of(context).textTheme.bodyMedium, - extraBlockSyntaxList: [ProposalBlockSyntax()], - extraGenerators: [ - ProposalGenerator( - backgroundColor: - Theme.of(context).colorScheme.secondaryContainer, - foregroundColor: - Theme.of(context).colorScheme.onSecondaryContainer, - borderColor: Theme.of(context).colorScheme.outline, - ), - ], - ), - if (reasoningChunks.value.isNotEmpty || - functionCalls.value.isNotEmpty) ...[ - const Gap(8), - Column( - children: [ - ...reasoningChunks.value.map( - (chunk) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - title: Text( - 'Reasoning', - style: Theme.of(context).textTheme.titleSmall, - ), - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - chunk, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - ), - ), - ), - ...functionCalls.value.map( - (call) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - visualDensity: VisualDensity.compact, - title: Text( - 'Function Call', - style: Theme.of(context).textTheme.titleSmall, - ), - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: SelectableText( - call, - style: GoogleFonts.robotoMono(), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ], - ], - ), - ); - return SheetScaffold( titleText: currentTopic.value ?? 'aiThought'.tr(), child: Center( @@ -612,80 +208,107 @@ class ThoughtSheet extends HookConsumerWidget { localThoughts.value.length + (isStreaming.value ? 1 : 0), itemBuilder: (context, index) { if (isStreaming.value && index == 0) { - return streamingThoughtItem(); + return streamingThoughtItem( + streamingText.value, + reasoningChunks.value, + functionCalls.value, + context, + ); } final thoughtIndex = isStreaming.value ? index - 1 : index; final thought = localThoughts.value[thoughtIndex]; - return thoughtItem(thought, thoughtIndex); + return thoughtItem(thought, thoughtIndex, context); }, ), ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (attachedMessages.isNotEmpty || attachedPosts.isNotEmpty) - Container( - margin: const EdgeInsets.only( - left: 16, - right: 16, - bottom: 8, - ), - child: Row( - children: [ - Icon( - Symbols.attach_file, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - const Gap(8), - Expanded( - child: Text( - [ - if (attachedMessages.isNotEmpty) - '${attachedMessages.length} message${attachedMessages.length > 1 ? 's' : ''}', - if (attachedPosts.isNotEmpty) - '${attachedPosts.length} post${attachedPosts.length > 1 ? 's' : ''}', - ].join(', '), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, + 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, ), ), - ), - IconButton( - icon: const Icon(Symbols.close, size: 16), - onPressed: () { - // Note: Since these are final parameters, we can't modify them directly - // This would require making the sheet stateful or using a callback - // For now, just show the indicator without remove functionality - }, - style: IconButton.styleFrom( - minimumSize: const Size(24, 24), - padding: EdgeInsets.zero, + 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(), + ), + ), + ], ), ), - ], - ), - ), - 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( + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( @@ -720,10 +343,10 @@ class ThoughtSheet extends HookConsumerWidget { ), ], ), - ), + ], ), ), - ], + ), ), ], ), @@ -732,135 +355,3 @@ class ThoughtSheet extends HookConsumerWidget { ); } } - -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/shared_widgets.dart b/lib/widgets/thought/shared_widgets.dart new file mode 100644 index 00000000..ee54e924 --- /dev/null +++ b/lib/widgets/thought/shared_widgets.dart @@ -0,0 +1,535 @@ +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, + ), + ), + ), + ], + ), + ), + ); + } +}