🐛 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

@@ -310,6 +310,7 @@
"settingsServerUrl": "Server URL", "settingsServerUrl": "Server URL",
"settingsApplied": "The settings has been applied.", "settingsApplied": "The settings has been applied.",
"notifications": "Notifications", "notifications": "Notifications",
"notificationsDescription": "See what's happended related to you recently.",
"posts": "Posts", "posts": "Posts",
"settingsBackgroundImage": "Background Image", "settingsBackgroundImage": "Background Image",
"settingsBackgroundImageClear": "Clear Background Image", "settingsBackgroundImageClear": "Clear Background Image",
@@ -1135,6 +1136,7 @@
"installUpdate": "Install update", "installUpdate": "Install update",
"openReleasePage": "Open release page", "openReleasePage": "Open release page",
"postCompose": "Compose Post", "postCompose": "Compose Post",
"postComposeDescription": "Compose a new post",
"postPublish": "Publish Post", "postPublish": "Publish Post",
"restoreDraftTitle": "Restore Draft", "restoreDraftTitle": "Restore Draft",
"restoreDraftMessage": "A draft was found. Do you want to restore it?", "restoreDraftMessage": "A draft was found. Do you want to restore it?",

View File

@@ -10,17 +10,20 @@ import "package:island/widgets/thought/thought_shared.dart";
import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:material_symbols_icons/material_symbols_icons.dart";
class ThoughtSheet extends HookConsumerWidget { class ThoughtSheet extends HookConsumerWidget {
final String? initialMessage;
final List<Map<String, dynamic>> attachedMessages; final List<Map<String, dynamic>> attachedMessages;
final List<String> attachedPosts; final List<String> attachedPosts;
const ThoughtSheet({ const ThoughtSheet({
super.key, super.key,
this.initialMessage,
this.attachedMessages = const [], this.attachedMessages = const [],
this.attachedPosts = const [], this.attachedPosts = const [],
}); });
static Future<void> show( static Future<void> show(
BuildContext context, { BuildContext context, {
String? initialMessage,
List<Map<String, dynamic>> attachedMessages = const [], List<Map<String, dynamic>> attachedMessages = const [],
List<String> attachedPosts = const [], List<String> attachedPosts = const [],
}) { }) {
@@ -28,11 +31,11 @@ class ThoughtSheet extends HookConsumerWidget {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useSafeArea: true, useSafeArea: true,
builder: builder: (context) => ThoughtSheet(
(context) => ThoughtSheet( initialMessage: initialMessage,
attachedMessages: attachedMessages, attachedMessages: attachedMessages,
attachedPosts: attachedPosts, attachedPosts: attachedPosts,
), ),
); );
} }
@@ -40,6 +43,7 @@ class ThoughtSheet extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final chatState = useThoughtChat( final chatState = useThoughtChat(
ref, ref,
initialMessage: initialMessage,
attachedMessages: attachedMessages, attachedMessages: attachedMessages,
attachedPosts: attachedPosts, attachedPosts: attachedPosts,
); );
@@ -75,31 +79,30 @@ class ThoughtSheet extends HookConsumerWidget {
return status return status
? chatInterface ? chatInterface
: Column( : Column(
children: [ children: [
MaterialBanner( MaterialBanner(
leading: const Icon(Symbols.error), leading: const Icon(Symbols.error),
content: const Text( content: const Text(
'You have unpaid orders. Please settle your payment to continue using the service.', 'You have unpaid orders. Please settle your payment to continue using the service.',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
TextButton(
onPressed: () {
retry();
},
child: Text('retry'.tr()),
), ),
], actions: [
), TextButton(
Expanded(child: chatInterface), onPressed: () {
], retry();
); },
child: Text('retry'.tr()),
),
],
),
Expanded(child: chatInterface),
],
);
}, },
orElse: orElse: () => ThoughtChatInterface(
() => ThoughtChatInterface( attachedMessages: attachedMessages,
attachedMessages: attachedMessages, attachedPosts: attachedPosts,
attachedPosts: attachedPosts, ),
),
), ),
); );
} }

View File

