From 08091d51bf33e712ec2d604d3e981f3521659e49 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 27 Oct 2025 01:08:42 +0800 Subject: [PATCH] :recycle: Refactor thought widgets --- .../thought/function_calls_section.dart | 133 +++++++ lib/widgets/thought/proposals_section.dart | 56 +++ lib/widgets/thought/reasoning_section.dart | 67 ++++ lib/widgets/thought/thought_content.dart | 71 ++++ lib/widgets/thought/thought_header.dart | 52 +++ lib/widgets/thought/thought_shared.dart | 356 ++---------------- lib/widgets/thought/token_info.dart | 49 +++ 7 files changed, 456 insertions(+), 328 deletions(-) create mode 100644 lib/widgets/thought/function_calls_section.dart create mode 100644 lib/widgets/thought/proposals_section.dart create mode 100644 lib/widgets/thought/reasoning_section.dart create mode 100644 lib/widgets/thought/thought_content.dart create mode 100644 lib/widgets/thought/thought_header.dart create mode 100644 lib/widgets/thought/token_info.dart diff --git a/lib/widgets/thought/function_calls_section.dart b/lib/widgets/thought/function_calls_section.dart new file mode 100644 index 00000000..f2cf5873 --- /dev/null +++ b/lib/widgets/thought/function_calls_section.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:island/models/thought.dart'; + +class FunctionCallsSection extends StatelessWidget { + const FunctionCallsSection({ + super.key, + required this.isStreaming, + required this.streamingFunctionCalls, + this.thought, + }); + + final bool isStreaming; + final List streamingFunctionCalls; + final SnThinkingThought? thought; + + bool get _hasFunctionCalls { + if (isStreaming) { + return streamingFunctionCalls.isNotEmpty; + } else { + return thought!.chunks.isNotEmpty && + thought!.chunks.any( + (chunk) => chunk.type == ThinkingChunkType.functionCall, + ); + } + } + + @override + Widget build(BuildContext context) { + if (!_hasFunctionCalls) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/thought/proposals_section.dart b/lib/widgets/thought/proposals_section.dart new file mode 100644 index 00000000..5bfa606b --- /dev/null +++ b/lib/widgets/thought/proposals_section.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +class ProposalsSection extends StatelessWidget { + const ProposalsSection({ + super.key, + required this.proposals, + required this.onProposalAction, + }); + + final List> proposals; + final void Function(BuildContext, Map) onProposalAction; + + @override + Widget build(BuildContext context) { + if (proposals.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Gap(12), + Wrap( + spacing: 8, + runSpacing: 8, + children: + proposals.map((proposal) { + return ElevatedButton.icon( + onPressed: () => onProposalAction(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(), + ), + ], + ); + } +} diff --git a/lib/widgets/thought/reasoning_section.dart b/lib/widgets/thought/reasoning_section.dart new file mode 100644 index 00000000..2e16eab3 --- /dev/null +++ b/lib/widgets/thought/reasoning_section.dart @@ -0,0 +1,67 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +class ReasoningSection extends StatelessWidget { + const ReasoningSection({super.key, required this.reasoningChunks}); + + final List reasoningChunks; + + @override + Widget build(BuildContext context) { + if (reasoningChunks.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + ), + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/thought/thought_content.dart b/lib/widgets/thought/thought_content.dart new file mode 100644 index 00000000..3445a886 --- /dev/null +++ b/lib/widgets/thought/thought_content.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:island/models/thought.dart'; +import 'package:island/widgets/content/markdown.dart'; +import 'package:island/widgets/thought/thought_proposal.dart'; + +class ThoughtContent extends StatelessWidget { + const ThoughtContent({ + super.key, + required this.isStreaming, + required this.streamingText, + this.thought, + }); + + final bool isStreaming; + final String streamingText; + final SnThinkingThought? thought; + + @override + Widget build(BuildContext context) { + if (isStreaming) { + // Streaming text with spinner + if (streamingText.isNotEmpty) { + return 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, + ), + ), + ), + ], + ); + } + return const SizedBox.shrink(); + } else { + // Regular thought content + if (thought!.content != null && thought!.content!.isNotEmpty) { + return 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, + ), + ], + ); + } + return const SizedBox.shrink(); + } + } +} diff --git a/lib/widgets/thought/thought_header.dart b/lib/widgets/thought/thought_header.dart new file mode 100644 index 00000000..8c3f7895 --- /dev/null +++ b/lib/widgets/thought/thought_header.dart @@ -0,0 +1,52 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +class ThoughtHeader extends StatelessWidget { + const ThoughtHeader({ + super.key, + required this.isStreaming, + required this.isUser, + }); + + final bool isStreaming; + final bool isUser; + + @override + Widget build(BuildContext context) { + if (!isStreaming) { + return 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, + ), + ), + ], + ); + } else { + return Text( + 'aiThought'.tr(), + style: Theme.of(context).textTheme.titleSmall!.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ); + } + } +} diff --git a/lib/widgets/thought/thought_shared.dart b/lib/widgets/thought/thought_shared.dart index 4551c8f9..959aaede 100644 --- a/lib/widgets/thought/thought_shared.dart +++ b/lib/widgets/thought/thought_shared.dart @@ -1,16 +1,17 @@ -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:island/widgets/thought/function_calls_section.dart'; +import 'package:island/widgets/thought/proposals_section.dart'; +import 'package:island/widgets/thought/reasoning_section.dart'; +import 'package:island/widgets/thought/thought_content.dart'; +import 'package:island/widgets/thought/thought_header.dart'; +import 'package:island/widgets/thought/token_info.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; List> _extractProposals(String content) { @@ -209,7 +210,7 @@ class ThoughtItem extends StatelessWidget { isStreaming || (!isStreaming && thought!.role == ThinkingThoughtRole.assistant); - final proposals = + final List> proposals = !isStreaming && thought!.content != null ? _extractProposals(thought!.content!) : []; @@ -220,42 +221,8 @@ class ThoughtItem extends StatelessWidget { 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), - ], + ThoughtHeader(isStreaming: isStreaming, isUser: isUser), + const Gap(8), // Content Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -271,299 +238,32 @@ class ThoughtItem extends StatelessWidget { 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, - ), - ], - ), - ], + ThoughtContent( + isStreaming: isStreaming, + streamingText: streamingText, + thought: thought, + ), // 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, - ), - ), - ), - ), - ], - ), - ), - ], + ReasoningSection(reasoningChunks: reasoningChunks), // 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, - ), - ), - ), - ), - ], - ], - ), - ), + FunctionCallsSection( + isStreaming: isStreaming, + streamingFunctionCalls: streamingFunctionCalls, + thought: thought, + ), + + // Token count and model name (for completed AI thoughts only) + if (!isStreaming && isAI && thought != null) + TokenInfo(thought: thought!), - // 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(), + if (!isStreaming && proposals.isNotEmpty && isAI) + ProposalsSection( + proposals: proposals, + onProposalAction: _handleProposalAction, ), - ], ], ), ), diff --git a/lib/widgets/thought/token_info.dart b/lib/widgets/thought/token_info.dart new file mode 100644 index 00000000..a05d4f0c --- /dev/null +++ b/lib/widgets/thought/token_info.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:island/models/thought.dart'; + +class TokenInfo extends StatelessWidget { + const TokenInfo({super.key, required this.thought}); + + final SnThinkingThought thought; + + @override + Widget build(BuildContext context) { + if (thought.tokenCount == null && thought.modelName == null) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + ), + ), + ], + ], + ), + ], + ); + } +}