Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s
Some checks failed
Build Linux App / build (push) Failing after 1m18s
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
|
||||
import 'package:trainhub_flutter/data/services/llm_client.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
@@ -18,22 +21,27 @@ AiProcessManager aiProcessManager(AiProcessManagerRef ref) {
|
||||
return manager;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
AiSettingsService aiSettingsService(AiSettingsServiceRef ref) {
|
||||
final service = getIt<AiSettingsService>();
|
||||
service.addListener(() => ref.notifyListeners());
|
||||
return service;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ChatController extends _$ChatController {
|
||||
late ChatRepository _repo;
|
||||
late NoteRepository _noteRepo;
|
||||
|
||||
final _dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: AiConstants.serverConnectTimeout,
|
||||
receiveTimeout: AiConstants.serverReceiveTimeout,
|
||||
),
|
||||
);
|
||||
late LlmClient _llm;
|
||||
CancelToken? _cancelToken;
|
||||
|
||||
@override
|
||||
Future<ChatState> build() async {
|
||||
_repo = getIt<ChatRepository>();
|
||||
_noteRepo = getIt<NoteRepository>();
|
||||
_llm = getIt<LlmClient>();
|
||||
// Abort any in-flight generation when the user leaves the chat page.
|
||||
ref.onDispose(() => _cancelToken?.cancel());
|
||||
final aiManager = ref.read(aiProcessManagerProvider);
|
||||
if (aiManager.status == AiServerStatus.offline) {
|
||||
aiManager.startServers();
|
||||
@@ -67,8 +75,9 @@ class ChatController extends _$ChatController {
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
sessions: sessions,
|
||||
activeSession:
|
||||
current.activeSession?.id == id ? null : current.activeSession,
|
||||
activeSession: current.activeSession?.id == id
|
||||
? null
|
||||
: current.activeSession,
|
||||
messages: current.activeSession?.id == id ? [] : current.messages,
|
||||
),
|
||||
);
|
||||
@@ -80,12 +89,44 @@ class ChatController extends _$ChatController {
|
||||
final sessionId = await _resolveSession(current, content);
|
||||
await _persistUserMessage(sessionId, content);
|
||||
final contextChunks = await _searchKnowledgeBase(content);
|
||||
final systemPrompt = _buildSystemPrompt(contextChunks);
|
||||
final trainingContext = await _buildTrainingContext();
|
||||
final systemPrompt = _buildSystemPrompt(contextChunks, trainingContext);
|
||||
final history = _buildHistory();
|
||||
final fullAiResponse = await _streamResponse(systemPrompt, history);
|
||||
await _persistAssistantResponse(sessionId, content, fullAiResponse);
|
||||
}
|
||||
|
||||
/// Summarizes the user's exercise library and training plans so the model
|
||||
/// can reference and plan around real data. Only attached when a cloud
|
||||
/// provider is active — the local 4B model's context is too small for it.
|
||||
Future<String> _buildTrainingContext() async {
|
||||
if (getIt<LlmClient>().activeProvider == AiProvider.local) return '';
|
||||
try {
|
||||
final exercises = await getIt<ExerciseRepository>().getAll();
|
||||
final plans = await getIt<TrainingPlanRepository>().getAll();
|
||||
|
||||
final buffer = StringBuffer();
|
||||
if (exercises.isNotEmpty) {
|
||||
buffer.writeln("### The trainer's exercise library:");
|
||||
for (final e in exercises.take(150)) {
|
||||
buffer.write('- ${e.name}');
|
||||
final tags = e.tags;
|
||||
if (tags != null && tags.isNotEmpty) buffer.write(' [$tags]');
|
||||
buffer.writeln();
|
||||
}
|
||||
}
|
||||
if (plans.isNotEmpty) {
|
||||
buffer.writeln("\n### The trainer's training plans:");
|
||||
for (final plan in plans.take(30)) {
|
||||
buffer.writeln('- ${plan.name}');
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _resolveSession(ChatState current, String content) async {
|
||||
if (current.activeSession != null) return current.activeSession!.id;
|
||||
final session = await _repo.createSession();
|
||||
@@ -144,61 +185,51 @@ class ChatController extends _$ChatController {
|
||||
return contextChunks;
|
||||
}
|
||||
|
||||
/// Most recent messages only — an unbounded history would eventually
|
||||
/// overflow the model context and slow every request down.
|
||||
List<Map<String, String>> _buildHistory() {
|
||||
final messages = state.valueOrNull?.messages ?? [];
|
||||
return messages
|
||||
.map((m) => <String, String>{
|
||||
'role': m.isUser ? 'user' : 'assistant',
|
||||
'content': m.content,
|
||||
})
|
||||
final recent = messages.length > AiConstants.chatHistoryLimit
|
||||
? messages.sublist(messages.length - AiConstants.chatHistoryLimit)
|
||||
: messages;
|
||||
return recent
|
||||
.map(
|
||||
(m) => <String, String>{
|
||||
'role': m.isUser ? 'user' : 'assistant',
|
||||
'content': m.content,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Stops an in-flight generation. The partial response streamed so far is
|
||||
/// kept and persisted like a normal reply.
|
||||
void stopGeneration() => _cancelToken?.cancel();
|
||||
|
||||
Future<String> _streamResponse(
|
||||
String systemPrompt,
|
||||
List<Map<String, String>> history,
|
||||
) async {
|
||||
final generateStep = _createStep('Generating response...');
|
||||
String fullAiResponse = '';
|
||||
_cancelToken = CancelToken();
|
||||
try {
|
||||
final response = await _dio.post<ResponseBody>(
|
||||
AiConstants.chatApiUrl,
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
data: {
|
||||
'messages': [
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
...history,
|
||||
],
|
||||
'temperature': AiConstants.chatTemperature,
|
||||
'stream': true,
|
||||
},
|
||||
);
|
||||
final stream = _llm.streamChat([
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
...history,
|
||||
], cancelToken: _cancelToken);
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.running,
|
||||
title: 'Writing...',
|
||||
);
|
||||
final stream = response.data!.stream;
|
||||
await for (final chunk in stream) {
|
||||
final textChunk = utf8.decode(chunk);
|
||||
for (final line in textChunk.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
final dataStr = line.substring(6).trim();
|
||||
if (dataStr == '[DONE]') break;
|
||||
if (dataStr.isEmpty) continue;
|
||||
try {
|
||||
final data = jsonDecode(dataStr);
|
||||
final delta = data['choices']?[0]?['delta']?['content'] ?? '';
|
||||
if (delta.isNotEmpty) {
|
||||
fullAiResponse += delta;
|
||||
final updatedState = state.valueOrNull;
|
||||
if (updatedState != null) {
|
||||
state = AsyncValue.data(
|
||||
updatedState.copyWith(streamingContent: fullAiResponse),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
await for (final delta in stream) {
|
||||
fullAiResponse += delta;
|
||||
final updatedState = state.valueOrNull;
|
||||
if (updatedState != null) {
|
||||
state = AsyncValue.data(
|
||||
updatedState.copyWith(streamingContent: fullAiResponse),
|
||||
);
|
||||
}
|
||||
}
|
||||
_updateStep(
|
||||
@@ -207,21 +238,29 @@ class ChatController extends _$ChatController {
|
||||
title: 'Response generated',
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
fullAiResponse += '\n\n[AI model communication error]';
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.error,
|
||||
title: 'Generation failed',
|
||||
details: '${e.message}',
|
||||
);
|
||||
if (CancelToken.isCancel(e)) {
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.completed,
|
||||
title: 'Stopped by user',
|
||||
);
|
||||
} else {
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.error,
|
||||
title: 'Generation failed',
|
||||
details: e.message ?? e.toString(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
fullAiResponse += '\n\n[Unexpected error]';
|
||||
_updateStep(
|
||||
generateStep.id,
|
||||
status: ThinkingStepStatus.error,
|
||||
title: 'Generation failed',
|
||||
details: e.toString(),
|
||||
);
|
||||
} finally {
|
||||
_cancelToken = null;
|
||||
}
|
||||
return fullAiResponse;
|
||||
}
|
||||
@@ -231,6 +270,17 @@ class ChatController extends _$ChatController {
|
||||
String userContent,
|
||||
String aiResponse,
|
||||
) async {
|
||||
// A failed generation yields an empty response — leave the error visible
|
||||
// in the thinking steps instead of saving an empty assistant message.
|
||||
if (aiResponse.trim().isEmpty) {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(isTyping: false, streamingContent: null),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'assistant',
|
||||
@@ -289,18 +339,32 @@ class ChatController extends _$ChatController {
|
||||
state = AsyncValue.data(current.copyWith(thinkingSteps: updatedSteps));
|
||||
}
|
||||
|
||||
static String _buildSystemPrompt(List<String> chunks) {
|
||||
if (chunks.isEmpty) return AiConstants.baseSystemPrompt;
|
||||
final contextBlock = chunks
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => '[${e.key + 1}] ${e.value}')
|
||||
.join('\n\n');
|
||||
return '${AiConstants.baseSystemPrompt}\n\n'
|
||||
'### Relevant notes from the trainer\'s knowledge base:\n'
|
||||
static String _buildSystemPrompt(
|
||||
List<String> chunks,
|
||||
String trainingContext,
|
||||
) {
|
||||
final buffer = StringBuffer(AiConstants.baseSystemPrompt);
|
||||
if (trainingContext.isNotEmpty) {
|
||||
buffer.write('\n\n$trainingContext');
|
||||
buffer.write(
|
||||
'\nWhen designing or discussing training plans, prefer exercises '
|
||||
'from the library above and reference existing plans by name.',
|
||||
);
|
||||
}
|
||||
if (chunks.isNotEmpty) {
|
||||
final contextBlock = chunks
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => '[${e.key + 1}] ${e.value}')
|
||||
.join('\n\n');
|
||||
buffer.write(
|
||||
'\n\n### Relevant notes from the trainer\'s knowledge base:\n'
|
||||
'$contextBlock\n\n'
|
||||
'Use the above context to inform your response when relevant. '
|
||||
'If the context is not directly applicable, rely on your general '
|
||||
'fitness knowledge.';
|
||||
'fitness knowledge.',
|
||||
);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,25 @@ final aiProcessManagerProvider = AutoDisposeProvider<AiProcessManager>.internal(
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AiProcessManagerRef = AutoDisposeProviderRef<AiProcessManager>;
|
||||
String _$chatControllerHash() => r'266d8a5ac91cbe6c112f85f15adf5a8046e85682';
|
||||
String _$aiSettingsServiceHash() => r'3d16401bd92a2502e16cc9bc55558e0813682f4b';
|
||||
|
||||
/// See also [aiSettingsService].
|
||||
@ProviderFor(aiSettingsService)
|
||||
final aiSettingsServiceProvider =
|
||||
AutoDisposeProvider<AiSettingsService>.internal(
|
||||
aiSettingsService,
|
||||
name: r'aiSettingsServiceProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$aiSettingsServiceHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AiSettingsServiceRef = AutoDisposeProviderRef<AiSettingsService>;
|
||||
String _$chatControllerHash() => r'227ef80f7bcc8787d85a726f151d878a3ba954d6';
|
||||
|
||||
/// See also [ChatController].
|
||||
@ProviderFor(ChatController)
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
@@ -63,9 +64,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
final now = DateTime.now();
|
||||
final hour = dt.hour.toString().padLeft(2, '0');
|
||||
final minute = dt.minute.toString().padLeft(2, '0');
|
||||
if (dt.year == now.year &&
|
||||
dt.month == now.month &&
|
||||
dt.day == now.day) {
|
||||
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
|
||||
return '$hour:$minute';
|
||||
}
|
||||
return '${dt.day}/${dt.month} $hour:$minute';
|
||||
@@ -76,14 +75,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final modelsValidated =
|
||||
ref.watch(aiModelSettingsControllerProvider).areModelsValidated;
|
||||
final modelsValidated = ref
|
||||
.watch(aiModelSettingsControllerProvider)
|
||||
.areModelsValidated;
|
||||
// A configured external provider (cloud, Ollama, LM Studio) makes chat
|
||||
// usable without the built-in local models.
|
||||
final settings = ref.watch(aiSettingsServiceProvider).settings;
|
||||
final cloudConfigured =
|
||||
settings.provider != AiProvider.local && settings.isProviderConfigured;
|
||||
final state = ref.watch(chatControllerProvider);
|
||||
final controller = ref.read(chatControllerProvider.notifier);
|
||||
ref.listen(chatControllerProvider, (prev, next) {
|
||||
if (next.hasValue &&
|
||||
(prev?.value?.messages.length ?? 0) <
|
||||
next.value!.messages.length) {
|
||||
(prev?.value?.messages.length ?? 0) < next.value!.messages.length) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
if (next.hasValue &&
|
||||
@@ -92,7 +96,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
if (!modelsValidated) {
|
||||
if (!modelsValidated && !cloudConfigured) {
|
||||
return const Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: MissingModelsState(),
|
||||
@@ -211,14 +215,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
color: isActive
|
||||
? AppColors.zinc700.withValues(alpha: 0.7)
|
||||
: isHovered
|
||||
? AppColors.zinc800.withValues(alpha: 0.6)
|
||||
: Colors.transparent,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
? AppColors.zinc800.withValues(alpha: 0.6)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: isActive
|
||||
? Border.all(
|
||||
color: AppColors.accent.withValues(alpha: 0.3),
|
||||
)
|
||||
? Border.all(color: AppColors.accent.withValues(alpha: 0.3))
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
@@ -239,8 +240,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
isActive ? FontWeight.w500 : FontWeight.normal,
|
||||
fontWeight: isActive ? FontWeight.w500 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -260,8 +260,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
Icons.delete_outline_rounded,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
onPressed: () =>
|
||||
controller.deleteSession(session.id),
|
||||
onPressed: () => controller.deleteSession(session.id),
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
),
|
||||
@@ -290,8 +289,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
horizontal: UIConstants.spacing24,
|
||||
vertical: UIConstants.spacing16,
|
||||
),
|
||||
itemCount:
|
||||
data.messages.length + (data.isTyping ? 1 : 0),
|
||||
itemCount: data.messages.length + (data.isTyping ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == data.messages.length) {
|
||||
if (data.thinkingSteps.isNotEmpty ||
|
||||
@@ -391,21 +389,46 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
ChatController controller,
|
||||
) {
|
||||
final aiManager = ref.watch(aiProcessManagerProvider);
|
||||
final aiSettings = ref.watch(aiSettingsServiceProvider).settings;
|
||||
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
|
||||
final isStarting = aiManager.status == AiServerStatus.starting;
|
||||
final isError = aiManager.status == AiServerStatus.error;
|
||||
final isReady = aiManager.status == AiServerStatus.ready;
|
||||
|
||||
// External providers (cloud, Ollama, LM Studio) don't need the built-in
|
||||
// llama server for chat. The local embedding server is still used for
|
||||
// the knowledge base when available, but its absence shouldn't block.
|
||||
final isCloud = aiSettings.provider != AiProvider.local;
|
||||
final cloudReady = isCloud && aiSettings.isProviderConfigured;
|
||||
final isReady = cloudReady || aiManager.status == AiServerStatus.ready;
|
||||
|
||||
String? statusMessage;
|
||||
Color statusColor = AppColors.textMuted;
|
||||
if (isStarting) {
|
||||
if (isCloud) {
|
||||
if (!cloudReady) {
|
||||
statusMessage =
|
||||
'No API key for ${aiSettings.provider.label} — add one in '
|
||||
'Settings, or switch back to the local model.';
|
||||
statusColor = AppColors.warning;
|
||||
} else {
|
||||
statusMessage =
|
||||
'${aiSettings.provider.label} · '
|
||||
'${aiSettings.modelFor(aiSettings.provider)}';
|
||||
}
|
||||
} else if (isStarting) {
|
||||
statusMessage =
|
||||
aiManager.statusDetail ??
|
||||
'Starting AI inference server (this may take a moment)...';
|
||||
statusColor = AppColors.info;
|
||||
} else if (isError) {
|
||||
statusMessage =
|
||||
'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}';
|
||||
// The full error contains multi-line server logs — show only the
|
||||
// first line here; details land in the debug console.
|
||||
final firstLine = (aiManager.errorMessage ?? 'Unknown error')
|
||||
.split('\n')
|
||||
.first
|
||||
.trim();
|
||||
statusMessage = 'AI Server Error: $firstLine';
|
||||
statusColor = AppColors.destructive;
|
||||
} else if (!isReady) {
|
||||
} else if (aiManager.status != AiServerStatus.ready) {
|
||||
statusMessage = 'AI Server offline. Reconnecting...';
|
||||
}
|
||||
return Container(
|
||||
@@ -424,7 +447,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (isStarting)
|
||||
if (!isCloud && isStarting)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
width: 12,
|
||||
@@ -434,7 +457,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
color: AppColors.info,
|
||||
),
|
||||
)
|
||||
else if (isError)
|
||||
else if (!isCloud && isError)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
@@ -453,7 +476,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isError)
|
||||
if (!isCloud && isError)
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
@@ -479,10 +502,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
border:
|
||||
Border.all(color: AppColors.border, width: 1),
|
||||
borderRadius: BorderRadius.circular(
|
||||
UIConstants.borderRadius,
|
||||
),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
@@ -525,22 +548,23 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Material(
|
||||
color: (isTyping || !isReady)
|
||||
? AppColors.zinc700
|
||||
: AppColors.accent,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
// While generating, the button becomes a stop control.
|
||||
color: !isReady ? AppColors.zinc700 : AppColors.accent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
onTap: (isTyping || !isReady)
|
||||
borderRadius: BorderRadius.circular(
|
||||
UIConstants.borderRadius,
|
||||
),
|
||||
onTap: !isReady
|
||||
? null
|
||||
: isTyping
|
||||
? controller.stopGeneration
|
||||
: () => _sendMessage(controller),
|
||||
child: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color: (isTyping || !isReady)
|
||||
? AppColors.textMuted
|
||||
: AppColors.zinc950,
|
||||
isTyping
|
||||
? Icons.stop_rounded
|
||||
: Icons.arrow_upward_rounded,
|
||||
color: !isReady ? AppColors.textMuted : AppColors.zinc950,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user