From ea0d132dce75345ac213922b280a4bb6715d06de Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 26 Oct 2025 20:57:35 +0800 Subject: [PATCH] :sparkles: AI proposal --- lib/models/thought.dart | 1 + lib/models/thought.freezed.dart | 49 ++++--- lib/models/thought.g.dart | 6 + lib/screens/thought/think.dart | 225 ++++++++++++++++++++++++++++++ lib/widgets/content/markdown.dart | 13 ++ 5 files changed, 274 insertions(+), 20 deletions(-) diff --git a/lib/models/thought.dart b/lib/models/thought.dart index 88edea4d..7236c30d 100644 --- a/lib/models/thought.dart +++ b/lib/models/thought.dart @@ -43,6 +43,7 @@ sealed class StreamThinkingRequest with _$StreamThinkingRequest { const factory StreamThinkingRequest({ required String userMessage, String? sequenceId, + @Default([]) List accpetProposals, }) = _StreamThinkingRequest; factory StreamThinkingRequest.fromJson(Map json) => diff --git a/lib/models/thought.freezed.dart b/lib/models/thought.freezed.dart index 3efe4ded..f1f58a21 100644 --- a/lib/models/thought.freezed.dart +++ b/lib/models/thought.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$StreamThinkingRequest { - String get userMessage; String? get sequenceId; + String get userMessage; String? get sequenceId; List get accpetProposals; /// Create a copy of StreamThinkingRequest /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $StreamThinkingRequestCopyWith get copyWith => _$StreamTh @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other.accpetProposals, accpetProposals)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,userMessage,sequenceId); +int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(accpetProposals)); @override String toString() { - return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId)'; + return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals)'; } @@ -48,7 +48,7 @@ abstract mixin class $StreamThinkingRequestCopyWith<$Res> { factory $StreamThinkingRequestCopyWith(StreamThinkingRequest value, $Res Function(StreamThinkingRequest) _then) = _$StreamThinkingRequestCopyWithImpl; @useResult $Res call({ - String userMessage, String? sequenceId + String userMessage, String? sequenceId, List accpetProposals }); @@ -65,11 +65,12 @@ class _$StreamThinkingRequestCopyWithImpl<$Res> /// Create a copy of StreamThinkingRequest /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? userMessage = null,Object? sequenceId = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,}) { return _then(_self.copyWith( userMessage: null == userMessage ? _self.userMessage : userMessage // ignore: cast_nullable_to_non_nullable as String,sequenceId: freezed == sequenceId ? _self.sequenceId : sequenceId // ignore: cast_nullable_to_non_nullable -as String?, +as String?,accpetProposals: null == accpetProposals ? _self.accpetProposals : accpetProposals // ignore: cast_nullable_to_non_nullable +as List, )); } @@ -151,10 +152,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String userMessage, String? sequenceId)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String userMessage, String? sequenceId, List accpetProposals)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _StreamThinkingRequest() when $default != null: -return $default(_that.userMessage,_that.sequenceId);case _: +return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals);case _: return orElse(); } @@ -172,10 +173,10 @@ return $default(_that.userMessage,_that.sequenceId);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String userMessage, String? sequenceId) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String userMessage, String? sequenceId, List accpetProposals) $default,) {final _that = this; switch (_that) { case _StreamThinkingRequest(): -return $default(_that.userMessage,_that.sequenceId);} +return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals);} } /// A variant of `when` that fallback to returning `null` /// @@ -189,10 +190,10 @@ return $default(_that.userMessage,_that.sequenceId);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String userMessage, String? sequenceId)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String userMessage, String? sequenceId, List accpetProposals)? $default,) {final _that = this; switch (_that) { case _StreamThinkingRequest() when $default != null: -return $default(_that.userMessage,_that.sequenceId);case _: +return $default(_that.userMessage,_that.sequenceId,_that.accpetProposals);case _: return null; } @@ -204,11 +205,18 @@ return $default(_that.userMessage,_that.sequenceId);case _: @JsonSerializable() class _StreamThinkingRequest implements StreamThinkingRequest { - const _StreamThinkingRequest({required this.userMessage, this.sequenceId}); + const _StreamThinkingRequest({required this.userMessage, this.sequenceId, final List accpetProposals = const []}): _accpetProposals = accpetProposals; factory _StreamThinkingRequest.fromJson(Map json) => _$StreamThinkingRequestFromJson(json); @override final String userMessage; @override final String? sequenceId; + final List _accpetProposals; +@override@JsonKey() List get accpetProposals { + if (_accpetProposals is EqualUnmodifiableListView) return _accpetProposals; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_accpetProposals); +} + /// Create a copy of StreamThinkingRequest /// with the given fields replaced by the non-null parameter values. @@ -223,16 +231,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _StreamThinkingRequest&&(identical(other.userMessage, userMessage) || other.userMessage == userMessage)&&(identical(other.sequenceId, sequenceId) || other.sequenceId == sequenceId)&&const DeepCollectionEquality().equals(other._accpetProposals, _accpetProposals)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,userMessage,sequenceId); +int get hashCode => Object.hash(runtimeType,userMessage,sequenceId,const DeepCollectionEquality().hash(_accpetProposals)); @override String toString() { - return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId)'; + return 'StreamThinkingRequest(userMessage: $userMessage, sequenceId: $sequenceId, accpetProposals: $accpetProposals)'; } @@ -243,7 +251,7 @@ abstract mixin class _$StreamThinkingRequestCopyWith<$Res> implements $StreamThi factory _$StreamThinkingRequestCopyWith(_StreamThinkingRequest value, $Res Function(_StreamThinkingRequest) _then) = __$StreamThinkingRequestCopyWithImpl; @override @useResult $Res call({ - String userMessage, String? sequenceId + String userMessage, String? sequenceId, List accpetProposals }); @@ -260,11 +268,12 @@ class __$StreamThinkingRequestCopyWithImpl<$Res> /// Create a copy of StreamThinkingRequest /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? userMessage = null,Object? sequenceId = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? userMessage = null,Object? sequenceId = freezed,Object? accpetProposals = null,}) { return _then(_StreamThinkingRequest( userMessage: null == userMessage ? _self.userMessage : userMessage // ignore: cast_nullable_to_non_nullable as String,sequenceId: freezed == sequenceId ? _self.sequenceId : sequenceId // ignore: cast_nullable_to_non_nullable -as String?, +as String?,accpetProposals: null == accpetProposals ? _self._accpetProposals : accpetProposals // ignore: cast_nullable_to_non_nullable +as List, )); } diff --git a/lib/models/thought.g.dart b/lib/models/thought.g.dart index 6f31ebe7..257dc0b0 100644 --- a/lib/models/thought.g.dart +++ b/lib/models/thought.g.dart @@ -11,6 +11,11 @@ _StreamThinkingRequest _$StreamThinkingRequestFromJson( ) => _StreamThinkingRequest( userMessage: json['user_message'] as String, sequenceId: json['sequence_id'] as String?, + accpetProposals: + (json['accpet_proposals'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], ); Map _$StreamThinkingRequestToJson( @@ -18,6 +23,7 @@ Map _$StreamThinkingRequestToJson( ) => { 'user_message': instance.userMessage, 'sequence_id': instance.sequenceId, + 'accpet_proposals': instance.accpetProposals, }; _SnThinkingChunk _$SnThinkingChunkFromJson(Map json) => diff --git a/lib/screens/thought/think.dart b/lib/screens/thought/think.dart index e7dcab85..3a5bd91b 100644 --- a/lib/screens/thought/think.dart +++ b/lib/screens/thought/think.dart @@ -16,8 +16,13 @@ import "package:island/widgets/app_scaffold.dart"; import "package:island/widgets/content/markdown.dart"; import "package:island/widgets/response.dart"; import "package:island/widgets/thought/thought_sequence_list.dart"; +import "package:island/route.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'; @@ -40,6 +45,36 @@ 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': + // Navigate to post creation screen with the proposal content + AppRouter.push( + context, + '/posts/compose?initialContent=${Uri.encodeComponent(proposal['content'] ?? '')}&source=ai_proposal', + ); + break; + default: + // Show a snackbar for unsupported proposal types + showSnackBar('Unsupported proposal type: ${proposal['type']}'); + } + } + final selectedSequenceId = useState(null); final thoughts = selectedSequenceId.value != null @@ -124,6 +159,7 @@ class ThoughtScreen extends HookConsumerWidget { final request = StreamThinkingRequest( userMessage: userMessage, sequenceId: selectedSequenceId.value, + accpetProposals: ['post_create'], ); try { @@ -290,6 +326,10 @@ class ThoughtScreen extends HookConsumerWidget { 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), @@ -350,8 +390,51 @@ class ThoughtScreen extends HookConsumerWidget { 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 (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(), + ), + ], ], ), ); @@ -406,6 +489,16 @@ class ThoughtScreen extends HookConsumerWidget { 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) ...[ @@ -616,3 +709,135 @@ 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 create a post', + ).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/content/markdown.dart b/lib/widgets/content/markdown.dart index 0108c3f8..e9633a62 100644 --- a/lib/widgets/content/markdown.dart +++ b/lib/widgets/content/markdown.dart @@ -32,6 +32,9 @@ class MarkdownTextContent extends HookConsumerWidget { final EdgeInsets? linesMargin; final bool isSelectable; final List? attachments; + final List extraInlineSyntaxList; + final List extraBlockSyntaxList; + final List extraGenerators; const MarkdownTextContent({ super.key, @@ -43,6 +46,9 @@ class MarkdownTextContent extends HookConsumerWidget { this.isSelectable = false, this.linesMargin, this.attachments, + this.extraInlineSyntaxList = const [], + this.extraBlockSyntaxList = const [], + this.extraGenerators = const [], }); @override @@ -218,7 +224,10 @@ class MarkdownTextContent extends HookConsumerWidget { highlightGenerator, spoilerGenerator, stickerGenerator, + ...extraGenerators, ], + extraInlineSyntaxList: extraInlineSyntaxList, + extraBlockSyntaxList: extraBlockSyntaxList, ), ); } @@ -227,6 +236,8 @@ class MarkdownTextContent extends HookConsumerWidget { bool isDark = false, EdgeInsets? linesMargin, List generators = const [], + List extraInlineSyntaxList = const [], + List extraBlockSyntaxList = const [], }) { return MarkdownGenerator( generators: [latexGenerator, ...generators], @@ -236,7 +247,9 @@ class MarkdownTextContent extends HookConsumerWidget { _SpoilerInlineSyntax(), _StickerInlineSyntax(), LatexSyntax(isDark), + ...extraInlineSyntaxList, ], + blockSyntaxList: extraBlockSyntaxList, linesMargin: linesMargin ?? EdgeInsets.symmetric(vertical: 4), ); }