@@ -38,3 +38,16 @@ class ShowComposeSheetEvent {
class ShowNotificationSheetEvent { class ShowNotificationSheetEvent {
const ShowNotificationSheetEvent(); const ShowNotificationSheetEvent();
} }
/// Event fired to show the thought sheet
class ShowThoughtSheetEvent {
final String? initialMessage;
final List<Map<String, dynamic>> attachedMessages;
final List<String> attachedPosts;
const ShowThoughtSheetEvent({
this.initialMessage,
this.attachedMessages = const [],
this.attachedPosts = const [],
});
}

View File

@@ -19,6 +19,7 @@ import 'package:island/widgets/content/network_status_sheet.dart';
import 'package:island/widgets/tour/tour.dart'; import 'package:island/widgets/tour/tour.dart';
import 'package:island/widgets/post/compose_sheet.dart'; import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/screens/notification.dart'; import 'package:island/screens/notification.dart';
import 'package:island/screens/thought/think_sheet.dart';
import 'package:island/services/event_bus.dart'; import 'package:island/services/event_bus.dart';
import 'package:tray_manager/tray_manager.dart'; import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
@@ -38,6 +39,7 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
StreamSubscription? composeSheetSubs; StreamSubscription? composeSheetSubs;
StreamSubscription? notificationSheetSubs; StreamSubscription? notificationSheetSubs;
StreamSubscription? thoughtSheetSubs;
@override @override
void initState() { void initState() {
@@ -70,6 +72,12 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
} }
}); });
thoughtSheetSubs = eventBus.on<ShowThoughtSheetEvent>().listen((event) {
if (mounted) {
_showThoughtSheet(event);
}
});
final initialUrl = await protocolHandler.getInitialUrl(); final initialUrl = await protocolHandler.getInitialUrl();
if (initialUrl != null && mounted) { if (initialUrl != null && mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -87,6 +95,7 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
ntySubs?.cancel(); ntySubs?.cancel();
composeSheetSubs?.cancel(); composeSheetSubs?.cancel();
notificationSheetSubs?.cancel(); notificationSheetSubs?.cancel();
thoughtSheetSubs?.cancel();
super.dispose(); super.dispose();
} }
@@ -154,6 +163,15 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
); );
} }
void _showThoughtSheet(ShowThoughtSheetEvent event) {
ThoughtSheet.show(
context,
initialMessage: event.initialMessage,
attachedMessages: event.attachedMessages,
attachedPosts: event.attachedPosts,
);
}
void _handleDeepLink(Uri uri, WidgetRef ref) async { void _handleDeepLink(Uri uri, WidgetRef ref) async {
String path = '/${uri.host}${uri.path}'; String path = '/${uri.host}${uri.path}';

View File

@@ -29,16 +29,17 @@ class CommandPattleWidget extends HookConsumerWidget {
static List<SpecialAction> _getSpecialActions(BuildContext context) { static List<SpecialAction> _getSpecialActions(BuildContext context) {
return [ return [
SpecialAction( SpecialAction(
name: 'Compose Post', name: 'postCompose'.tr(),
description: 'Create a new post', description: 'postComposeDescription'.tr(),
icon: Symbols.edit, icon: Symbols.edit,
action: () { action: () {
eventBus.fire(const ShowComposeSheetEvent()); eventBus.fire(const ShowComposeSheetEvent());
}, },
), ),
SpecialAction( SpecialAction(
name: 'Notifications', name: 'notifications'.tr(),
description: 'View your notifications', description: 'notificationsDescription'.tr(),
searchableAliases: ['notifications', 'alert', 'bell'],
icon: Symbols.notifications, icon: Symbols.notifications,
action: () { action: () {
eventBus.fire(const ShowNotificationSheetEvent()); eventBus.fire(const ShowNotificationSheetEvent());
@@ -149,7 +150,7 @@ class CommandPattleWidget extends HookConsumerWidget {
filteredChats.isEmpty && filteredChats.isEmpty &&
filteredSpecialActions.isEmpty && filteredSpecialActions.isEmpty &&
filteredRoutes.isEmpty filteredRoutes.isEmpty
? _getFallbackActions(searchQuery.value) ? _getFallbackActions(context, searchQuery.value)
: <FallbackAction>[]; : <FallbackAction>[];
// Combine results: fallbacks first, then chats, special actions, routes // Combine results: fallbacks first, then chats, special actions, routes
@@ -202,17 +203,7 @@ class CommandPattleWidget extends HookConsumerWidget {
if (event.logicalKey == LogicalKeyboardKey.enter || if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter) { event.logicalKey == LogicalKeyboardKey.numpadEnter) {
final item = allResults[focusedIndex.value ?? 0]; final item = allResults[focusedIndex.value ?? 0];
if (item is SnChatRoom) { _executeItem(context, ref, item);
_navigateToChat(context, ref, item);
} else if (item is SpecialAction) {
onDismiss();
item.action();
} else if (item is RouteItem) {
_navigateToRoute(context, ref, item);
} else if (item is FallbackAction) {
onDismiss();
item.action();
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (allResults.isNotEmpty) { if (allResults.isNotEmpty) {
if (focusedIndex.value == null) { if (focusedIndex.value == null) {
@@ -291,6 +282,13 @@ class CommandPattleWidget extends HookConsumerWidget {
leading: CircleAvatar( leading: CircleAvatar(
child: const Icon(Symbols.keyboard_command_key), child: const Icon(Symbols.keyboard_command_key),
).padding(horizontal: 8), ).padding(horizontal: 8),
onSubmitted: !isDesktop() && allResults.isNotEmpty
? (value) => _executeItem(
context,
ref,
allResults[0],
)
: null,
), ),
AnimatedSize( AnimatedSize(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -389,43 +387,80 @@ class CommandPattleWidget extends HookConsumerWidget {
ref.read(routerProvider).go(route.path); ref.read(routerProvider).go(route.path);
} }
static List<FallbackAction> _getFallbackActions(String query) { void _executeItem(BuildContext context, WidgetRef ref, dynamic item) {
if (item is SnChatRoom) {
_navigateToChat(context, ref, item);
} else if (item is SpecialAction) {
onDismiss();
item.action();
} else if (item is RouteItem) {
_navigateToRoute(context, ref, item);
} else if (item is FallbackAction) {
onDismiss();
item.action();
}
}
static List<FallbackAction> _getFallbackActions(
BuildContext context,
String query,
) {
final List<FallbackAction> actions = []; final List<FallbackAction> actions = [];
// Check if query is a URL // Check if query is a URL
final Uri? uri = Uri.tryParse(query); final Uri? uri = Uri.tryParse(query);
if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { final isValidUrl =
uri != null && (uri.scheme == 'http' || uri.scheme == 'https');
final isDomain = RegExp(
r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$',
).hasMatch(query);
if (isValidUrl || isDomain) {
final finalUri = isDomain ? Uri.parse('https://$query') : uri!;
actions.add( actions.add(
FallbackAction( FallbackAction(
name: 'Open URL', name: 'Open URL',
description: 'Open $query in browser', description: 'Open ${finalUri.toString()} in browser',
icon: Symbols.open_in_new, icon: Symbols.open_in_new,
action: () async { action: () async {
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(finalUri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication); await launchUrl(finalUri, mode: LaunchMode.externalApplication);
}
},
),
);
} else {
// Search the web
actions.add(
FallbackAction(
name: 'Search the web',
description: 'Search "$query" on Google',
icon: Symbols.search,
action: () async {
final searchUri = Uri.https('www.google.com', '/search', {
'q': query,
});
if (await canLaunchUrl(searchUri)) {
await launchUrl(searchUri, mode: LaunchMode.externalApplication);
} }
}, },
), ),
); );
} }
// Ask the AI
// Bugged, DO NOT USE
// actions.add(
// FallbackAction(
// name: 'Ask the AI',
// description: 'Ask "$query" to the AI',
// icon: Symbols.bubble_chart,
// action: () {
// eventBus.fire(ShowThoughtSheetEvent(initialMessage: query));
// },
// ),
// );
// Search the web
actions.add(
FallbackAction(
name: 'Search the web',
description: 'Search "$query" on Google',
icon: Symbols.search,
action: () async {
final searchUri = Uri.https('www.google.com', '/search', {
'q': query,
});
if (await canLaunchUrl(searchUri)) {
await launchUrl(searchUri, mode: LaunchMode.externalApplication);
}
},
),
);
return actions; return actions;
} }
} }

View File

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