diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 83d26ce5..6d9bf5f7 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1302,7 +1302,8 @@ "thoughtInputHint": "Ask sn-chan anything...", "thoughtNewConversation": "Start New Conversation", "thoughtParseError": "Failed to parse AI response", - "thoughtFunctionCall": "Function Call", + "thoughtFunctionCallBegin": "Calling tool {}", + "thoughtFunctionCallFinish": "Tool {} respond", "aiThought": "AI Thought", "aiThoughtTitle": "Let sn-chan think", "postReferenceUnavailable": "Referenced post is unavailable", diff --git a/lib/widgets/thought/function_calls_section.dart b/lib/widgets/thought/function_calls_section.dart index 06f85e52..53263064 100644 --- a/lib/widgets/thought/function_calls_section.dart +++ b/lib/widgets/thought/function_calls_section.dart @@ -2,46 +2,30 @@ 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:material_symbols_icons/material_symbols_icons.dart'; -import 'package:island/models/thought.dart'; -class FunctionCallsSection extends StatefulWidget { +class FunctionCallsSection extends HookWidget { const FunctionCallsSection({ super.key, + required this.isFinish, required this.isStreaming, - required this.streamingFunctionCalls, - this.thought, + required this.functionCallData, }); + final bool isFinish; final bool isStreaming; - final List streamingFunctionCalls; - final SnThinkingThought? thought; - - @override - State createState() => _FunctionCallsSectionState(); -} - -class _FunctionCallsSectionState extends State { - bool _isExpanded = false; - - bool get _hasFunctionCalls { - if (widget.isStreaming) { - return widget.streamingFunctionCalls.isNotEmpty; - } else { - return widget.thought!.parts.isNotEmpty && - widget.thought!.parts.any( - (part) => part.type == ThinkingMessagePartType.functionCall, - ); - } - } + final String? functionCallData; @override Widget build(BuildContext context) { - if (!_hasFunctionCalls) { - return const SizedBox.shrink(); - } + final isExpanded = useState(false); + + var functionCallName = + jsonDecode(functionCallData ?? '{}')?['name'] as String?; + if (functionCallName?.isEmpty ?? true) functionCallName = 'unknown'.tr(); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -56,7 +40,7 @@ class _FunctionCallsSectionState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ InkWell( - onTap: () => setState(() => _isExpanded = !_isExpanded), + onTap: () => isExpanded.value = !isExpanded.value, child: Row( children: [ Icon( @@ -67,7 +51,9 @@ class _FunctionCallsSectionState extends State { const Gap(4), Expanded( child: Text( - 'thoughtFunctionCall'.tr(), + isFinish + ? 'thoughtFunctionCallFinish'.tr(args: []) + : 'thoughtFunctionCallBegin'.tr(args: []), style: Theme.of(context).textTheme.bodySmall!.copyWith( fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.tertiary, @@ -75,81 +61,44 @@ class _FunctionCallsSectionState extends State { ), ), Icon( - _isExpanded ? Symbols.expand_more : Symbols.expand_less, + isExpanded.value + ? Symbols.expand_more + : Symbols.expand_less, size: 16, color: Theme.of(context).colorScheme.tertiary, ), ], ), ), - Visibility(visible: _isExpanded, child: const Gap(4)), + Visibility(visible: isExpanded.value, child: const Gap(4)), Visibility( - visible: _isExpanded, + visible: isExpanded.value, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (widget.isStreaming) ...[ - ...widget.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, - ), - ), + 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, ), ), - ] else ...[ - ...widget.thought!.parts - .where( - (part) => - part.type == - ThinkingMessagePartType.functionCall, - ) - .map( - (part) => 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(part.functionCall?.toJson() ?? {}), - style: GoogleFonts.robotoMono( - fontSize: 11, - color: - Theme.of(context).colorScheme.onSurface, - height: 1.3, - ), - ), - ), - ), - ], + child: SelectableText( + functionCallData!, + style: GoogleFonts.robotoMono( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface, + height: 1.3, + ), + ), + ), ], ), ), diff --git a/lib/widgets/thought/thought_shared.dart b/lib/widgets/thought/thought_shared.dart index 68735214..f326c264 100644 --- a/lib/widgets/thought/thought_shared.dart +++ b/lib/widgets/thought/thought_shared.dart @@ -21,6 +21,12 @@ import 'package:island/widgets/thought/token_info.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; +class StreamItem { + const StreamItem(this.type, this.data); + final String type; + final dynamic data; +} + class ThoughtChatState { final ValueNotifier sequenceId; final ValueNotifier> localThoughts; @@ -28,8 +34,7 @@ class ThoughtChatState { final TextEditingController messageController; final ScrollController scrollController; final ValueNotifier isStreaming; - final ValueNotifier> streamingParts; - final ValueNotifier> reasoningChunks; + final ValueNotifier> streamingItems; final ListController listController; final ValueNotifier> bottomGradientNotifier; final Future Function() sendMessage; @@ -41,8 +46,7 @@ class ThoughtChatState { required this.messageController, required this.scrollController, required this.isStreaming, - required this.streamingParts, - required this.reasoningChunks, + required this.streamingItems, required this.listController, required this.bottomGradientNotifier, required this.sendMessage, @@ -67,8 +71,7 @@ ThoughtChatState useThoughtChat( final messageController = useTextEditingController(); final scrollController = useScrollController(); final isStreaming = useState(false); - final streamingParts = useState>([]); - final reasoningChunks = useState>([]); + final streamingItems = useState>([]); final listController = useMemoized(() => ListController(), []); @@ -143,8 +146,7 @@ ThoughtChatState useThoughtChat( try { isStreaming.value = true; - streamingParts.value = []; - reasoningChunks.value = []; + streamingItems.value = []; final apiClient = ref.read(apiClientProvider); final response = await apiClient.post( @@ -177,42 +179,31 @@ ThoughtChatState useThoughtChat( final type = event['type']; final eventData = event['data']; if (type == 'text') { - if (streamingParts.value.isNotEmpty && - streamingParts.value.last.type == - ThinkingMessagePartType.text) { - final last = streamingParts.value.last; - final newParts = [...streamingParts.value]; - newParts[newParts.length - 1] = last.copyWith( - text: (last.text ?? '') + eventData, - ); - streamingParts.value = newParts; - } else { - streamingParts.value = [ - ...streamingParts.value, - SnThinkingMessagePart( - type: ThinkingMessagePartType.text, - text: eventData, - ), - ]; - } + streamingItems.value = [ + ...streamingItems.value, + StreamItem('text', eventData), + ]; } else if (type == 'function_call') { - streamingParts.value = [ - ...streamingParts.value, - SnThinkingMessagePart( - type: ThinkingMessagePartType.functionCall, - functionCall: SnFunctionCall.fromJson(eventData), + streamingItems.value = [ + ...streamingItems.value, + StreamItem( + 'function_call', + SnFunctionCall.fromJson(eventData), ), ]; } else if (type == 'function_result') { - streamingParts.value = [ - ...streamingParts.value, - SnThinkingMessagePart( - type: ThinkingMessagePartType.functionResult, - functionResult: SnFunctionResult.fromJson(eventData), + streamingItems.value = [ + ...streamingItems.value, + StreamItem( + 'function_result', + SnFunctionResult.fromJson(eventData), ), ]; } else if (type == 'reasoning') { - reasoningChunks.value = [...reasoningChunks.value, eventData]; + streamingItems.value = [ + ...streamingItems.value, + StreamItem('reasoning', eventData), + ]; } } else if (line.startsWith('topic: ')) { final jsonStr = line.substring(7); @@ -336,8 +327,7 @@ ThoughtChatState useThoughtChat( messageController: messageController, scrollController: scrollController, isStreaming: isStreaming, - streamingParts: streamingParts, - reasoningChunks: reasoningChunks, + streamingItems: streamingItems, listController: listController, bottomGradientNotifier: bottomGradientNotifier, sendMessage: sendMessage, @@ -392,40 +382,16 @@ class ThoughtChatInterface extends HookConsumerWidget { (chatState.isStreaming.value ? 1 : 0), itemBuilder: (context, index) { if (chatState.isStreaming.value && index == 0) { - final streamingText = chatState.streamingParts.value - .where( - (p) => p.type == ThinkingMessagePartType.text, - ) - .map((p) => p.text ?? '') - .join(''); - final streamingFunctionCalls = - chatState.streamingParts.value - .where( - (p) => - p.type == - ThinkingMessagePartType.functionCall, - ) - .map( - (p) => JsonEncoder.withIndent( - ' ', - ).convert(p.functionCall?.toJson() ?? {}), - ) - .toList(); return ThoughtItem( isStreaming: true, - streamingText: streamingText, - reasoningChunks: chatState.reasoningChunks.value, - streamingFunctionCalls: streamingFunctionCalls, + streamingItems: chatState.streamingItems.value, ); } final thoughtIndex = chatState.isStreaming.value ? index - 1 : index; final thought = chatState.localThoughts.value[thoughtIndex]; - return ThoughtItem( - thought: thought, - thoughtIndex: thoughtIndex, - ); + return ThoughtItem(thought: thought); }, ), ), @@ -661,39 +627,21 @@ class ThoughtItem extends StatelessWidget { const ThoughtItem({ super.key, this.thought, - this.thoughtIndex, this.isStreaming = false, - this.streamingText = '', - this.reasoningChunks = const [], - this.streamingFunctionCalls = const [], + this.streamingItems, }) : assert( - (thought != null && !isStreaming) || (thought == null && isStreaming), - 'Either thought or streaming parameters must be provided', + (streamingItems != null && isStreaming) || + (thought != null && !isStreaming), + 'Either streamingItems or thought must be provided', ); final SnThinkingThought? thought; - final int? thoughtIndex; final bool isStreaming; - final String streamingText; - final List reasoningChunks; - final List streamingFunctionCalls; + final List? streamingItems; @override Widget build(BuildContext context) { final isUser = !isStreaming && thought!.role == ThinkingThoughtRole.user; - final isAI = - isStreaming || - (!isStreaming && thought!.role == ThinkingThoughtRole.assistant); - - final List> proposals = - !isStreaming - ? _extractProposals( - thought!.parts - .where((p) => p.type == ThinkingMessagePartType.text) - .map((p) => p.text ?? '') - .join(''), - ) - : []; return Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -717,66 +665,134 @@ class ThoughtItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, - children: [ - // Main content - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible( - child: ThoughtContent( - isStreaming: isStreaming, - streamingText: streamingText, - thought: thought, - ), - ), - if (isStreaming && isAI) - SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2.5, - padding: const EdgeInsets.all(4), - ), - ), - ], - ), - - // Reasoning chunks (streaming only) - if (reasoningChunks.isNotEmpty) - ReasoningSection(reasoningChunks: reasoningChunks), - - // Function calls - if (streamingFunctionCalls.isNotEmpty || - (thought?.parts.isNotEmpty ?? false) && - thought!.parts.any( - (part) => - part.type == ThinkingMessagePartType.functionCall, - )) - FunctionCallsSection( - isStreaming: isStreaming, - streamingFunctionCalls: streamingFunctionCalls, - thought: thought, - ), - - // Token count and model name (for completed AI thoughts only) - if (!isStreaming && - isAI && - thought != null && - !thought!.id.startsWith('error-')) - TokenInfo(thought: thought!), - - // Proposals (for completed AI thoughts only) - if (!isStreaming && proposals.isNotEmpty && isAI) - ProposalsSection( - proposals: proposals, - onProposalAction: _handleProposalAction, - ), - ], + children: buildWidgetsList(), ), ), ], ), ); } + + List buildWidgetsList() { + final List items = + isStreaming + ? (streamingItems ?? []) + : thought!.parts.map((p) { + String type; + switch (p.type) { + case ThinkingMessagePartType.text: + type = 'text'; + break; + case ThinkingMessagePartType.functionCall: + type = 'function_call'; + break; + case ThinkingMessagePartType.functionResult: + type = 'function_result'; + break; + } + return StreamItem( + type, + p.type == ThinkingMessagePartType.text + ? p.text ?? '' + : p.functionCall ?? p.functionResult, + ); + }).toList(); + + final isAI = + isStreaming || + (!isStreaming && thought!.role == ThinkingThoughtRole.assistant); + final List> proposals = + !isStreaming + ? _extractProposals( + thought!.parts + .where((p) => p.type == ThinkingMessagePartType.text) + .map((p) => p.text ?? '') + .join(), + ) + : []; + + final List widgets = []; + String currentText = ''; + bool hasOpenText = false; + for (int i = 0; i < items.length; i++) { + final item = items[i]; + if (item.type == 'text') { + currentText += item.data as String; + hasOpenText = true; + } else { + if (hasOpenText) { + bool isLastTextBlock = + !items.sublist(i).any((it) => it.type == 'text'); + widgets.add(buildTextRow(currentText, isLastTextBlock)); + currentText = ''; + hasOpenText = false; + } + widgets.add(buildItemWidget(item)); + } + } + if (hasOpenText) { + widgets.add(buildTextRow(currentText, true)); + } + + // The proposals and token info at the end + if (!isStreaming && proposals.isNotEmpty && isAI) { + widgets.add( + ProposalsSection( + proposals: proposals, + onProposalAction: _handleProposalAction, + ), + ); + } + if (!isStreaming && + isAI && + thought != null && + !thought!.id.startsWith('error-')) { + widgets.add(TokenInfo(thought: thought!)); + } + return widgets; + } + + Row buildTextRow(String text, bool hasSpinner) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: ThoughtContent( + isStreaming: isStreaming && hasSpinner, + streamingText: text, + thought: thought, + ), + ), + if (isStreaming && hasSpinner) + const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + padding: EdgeInsets.all(4), + ), + ), + ], + ); + } + + Widget buildItemWidget(StreamItem item) { + switch (item.type) { + case 'reasoning': + return ReasoningSection(reasoningChunks: [item.data]); + case 'function_call': + case 'function_result': + final jsonStr = JsonEncoder.withIndent( + ' ', + ).convert(item.data.toJson()); + return FunctionCallsSection( + isFinish: item.type == 'function_result', + isStreaming: isStreaming, + functionCallData: jsonStr, + ); + default: + throw 'unknown item type ${item.type}'; + } + } }