💄 Optimize thought function call style
This commit is contained in:
@@ -1302,8 +1302,9 @@
|
|||||||
"thoughtInputHint": "Ask sn-chan anything...",
|
"thoughtInputHint": "Ask sn-chan anything...",
|
||||||
"thoughtNewConversation": "Start New Conversation",
|
"thoughtNewConversation": "Start New Conversation",
|
||||||
"thoughtParseError": "Failed to parse AI response",
|
"thoughtParseError": "Failed to parse AI response",
|
||||||
|
"thoughtFunctionCall": "Use {}",
|
||||||
"thoughtFunctionCallBegin": "Calling tool {}",
|
"thoughtFunctionCallBegin": "Calling tool {}",
|
||||||
"thoughtFunctionCallFinish": "Tool {} respond",
|
"thoughtFunctionCallFinish": "{} responded",
|
||||||
"aiThought": "AI Thought",
|
"aiThought": "AI Thought",
|
||||||
"aiThoughtTitle": "Let sn-chan think",
|
"aiThoughtTitle": "Let sn-chan think",
|
||||||
"postReferenceUnavailable": "Referenced post is unavailable",
|
"postReferenceUnavailable": "Referenced post is unavailable",
|
||||||
|
|||||||
@@ -2,110 +2,191 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
class FunctionCallsSection extends HookWidget {
|
class FunctionCallsSection extends HookWidget {
|
||||||
const FunctionCallsSection({
|
const FunctionCallsSection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.isFinish,
|
required this.isFinish,
|
||||||
required this.isStreaming,
|
required this.isStreaming,
|
||||||
required this.functionCallData,
|
this.callData,
|
||||||
|
this.resultData,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isFinish;
|
final bool isFinish;
|
||||||
final bool isStreaming;
|
final bool isStreaming;
|
||||||
final String? functionCallData;
|
final String? callData;
|
||||||
|
final String? resultData;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 =
|
final showSpinner = isStreaming && !isFinish;
|
||||||
jsonDecode(functionCallData ?? '{}')?['name'] as String?;
|
|
||||||
if (functionCallName?.isEmpty ?? true) functionCallName = 'unknown'.tr();
|
final isExpanded = useState(false);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
ExpansionTile(
|
||||||
padding: const EdgeInsets.all(8),
|
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
decoration: BoxDecoration(
|
minTileHeight: 24,
|
||||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
backgroundColor: Theme.of(context).colorScheme.tertiaryContainer,
|
||||||
|
collapsedBackgroundColor:
|
||||||
|
Theme.of(context).colorScheme.tertiaryContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
collapsedShape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Column(
|
leading: Icon(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
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: [
|
children: [
|
||||||
InkWell(
|
Expanded(
|
||||||
onTap: () => isExpanded.value = !isExpanded.value,
|
child: Text(
|
||||||
child: Row(
|
'thoughtFunctionCall'.tr(args: [functionCallName]),
|
||||||
children: [
|
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||||
Icon(
|
fontWeight: FontWeight.w600,
|
||||||
Symbols.code,
|
color: Theme.of(context).colorScheme.tertiary,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Visibility(visible: isExpanded.value, child: const Gap(4)),
|
if (showSpinner)
|
||||||
Visibility(
|
const SizedBox(
|
||||||
visible: isExpanded.value,
|
height: 14,
|
||||||
child: Column(
|
width: 14,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ class StreamItem {
|
|||||||
final dynamic data;
|
final dynamic data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FunctionCallPair {
|
||||||
|
const FunctionCallPair(this.call, [this.result]);
|
||||||
|
|
||||||
|
final StreamItem? call;
|
||||||
|
final StreamItem? result;
|
||||||
|
}
|
||||||
|
|
||||||
class ThoughtChatState {
|
class ThoughtChatState {
|
||||||
final ValueNotifier<String?> sequenceId;
|
final ValueNotifier<String?> sequenceId;
|
||||||
final ValueNotifier<List<SnThinkingThought>> localThoughts;
|
final ValueNotifier<List<SnThinkingThought>> localThoughts;
|
||||||
@@ -714,12 +721,59 @@ class ThoughtItem extends StatelessWidget {
|
|||||||
final List<Widget> widgets = [];
|
final List<Widget> widgets = [];
|
||||||
String currentText = '';
|
String currentText = '';
|
||||||
bool hasOpenText = false;
|
bool hasOpenText = false;
|
||||||
for (int i = 0; i < items.length; i++) {
|
int i = 0;
|
||||||
|
while (i < items.length) {
|
||||||
final item = items[i];
|
final item = items[i];
|
||||||
if (item.type == 'text') {
|
if (item.type == 'text') {
|
||||||
currentText += item.data as String;
|
currentText += item.data as String;
|
||||||
hasOpenText = true;
|
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) {
|
if (hasOpenText) {
|
||||||
bool isLastTextBlock =
|
bool isLastTextBlock =
|
||||||
!items.sublist(i).any((it) => it.type == 'text');
|
!items.sublist(i).any((it) => it.type == 'text');
|
||||||
@@ -728,7 +782,11 @@ class ThoughtItem extends StatelessWidget {
|
|||||||
hasOpenText = false;
|
hasOpenText = false;
|
||||||
}
|
}
|
||||||
widgets.add(buildItemWidget(item));
|
widgets.add(buildItemWidget(item));
|
||||||
|
} else {
|
||||||
|
// ignore or throw
|
||||||
|
print('unknown item type ${item.type}');
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
if (hasOpenText) {
|
if (hasOpenText) {
|
||||||
widgets.add(buildTextRow(currentText, true));
|
widgets.add(buildTextRow(currentText, true));
|
||||||
@@ -781,16 +839,6 @@ class ThoughtItem extends StatelessWidget {
|
|||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'reasoning':
|
case 'reasoning':
|
||||||
return ReasoningSection(reasoningChunks: [item.data]);
|
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:
|
default:
|
||||||
throw 'unknown item type ${item.type}';
|
throw 'unknown item type ${item.type}';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user