From 6f6422c15e4e834ef570eb612943e772bb82a046 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 15 Nov 2025 23:02:25 +0800 Subject: [PATCH] :lipstick: Optimize thought function call style --- assets/i18n/en-US.json | 3 +- .../thought/function_calls_section.dart | 225 ++++++++++++------ lib/widgets/thought/thought_shared.dart | 72 +++++- 3 files changed, 215 insertions(+), 85 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 6d9bf5f7..08e73200 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1302,8 +1302,9 @@ "thoughtInputHint": "Ask sn-chan anything...", "thoughtNewConversation": "Start New Conversation", "thoughtParseError": "Failed to parse AI response", + "thoughtFunctionCall": "Use {}", "thoughtFunctionCallBegin": "Calling tool {}", - "thoughtFunctionCallFinish": "Tool {} respond", + "thoughtFunctionCallFinish": "{} responded", "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 53263064..70bf08da 100644 --- a/lib/widgets/thought/function_calls_section.dart +++ b/lib/widgets/thought/function_calls_section.dart @@ -2,110 +2,191 @@ import 'dart:convert'; 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:material_symbols_icons/material_symbols_icons.dart'; +import 'package:styled_widget/styled_widget.dart'; class FunctionCallsSection extends HookWidget { const FunctionCallsSection({ super.key, required this.isFinish, required this.isStreaming, - required this.functionCallData, + this.callData, + this.resultData, }); final bool isFinish; final bool isStreaming; - final String? functionCallData; + final String? callData; + final String? resultData; @override Widget build(BuildContext context) { - final isExpanded = useState(false); + String functionCallName; + if (callData != null) { + final parsed = jsonDecode(callData!) as Map; + functionCallName = (parsed['name'] as String?) ?? 'unknown'.tr(); + } else { + functionCallName = 'unknown'.tr(); + } + if (functionCallName.isEmpty) functionCallName = 'unknown'.tr(); - var functionCallName = - jsonDecode(functionCallData ?? '{}')?['name'] as String?; - if (functionCallName?.isEmpty ?? true) functionCallName = 'unknown'.tr(); + final showSpinner = isStreaming && !isFinish; + + final isExpanded = useState(false); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.tertiaryContainer, + ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 8), + minTileHeight: 24, + backgroundColor: Theme.of(context).colorScheme.tertiaryContainer, + collapsedBackgroundColor: + Theme.of(context).colorScheme.tertiaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + collapsedShape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + leading: Icon( + Symbols.hardware, + size: 16, + color: Theme.of(context).colorScheme.tertiary, + ), + trailing: SizedBox( + width: 30, // Specify desired width + height: 30, // Specify desired height + child: Icon( + isExpanded.value + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_up, + size: 16, + color: + isExpanded.value + ? Theme.of(context).colorScheme.tertiary + : Theme.of(context).colorScheme.tertiaryFixedDim, + ), + ), + title: Row( children: [ - InkWell( - onTap: () => isExpanded.value = !isExpanded.value, - child: Row( - children: [ - Icon( - Symbols.code, - size: 14, - color: Theme.of(context).colorScheme.tertiary, - ), - const Gap(4), - Expanded( - child: Text( - isFinish - ? 'thoughtFunctionCallFinish'.tr(args: []) - : 'thoughtFunctionCallBegin'.tr(args: []), - style: Theme.of(context).textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.tertiary, - ), - ), - ), - Icon( - isExpanded.value - ? Symbols.expand_more - : Symbols.expand_less, - size: 16, - color: Theme.of(context).colorScheme.tertiary, - ), - ], + Expanded( + child: Text( + 'thoughtFunctionCall'.tr(args: [functionCallName]), + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.tertiary, + ), ), ), - Visibility(visible: isExpanded.value, child: const Gap(4)), - Visibility( - visible: isExpanded.value, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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( - functionCallData!, - style: GoogleFonts.robotoMono( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface, - height: 1.3, - ), - ), - ), - ], + if (showSpinner) + const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), ), - ), ], ), + childrenPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + children: [ + if (callData != null) + _buildBlock(context, false, functionCallName, callData!), + if (resultData != null) ...[ + if (callData != null && resultData != null) const Gap(8), + _buildBlock(context, true, functionCallName, resultData!), + ], + ], ), ], ); } + + Widget _buildBlock( + BuildContext context, + bool isResult, + String name, + String data, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + spacing: 8, + children: [ + Icon( + isResult ? Symbols.check : Symbols.play_arrow_rounded, + size: 16, + fill: 1, + ), + Text( + isResult + ? "thoughtFunctionCallFinish".tr(args: [name]) + : "thoughtFunctionCallBegin".tr(args: [name]), + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + const Gap(4), + if (isResult) + Row( + spacing: 8, + children: [ + Icon(Symbols.update, size: 16), + Expanded( + child: Text( + 'Generated ${utf8.encode(data).length} bytes', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + SizedBox( + height: 16, + child: IconButton( + iconSize: 16, + icon: const Icon(Symbols.content_copy), + onPressed: () => Clipboard.setData(ClipboardData(text: data)), + tooltip: 'Copy response', + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + ), + ), + ], + ).opacity(0.8) + else + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + 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( + data, + 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 f326c264..f6b4a602 100644 --- a/lib/widgets/thought/thought_shared.dart +++ b/lib/widgets/thought/thought_shared.dart @@ -27,6 +27,13 @@ class StreamItem { final dynamic data; } +class FunctionCallPair { + const FunctionCallPair(this.call, [this.result]); + + final StreamItem? call; + final StreamItem? result; +} + class ThoughtChatState { final ValueNotifier sequenceId; final ValueNotifier> localThoughts; @@ -714,12 +721,59 @@ class ThoughtItem extends StatelessWidget { final List widgets = []; String currentText = ''; bool hasOpenText = false; - for (int i = 0; i < items.length; i++) { + int i = 0; + while (i < items.length) { final item = items[i]; if (item.type == 'text') { currentText += item.data as String; hasOpenText = true; - } else { + } else if (item.type == 'function_call') { + if (hasOpenText) { + bool isLastTextBlock = + !items.sublist(i).any((it) => it.type == 'text'); + widgets.add(buildTextRow(currentText, isLastTextBlock)); + currentText = ''; + hasOpenText = false; + } + // check next for result + StreamItem? result; + if (i + 1 < items.length && + items[i + 1].type == 'function_result' && + items[i + 1].data.callId == item.data.id) { + result = items[i + 1]; + i++; // skip it + } + widgets.add( + FunctionCallsSection( + isFinish: result != null, + isStreaming: isStreaming, + callData: JsonEncoder.withIndent(' ').convert(item.data.toJson()), + resultData: + result != null + ? JsonEncoder.withIndent(' ').convert(result.data.toJson()) + : null, + ), + ); + } else if (item.type == 'function_result') { + if (hasOpenText) { + bool isLastTextBlock = + !items.sublist(i).any((it) => it.type == 'text'); + widgets.add(buildTextRow(currentText, isLastTextBlock)); + currentText = ''; + hasOpenText = false; + } + // orphan result, treat as finished with call + widgets.add( + FunctionCallsSection( + isFinish: true, + isStreaming: isStreaming, + callData: null, + resultData: JsonEncoder.withIndent( + ' ', + ).convert(item.data.toJson()), + ), + ); + } else if (item.type == 'reasoning') { if (hasOpenText) { bool isLastTextBlock = !items.sublist(i).any((it) => it.type == 'text'); @@ -728,7 +782,11 @@ class ThoughtItem extends StatelessWidget { hasOpenText = false; } widgets.add(buildItemWidget(item)); + } else { + // ignore or throw + print('unknown item type ${item.type}'); } + i++; } if (hasOpenText) { widgets.add(buildTextRow(currentText, true)); @@ -781,16 +839,6 @@ class ThoughtItem extends StatelessWidget { 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}'; }