🐛 Bug fixes for the AI thought

This commit is contained in:
2025-12-21 21:48:46 +08:00
parent 87a54625aa
commit eb89d9223a
7 changed files with 403 additions and 386 deletions

View File

@@ -28,93 +28,45 @@ class ThoughtContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (isStreaming) {
// Streaming text with spinner
if (streamingText.isNotEmpty) {
final isStreamingError = streamingText.startsWith('Error:');
return Container(
padding: isStreamingError ? const EdgeInsets.all(8) : EdgeInsets.zero,
decoration:
isStreamingError
? BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.error,
width: 1,
),
borderRadius: BorderRadius.circular(8),
)
: null,
child: MarkdownTextContent(
isSelectable: true,
content: streamingText,
extraBlockSyntaxList: [ProposalBlockSyntax()],
textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
color:
isStreamingError ? Theme.of(context).colorScheme.error : null,
),
extraGenerators: [
ProposalGenerator(
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
foregroundColor:
Theme.of(context).colorScheme.onSecondaryContainer,
borderColor: Theme.of(context).colorScheme.outline,
final content = streamingText.isNotEmpty
? streamingText
: thought != null
? thought!.parts
.where((p) => p.type == ThinkingMessagePartType.text)
.map((p) => p.text ?? '')
.join('')
: '';
if (content.isEmpty) return const SizedBox.shrink();
final isError = content.startsWith('Error:') || _isErrorMessage;
return Container(
padding: isError ? const EdgeInsets.all(8) : EdgeInsets.zero,
decoration: isError
? BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.error,
width: 1,
),
],
borderRadius: BorderRadius.circular(8),
)
: null,
child: MarkdownTextContent(
isSelectable: true,
content: content,
extraBlockSyntaxList: [ProposalBlockSyntax()],
textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: isError ? Theme.of(context).colorScheme.error : null,
),
extraGenerators: [
ProposalGenerator(
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer,
borderColor: Theme.of(context).colorScheme.outline,
),
);
}
return const SizedBox.shrink();
} else {
// Regular thought content - render parts
if (thought!.parts.isNotEmpty) {
final textParts = thought!.parts
.where((p) => p.type == ThinkingMessagePartType.text)
.map((p) => p.text ?? '')
.join('');
if (textParts.isNotEmpty) {
return Container(
padding:
_isErrorMessage
? const EdgeInsets.symmetric(horizontal: 12, vertical: 4)
: EdgeInsets.zero,
decoration:
_isErrorMessage
? BoxDecoration(
color: Theme.of(
context,
).colorScheme.error.withOpacity(0.1),
border: Border.all(
color: Theme.of(context).colorScheme.error,
width: 1,
),
borderRadius: BorderRadius.circular(8),
)
: null,
child: MarkdownTextContent(
isSelectable: true,
content: textParts,
extraBlockSyntaxList: [ProposalBlockSyntax()],
textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
color:
_isErrorMessage
? Theme.of(context).colorScheme.error
: null,
),
extraGenerators: [
ProposalGenerator(
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
foregroundColor:
Theme.of(context).colorScheme.onSecondaryContainer,
borderColor: Theme.of(context).colorScheme.outline,
),
],
),
);
}
}
return const SizedBox.shrink();
}
],
),
);
}
}

View File

@@ -74,6 +74,7 @@ ThoughtChatState useThoughtChat(
String? initialSequenceId,
List<SnThinkingThought>? initialThoughts,
String? initialTopic,
String? initialMessage,
List<Map<String, dynamic>> attachedMessages = const [],
List<String> attachedPosts = const [],
VoidCallback? onSequenceIdChanged,
@@ -117,11 +118,13 @@ ThoughtChatState useThoughtChat(
useEffect(() {
if (localThoughts.value.isNotEmpty || isStreaming.value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
if (scrollController.hasClients) {
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
return null;
@@ -141,10 +144,12 @@ ThoughtChatState useThoughtChat(
return () => scrollController.removeListener(onScroll);
}, [scrollController]);
Future<void> sendMessage() async {
if (messageController.text.trim().isEmpty) return;
Future<void> sendMessage({String? message}) async {
if (message == null && messageController.text.trim().isEmpty) {
return;
}
final userMessage = messageController.text.trim();
final userMessage = message ?? messageController.text.trim();
// Add user message to local thoughts
final userInfo = ref.read(userInfoProvider);
@@ -177,8 +182,9 @@ ThoughtChatState useThoughtChat(
accpetProposals: ['post_create'],
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
serviceId:
selectedServiceId.value.isNotEmpty ? selectedServiceId.value : null,
serviceId: selectedServiceId.value.isNotEmpty
? selectedServiceId.value
: null,
);
try {
@@ -309,8 +315,8 @@ ThoughtChatState useThoughtChat(
final now = DateTime.now();
final errorMessage =
error is DioException && error.response?.data is ResponseBody
? 'toughtParseError'.tr()
: error.toString();
? 'toughtParseError'.tr()
: error.toString();
final errorThought = SnThinkingThought(
id: 'error-${DateTime.now().millisecondsSinceEpoch}',
parts: [
@@ -368,6 +374,15 @@ ThoughtChatState useThoughtChat(
}
}
useEffect(() {
if (initialMessage?.isNotEmpty ?? false) {
WidgetsBinding.instance.addPostFrameCallback((_) {
sendMessage(message: initialMessage);
});
}
return null;
}, [initialMessage]);
return ThoughtChatState(
sequenceId: sequenceId,
localThoughts: localThoughts,
@@ -388,6 +403,7 @@ class ThoughtChatInterface extends HookConsumerWidget {
final List<SnThinkingThought>? initialThoughts;
final String? initialSequenceId;
final String? initialTopic;
final String? initialMessage;
final List<Map<String, dynamic>> attachedMessages;
final List<String> attachedPosts;
final bool isDisabled;
@@ -397,6 +413,7 @@ class ThoughtChatInterface extends HookConsumerWidget {
this.initialThoughts,
this.initialSequenceId,
this.initialTopic,
this.initialMessage,
this.attachedMessages = const [],
this.attachedPosts = const [],
this.isDisabled = false,
@@ -415,6 +432,7 @@ class ThoughtChatInterface extends HookConsumerWidget {
initialSequenceId: initialSequenceId,
initialThoughts: initialThoughts,
initialTopic: initialTopic,
initialMessage: initialMessage,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
);
@@ -445,84 +463,78 @@ class ThoughtChatInterface extends HookConsumerWidget {
Expanded(
child:
previousInputHeight != null &&
previousInputHeight != inputHeight.value
? TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: previousInputHeight,
end: inputHeight.value,
),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder:
(context, height, child) =>
SuperListView.builder(
listController: chatState.listController,
controller: chatState.scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(
context,
).padding.bottom +
8 +
height,
),
reverse: true,
itemCount:
chatState.localThoughts.value.length +
(chatState.isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isStreaming.value &&
index == 0) {
return ThoughtItem(
isStreaming: true,
streamingItems:
chatState.streamingItems.value,
);
}
final thoughtIndex =
chatState.isStreaming.value
? index - 1
: index;
final thought =
chatState
.localThoughts
.value[thoughtIndex];
return ThoughtItem(thought: thought);
},
),
)
: SuperListView.builder(
listController: chatState.listController,
controller: chatState.scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
8 +
inputHeight.value,
),
reverse: true,
itemCount:
chatState.localThoughts.value.length +
(chatState.isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingItems:
chatState.streamingItems.value,
);
}
final thoughtIndex =
chatState.isStreaming.value
previousInputHeight != inputHeight.value
? TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: previousInputHeight,
end: inputHeight.value,
),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder: (context, height, child) =>
SuperListView.builder(
listController: chatState.listController,
controller: chatState.scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
8 +
height,
),
reverse: true,
itemCount:
chatState.localThoughts.value.length +
(chatState.isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isStreaming.value &&
index == 0) {
return ThoughtItem(
isStreaming: true,
streamingItems:
chatState.streamingItems.value,
);
}
final thoughtIndex =
chatState.isStreaming.value
? index - 1
: index;
final thought =
chatState.localThoughts.value[thoughtIndex];
return ThoughtItem(thought: thought);
},
final thought = chatState
.localThoughts
.value[thoughtIndex];
return ThoughtItem(thought: thought);
},
),
)
: SuperListView.builder(
listController: chatState.listController,
controller: chatState.scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
8 +
inputHeight.value,
),
reverse: true,
itemCount:
chatState.localThoughts.value.length +
(chatState.isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingItems: chatState.streamingItems.value,
);
}
final thoughtIndex = chatState.isStreaming.value
? index - 1
: index;
final thought =
chatState.localThoughts.value[thoughtIndex];
return ThoughtItem(thought: thought);
},
),
),
],
),
@@ -531,35 +543,31 @@ class ThoughtChatInterface extends HookConsumerWidget {
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input)
AnimatedBuilder(
animation: chatState.bottomGradientNotifier.value,
builder:
(context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: chatState.bottomGradientNotifier.value.value,
child: Container(
height: math.min(
MediaQuery.of(context).size.height * 0.1,
128,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.8),
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.0),
],
),
),
builder: (context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: chatState.bottomGradientNotifier.value.value,
child: Container(
height: math.min(MediaQuery.of(context).size.height * 0.1, 128),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.8),
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.0),
],
),
),
),
),
),
),
// Thought Input positioned above gradient (higher z-index)
Positioned(
@@ -746,58 +754,42 @@ class ThoughtInput extends HookWidget {
maxLines: 5,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted:
(!isStreaming && !isDisabled)
? (_) => onSend()
: null,
onSubmitted: (!isStreaming && !isDisabled)
? (_) => onSend()
: null,
),
),
IconButton(
icon: Icon(isStreaming ? Symbols.stop : Icons.send),
color: Theme.of(context).colorScheme.primary,
onPressed: (!isStreaming && !isDisabled) ? onSend : null,
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Row(
children: [
if (services.isNotEmpty)
DropdownButtonHideUnderline(
child: DropdownButton2<String>(
value:
selectedServiceId.value.isEmpty
Row(
children: [
if (services.isNotEmpty)
SizedBox(
height: 40,
child: DropdownButtonHideUnderline(
child: DropdownButton2<String>(
value: selectedServiceId.value.isEmpty
? null
: selectedServiceId.value,
customButton: Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
border: BoxBorder.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
borderRadius: const BorderRadius.all(
Radius.circular(16),
),
),
child: Row(
spacing: 8,
children: [
const Icon(
Symbols.network_intelligence,
size: 20,
customButton: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
Text(selectedServiceId.value),
const Icon(
Symbols.keyboard_arrow_down,
size: 14,
).padding(right: 4),
],
).padding(vertical: 2, horizontal: 6),
),
items:
services
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(24),
),
),
child: Row(
spacing: 8,
children: [
Text(selectedServiceId.value),
const Icon(
Symbols.keyboard_arrow_down,
size: 14,
).padding(right: 4),
],
).padding(vertical: 2, horizontal: 6),
),
items: services
.map(
(service) => DropdownMenuItem<String>(
value: service.serviceId,
@@ -807,61 +799,66 @@ class ThoughtInput extends HookWidget {
children: [
Text(
service.serviceId,
style: DefaultTextStyle.of(
context,
).style.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
),
style: DefaultTextStyle.of(context)
.style
.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Text(
'Rate: ${service.billingMultiplier}x, Level: P${service.perkLevel}',
style: DefaultTextStyle.of(
context,
).style.copyWith(
fontSize: 12,
color:
Theme.of(context)
'${service.billingMultiplier}x, T${service.perkLevel}',
style: DefaultTextStyle.of(context)
.style
.copyWith(
fontSize: 12,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
],
),
),
)
.toList(),
onChanged:
!isStreaming && !isDisabled
onChanged: !isStreaming && !isDisabled
? (value) {
if (value != null) {
selectedServiceId.value = value;
if (value != null) {
selectedServiceId.value = value;
}
}
}
: null,
hint: const Text('Select Service'),
isDense: true,
buttonStyleData: ButtonStyleData(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(16),
hint: const Text('Select Service'),
isDense: true,
buttonStyleData: ButtonStyleData(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(24),
),
),
),
menuItemStyleData: MenuItemStyleData(
selectedMenuItemBuilder: (context, child) {
return child;
},
height: 56,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
),
),
),
menuItemStyleData: MenuItemStyleData(
selectedMenuItemBuilder: (context, child) {
return child;
},
height: 56,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
),
),
),
],
),
],
),
IconButton(
icon: Icon(isStreaming ? Symbols.stop : Icons.send),
color: Theme.of(context).colorScheme.primary,
onPressed: (!isStreaming && !isDisabled) ? onSend : null,
),
],
),
],
),
@@ -923,42 +920,40 @@ class ThoughtItem extends StatelessWidget {
}
List<Widget> buildWidgetsList() {
final List<StreamItem> 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 List<StreamItem> 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<Map<String, String>> proposals =
!isStreaming
? _extractProposals(
thought!.parts
.where((p) => p.type == ThinkingMessagePartType.text)
.map((p) => p.text ?? '')
.join(),
)
: [];
final List<Map<String, String>> proposals = !isStreaming
? _extractProposals(
thought!.parts
.where((p) => p.type == ThinkingMessagePartType.text)
.map((p) => p.text ?? '')
.join(),
)
: [];
final List<Widget> widgets = [];
String currentText = '';
@@ -986,10 +981,9 @@ class ThoughtItem extends StatelessWidget {
isFinish: result != null,
isStreaming: isStreaming,
callData: JsonEncoder.withIndent(' ').convert(item.data.toJson()),
resultData:
result != null
? JsonEncoder.withIndent(' ').convert(result.data.toJson())
: null,
resultData: result != null
? JsonEncoder.withIndent(' ').convert(result.data.toJson())
: null,
),
);
} else if (item.type == 'function_result') {