Melhorias no comportamento do chat com IA;

Adição do histórico de conversas com IA.
This commit is contained in:
2026-05-21 23:20:18 +01:00
parent 5bda59f7af
commit 7ee262f4c7
5 changed files with 825 additions and 162 deletions

View File

@@ -142,14 +142,7 @@ class AppRouter {
], ],
), ),
// AI Tutor Route (independent) // AI Tutor Route with conversation ID (resume conversation) - MUST come before regular /ai-tutor route
GoRoute(
path: tutor,
name: 'aiTutor',
builder: (context, state) => const TutorChatPageSimple(),
),
// AI Tutor Route with conversation ID (resume conversation)
GoRoute( GoRoute(
path: '$tutor/:conversationId', path: '$tutor/:conversationId',
name: 'aiTutorConversation', name: 'aiTutorConversation',
@@ -159,6 +152,13 @@ class AppRouter {
}, },
), ),
// AI Tutor Route (independent - new conversation)
GoRoute(
path: tutor,
name: 'aiTutor',
builder: (context, state) => const TutorChatPageSimple(),
),
// Chat History Route // Chat History Route
GoRoute( GoRoute(
path: chatHistory, path: chatHistory,

View File

@@ -360,4 +360,114 @@ class ChatMemoryService {
Logger.error('Error clearing history: $e'); Logger.error('Error clearing history: $e');
} }
} }
/// Generate a smart title from the first message by extracting keywords
static String generateConversationTitle(String firstMessage) {
// Remove common Portuguese stopwords and greetings
final stopwords = {
'olá',
'oi',
'bom dia',
'boa tarde',
'boa noite',
'consegues',
'podes',
'pode',
'poderia',
'poderias',
'explicar',
'explica',
'explicar-me',
'explicar-lhe',
'sobre',
'acerca de',
'a respeito de',
'relativamente a',
'o que é',
'o que são',
'qual é',
'quais são',
'como',
'onde',
'quando',
'porquê',
'por que',
'quero',
'quero que',
'gostaria',
'gostaria de',
'preciso',
'preciso de',
'ajuda',
'ajuda-me',
'me',
'te',
'lhe',
'nos',
'vos',
'um',
'uma',
'uns',
'umas',
'e',
'ou',
'mas',
'porém',
'todavia',
'para',
'de',
'em',
'a',
'o',
'as',
'os',
'que',
'quem',
'qual',
'quais',
'é',
'são',
'está',
'estão',
'foi',
'foram',
'?',
'!',
'.',
',',
';',
':',
};
// Convert to lowercase and remove punctuation
final cleaned = firstMessage
.toLowerCase()
.replaceAll(RegExp(r'[?!.;,]'), '')
.trim();
// Split into words and remove stopwords
final words = cleaned
.split(' ')
.where((word) => word.isNotEmpty && !stopwords.contains(word))
.toList();
// Take the first 2-3 meaningful words as the title
if (words.isEmpty) {
return firstMessage.length > 30
? '${firstMessage.substring(0, 30)}...'
: firstMessage;
}
final titleWords = words.take(3).join(' ');
// Capitalize first letter of each word
final title = titleWords
.split(' ')
.map((word) {
if (word.isEmpty) return '';
return word[0].toUpperCase() + word.substring(1);
})
.join(' ');
return title.length > 40 ? '${title.substring(0, 40)}...' : title;
}
} }

View File

@@ -860,11 +860,153 @@ ans é o índice (0-3) da opção correcta.''';
return followUpStarters.any((s) => q.startsWith(s) || q == s.trim()); return followUpStarters.any((s) => q.startsWith(s) || q == s.trim());
} }
/// Build dynamic system prompt based on selected materials and discipline
static String _buildSystemPrompt({
List<String>? selectedMaterialNames,
String? disciplineName,
bool isMathematics = false,
}) {
final buffer = StringBuffer();
buffer.writeln(
r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa.''',
);
buffer.writeln();
buffer.writeln('Nunca referes o nome do modelo de linguagem.');
buffer.writeln('Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.');
buffer.writeln('Respondes sempre como o Vico.');
buffer.writeln();
buffer.writeln('Tens personalidade simpática, confiante e motivadora.');
buffer.writeln(
'Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável.',
);
buffer.writeln();
// Material context section
if (disciplineName != null && disciplineName.isNotEmpty) {
buffer.writeln('DISCIPLINA ATUAL: $disciplineName');
}
if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) {
buffer.writeln(
'MATERIAIS SELECIONADOS: ${selectedMaterialNames.join(", ")}',
);
}
if (disciplineName != null ||
(selectedMaterialNames != null && selectedMaterialNames.isNotEmpty)) {
buffer.writeln();
}
// LaTeX prohibition (always)
buffer.writeln(
'IMPORTANTE: NUNCA uses LaTeX ou símbolos como \$ ou \$\$ para fórmulas matemáticas.',
);
buffer.writeln(
'Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).',
);
buffer.writeln();
// Discipline-specific rules
if (isMathematics) {
buffer.writeln('REGRAS CRÍTICAS PARA MATEMÁTICA:');
buffer.writeln(
'- O material fornecido serve como REFERÊNCIA de matéria, fórmulas e métodos.',
);
buffer.writeln(
'- Podes CRIAR exercícios NOVOS que sigam a mesma lógica, fórmulas e métodos do material.',
);
buffer.writeln(
'- NÃO copies exercícios diretamente do material — cria variações com valores diferentes.',
);
buffer.writeln(
'- Se o aluno pedir exercícios, cria exercícios novos baseados nos CONCEITOS e FÓRMULAS do material.',
);
buffer.writeln(
'- Se a resposta não estiver no contexto, usa o teu conhecimento matemático geral para ajudar, mas prioriza os métodos do material.',
);
buffer.writeln(
'- SEMPRE fornece o passo a passo completo com o resultado final.',
);
} else {
buffer.writeln('REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS:');
buffer.writeln(
'- Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo.',
);
buffer.writeln(
'- NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares.',
);
buffer.writeln(
'- Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível."',
);
}
buffer.writeln(
'- Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade.',
);
buffer.writeln();
// Material handling rules (always)
buffer.writeln('IMPORTANTE - COMO TRATAR MATERIAIS SELECIONADOS:');
buffer.writeln(
'- Quando o aluno selecionar materiais (PDFs, fichas, exames), ASSUME que o aluno quer ajuda com esses materiais.',
);
buffer.writeln(
'- NUNCA perguntes "que ficha pretendo resolver" ou "o que pretendo resolver".',
);
buffer.writeln(
'- NUNCA perguntes "em que posso ajudar" quando materiais estão selecionados.',
);
buffer.writeln(
'- ASSUME automaticamente que o aluno quer explicação, resolução ou ajuda com os materiais selecionados.',
);
if (selectedMaterialNames != null && selectedMaterialNames.isNotEmpty) {
buffer.writeln(
'- Se a pergunta do aluno for vaga (ex: "ajuda"), oferece ajuda específica sobre os materiais selecionados.',
);
}
buffer.writeln();
// Greeting rules (always)
buffer.writeln('IMPORTANTE - NÃO REPITAS SAUDAÇÕES:');
buffer.writeln(
'- NUNCA começes uma resposta com "Olá", "Olá de novo", "Oi", "Bom dia", etc., a menos que seja a PRIMEIRA mensagem da conversa.',
);
buffer.writeln(
'- Nas respostas subsequentes, vai DIRETO ao assunto sem saudar novamente.',
);
buffer.writeln(
'- Se já saudaste o utilizador uma vez na conversa, NUNCA o faças de novo.',
);
buffer.writeln();
// Complete response rules (always)
buffer.writeln('IMPORTANTE - RESPOSTAS COMPLETAS:');
buffer.writeln('- NUNCA termines respostas com dois pontos (:).');
buffer.writeln(
'- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".',
);
buffer.writeln(
'- SEMPRE completa as frases e fornece a resposta completa.',
);
buffer.writeln(
'- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.',
);
buffer.writeln(
'- Se precisares de definir algo, fornece a definição completa.',
);
return buffer.toString();
}
/// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and Vico identity /// Simple ask method for chat UI - uses conversation memory, teacher PDFs, and Vico identity
/// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno /// [selectedMaterialIds] — se fornecido, limita o RAG apenas aos materiais escolhidos pelo aluno
/// [selectedMaterialNames] — nomes dos materiais selecionados (para contextualizar a IA)
/// [disciplineName] — nome da disciplina (ex: "Matemática A", "Português")
/// [isMathematics] — true se for matemática ou disciplina quantitativa
static Future<String> ask( static Future<String> ask(
String userQuery, { String userQuery, {
List<String>? selectedMaterialIds, List<String>? selectedMaterialIds,
List<String>? selectedMaterialNames,
String? disciplineName,
bool isMathematics = false,
}) async { }) async {
Logger.info('USING RAG AI SERVICE'); Logger.info('USING RAG AI SERVICE');
@@ -872,50 +1014,21 @@ ans é o índice (0-3) da opção correcta.''';
List<Map<String, String>> messages = []; List<Map<String, String>> messages = [];
// PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO) // PASSO 2 — ADICIONAR SYSTEM MESSAGE DO VICO (SEMPRE PRIMEIRO)
messages.add({ final systemPrompt = _buildSystemPrompt(
'role': 'system', selectedMaterialNames: selectedMaterialNames,
'content': disciplineName: disciplineName,
r'''Tu és "Vico", o Assistente IA oficial do Learn It — uma plataforma educativa portuguesa. isMathematics: isMathematics,
);
messages.add({'role': 'system', 'content': systemPrompt});
Nunca referes o nome do modelo de linguagem. // PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 10 para manter contexto)
Nunca dizes que és Qwen, OpenAI ou qualquer outro modelo.
Respondes sempre como o Vico.
Tens personalidade simpática, confiante e motivadora.
Podes responder normalmente a saudações, agradecimentos e conversa casual — sê natural e amigável.
IMPORTANTE: NUNCA uses LaTeX ou símbolos como $ ou $$ para fórmulas matemáticas.
Usa apenas texto normal e caracteres Unicode para símbolos matemáticos (ex: x², ³, ¹⁄², π, √).
REGRAS CRÍTICAS PARA PERGUNTAS EDUCATIVAS:
- Quando te for fornecido contexto de materiais do professor (indicado com [MATERIAL: ...]), responde EXCLUSIVAMENTE com base nesse conteúdo.
- NÃO inventes factos educativos, NÃO uses conhecimento externo sobre matérias escolares.
- Se a resposta educativa não estiver no contexto fornecido, diz claramente: "Não encontrei essa informação no material disponível."
- Para conversa casual e saudações não precisas de contexto — responde livremente com a tua personalidade.
IMPORTANTE - COMO TRATAR MATERIAIS SELECIONADOS:
- Quando o aluno selecionar materiais (PDFs, fichas, exames), ASSUME que o aluno quer ajuda com esses materiais.
- NUNCA perguntes "que ficha pretendo resolver" ou "o que pretendo resolver".
- NUNCA perguntes "em que posso ajudar" quando materiais estão selecionados.
- ASSUME automaticamente que o aluno quer explicação, resolução ou ajuda com os materiais selecionados.
- Analisa os materiais selecionados e oferece ajuda proativamente: "Vejo que selecionaste [nome do material]. Como posso ajudar com este conteúdo?"
- Se a pergunta do aluno for vaga (ex: "ajuda"), usa os materiais selecionados para oferecer ajuda específica sobre o conteúdo.
IMPORTANTE - RESPOSTAS COMPLETAS:
- NUNCA termines respostas com dois pontos (:).
- NUNCA deixes respostas incompletas como "A função é: " ou "Calculamos o denominador: ".
- SEMPRE completa as frases e fornece a resposta completa.
- Se precisares de explicar um cálculo, explica-o completamente com o resultado final.
- Se precisares de definir algo, fornece a definição completa.''',
});
// PASSO 3 — BUSCAR MEMÓRIA DA CONVERSA NA Cloud Firestore (máx 4 para poupar heap)
final conversationId = ChatMemoryService.currentConversationId; final conversationId = ChatMemoryService.currentConversationId;
var lastHistoryMessageIsDuplicate = false;
if (conversationId != null) { if (conversationId != null) {
final conversationHistory = final conversationHistory =
await ChatMemoryService.getConversationMessages( await ChatMemoryService.getConversationMessages(
conversationId: conversationId, conversationId: conversationId,
limit: 4, limit: 10,
); );
for (final msg in conversationHistory) { for (final msg in conversationHistory) {
messages.add({ messages.add({
@@ -923,6 +1036,17 @@ IMPORTANTE - RESPOSTAS COMPLETAS:
'content': msg['content'] as String, 'content': msg['content'] as String,
}); });
} }
// Verificar se a última mensagem do histórico já é a pergunta atual
// (evita duplicação quando a UI guardou a mensagem antes de chamar ask())
if (conversationHistory.isNotEmpty) {
final lastMsg = conversationHistory.last;
if (lastMsg['role'] == 'user' && lastMsg['content'] == userQuery) {
lastHistoryMessageIsDuplicate = true;
Logger.info(
'Last history message matches current query — skipping duplicate user message',
);
}
}
} }
// Log de confirmação de ordem do histórico // Log de confirmação de ordem do histórico
@@ -934,8 +1058,13 @@ IMPORTANTE - RESPOSTAS COMPLETAS:
// Small talk: skip PDF lookup entirely and go straight to model // Small talk: skip PDF lookup entirely and go straight to model
if (_isSmallTalk(userQuery)) { if (_isSmallTalk(userQuery)) {
Logger.info('Small talk detected — skipping PDF lookup'); Logger.info('Small talk detected — skipping PDF lookup');
if (!lastHistoryMessageIsDuplicate) {
messages.add({'role': 'user', 'content': userQuery}); messages.add({'role': 'user', 'content': userQuery});
}
// Only save to Firestore if not already saved by UI
if (!lastHistoryMessageIsDuplicate) {
await ChatMemoryService.saveMessage(role: 'user', content: userQuery); await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
}
final response = await _callOllamaAPIWithMessages(messages); final response = await _callOllamaAPIWithMessages(messages);
await ChatMemoryService.saveMessage(role: 'assistant', content: response); await ChatMemoryService.saveMessage(role: 'assistant', content: response);
return response; return response;
@@ -963,8 +1092,13 @@ IMPORTANTE - RESPOSTAS COMPLETAS:
} }
if (pdfContext.isEmpty) { if (pdfContext.isEmpty) {
// Sem contexto encontrado — responder com base na personalidade mas sem inventar conteúdo // Sem contexto encontrado — responder com base na personalidade mas sem inventar conteúdo
if (!lastHistoryMessageIsDuplicate) {
messages.add({'role': 'user', 'content': userQuery}); messages.add({'role': 'user', 'content': userQuery});
}
// Only save to Firestore if not already saved by UI
if (!lastHistoryMessageIsDuplicate) {
await ChatMemoryService.saveMessage(role: 'user', content: userQuery); await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
}
final response = await _callOllamaAPIWithMessages(messages); final response = await _callOllamaAPIWithMessages(messages);
await ChatMemoryService.saveMessage(role: 'assistant', content: response); await ChatMemoryService.saveMessage(role: 'assistant', content: response);
return response; return response;
@@ -979,14 +1113,18 @@ $pdfContext
Pergunta: $userQuery''' Pergunta: $userQuery'''
: userQuery; : userQuery;
if (!lastHistoryMessageIsDuplicate) {
messages.add({'role': 'user', 'content': userContent}); messages.add({'role': 'user', 'content': userContent});
}
Logger.info( Logger.info(
'USING RAG AI SERVICE - Built messages array with ${messages.length} messages', 'USING RAG AI SERVICE - Built messages array with ${messages.length} messages',
); );
// Save user message to Firestore // Only save to Firestore if not already saved by UI
if (!lastHistoryMessageIsDuplicate) {
await ChatMemoryService.saveMessage(role: 'user', content: userQuery); await ChatMemoryService.saveMessage(role: 'user', content: userQuery);
}
// Call API // Call API
final response = await _callOllamaAPIWithMessages(messages); final response = await _callOllamaAPIWithMessages(messages);

View File

@@ -14,12 +14,24 @@ class ChatHistoryPage extends StatefulWidget {
class _ChatHistoryPageState extends State<ChatHistoryPage> { class _ChatHistoryPageState extends State<ChatHistoryPage> {
List<Map<String, dynamic>> _conversations = []; List<Map<String, dynamic>> _conversations = [];
List<Map<String, dynamic>> _filteredConversations = [];
bool _isLoading = true; bool _isLoading = true;
final TextEditingController _searchController = TextEditingController();
String _selectedDateFilter = 'all'; // all, today, yesterday, week, month
final Set<String> _selectedConversationIds = {};
bool _isSelectionMode = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadConversations(); _loadConversations();
_searchController.addListener(_filterConversations);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
} }
Future<void> _loadConversations() async { Future<void> _loadConversations() async {
@@ -29,6 +41,7 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_conversations = conversations; _conversations = conversations;
_filteredConversations = conversations;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -40,6 +53,136 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
} }
} }
void _toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedConversationIds.clear();
}
});
}
void _toggleConversationSelection(String conversationId) {
setState(() {
if (_selectedConversationIds.contains(conversationId)) {
_selectedConversationIds.remove(conversationId);
} else {
_selectedConversationIds.add(conversationId);
}
});
}
Future<void> _deleteSelectedConversations() async {
if (_selectedConversationIds.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Eliminar ${_selectedConversationIds.length} conversas'),
content: const Text('Tem certeza que deseja eliminar estas conversas?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Eliminar'),
),
],
),
);
if (confirmed != true) return;
try {
for (final id in _selectedConversationIds) {
await ChatMemoryService.deleteConversation(id);
}
await _loadConversations();
setState(() {
_selectedConversationIds.clear();
_isSelectionMode = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.delete_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Conversas eliminadas'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
} catch (e) {
Logger.error('Error deleting conversations: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Erro ao eliminar conversas'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
}
void _filterConversations() {
final searchQuery = _searchController.text.toLowerCase();
setState(() {
_filteredConversations = _conversations.where((conv) {
// Filter by search query (title)
final title = (conv['title'] as String? ?? '').toLowerCase();
final matchesSearch = title.contains(searchQuery);
// Filter by date
final matchesDate = _matchesDateFilter(conv);
return matchesSearch && matchesDate;
}).toList();
});
}
bool _matchesDateFilter(Map<String, dynamic> conv) {
final updatedAt = conv['updatedAt'] as Timestamp?;
if (updatedAt == null) return true;
final date = updatedAt.toDate();
final now = DateTime.now();
final difference = now.difference(date);
switch (_selectedDateFilter) {
case 'today':
return difference.inDays == 0;
case 'yesterday':
return difference.inDays == 1;
case 'week':
return difference.inDays < 7;
case 'month':
return difference.inDays < 30;
default:
return true;
}
}
Future<void> _deleteConversation(String conversationId) async { Future<void> _deleteConversation(String conversationId) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -66,15 +209,41 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
await ChatMemoryService.deleteConversation(conversationId); await ChatMemoryService.deleteConversation(conversationId);
await _loadConversations(); await _loadConversations();
if (mounted) { if (mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(const SnackBar(content: Text('Conversa eliminada'))); content: Row(
children: [
const Icon(Icons.delete_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Conversa eliminada'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
} }
} catch (e) { } catch (e) {
Logger.error('Error deleting conversation: $e'); Logger.error('Error deleting conversation: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erro ao eliminar conversa')), SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
const Text('Erro ao eliminar conversa'),
],
),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
); );
} }
} }
@@ -111,7 +280,9 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
onPressed: () => context.go('/student-dashboard'), onPressed: () => context.go('/student-dashboard'),
), ),
title: Text( title: Text(
'Histórico de Conversas', _isSelectionMode
? '${_selectedConversationIds.length} selecionadas'
: 'Histórico de Conversas',
style: TextStyle( style: TextStyle(
color: cs.onSurface, color: cs.onSurface,
fontSize: 18, fontSize: 18,
@@ -119,15 +290,87 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
), ),
), ),
actions: [ actions: [
if (_isSelectionMode)
IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: _selectedConversationIds.isEmpty
? null
: _deleteSelectedConversations,
)
else
IconButton(
icon: Icon(Icons.checklist, color: cs.onSurface),
onPressed: _toggleSelectionMode,
),
if (!_isSelectionMode)
IconButton( IconButton(
icon: Icon(Icons.refresh, color: cs.onSurface), icon: Icon(Icons.refresh, color: cs.onSurface),
onPressed: _loadConversations, onPressed: _loadConversations,
), ),
if (_isSelectionMode)
IconButton(
icon: Icon(Icons.close, color: cs.onSurface),
onPressed: _toggleSelectionMode,
),
], ],
), ),
body: _isLoading body: Column(
children: [
// Search and filters
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Search bar
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Pesquisar conversas...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: cs.outline.withValues(alpha: 0.3),
),
),
filled: true,
fillColor: cs.surface,
),
),
const SizedBox(height: 12),
// Date filter chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildDateFilterChip('Todas', 'all', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Hoje', 'today', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Ontem', 'yesterday', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Última semana', 'week', cs),
const SizedBox(width: 8),
_buildDateFilterChip('Último mês', 'month', cs),
],
),
),
],
),
),
// Conversation list
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _conversations.isEmpty : _filteredConversations.isEmpty
? Center( ? Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
@@ -141,7 +384,9 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Sem conversas ainda', _conversations.isEmpty
? 'Sem conversas ainda'
: 'Nenhuma conversa encontrada',
style: TextStyle( style: TextStyle(
color: cs.onSurfaceVariant, color: cs.onSurfaceVariant,
fontSize: 16, fontSize: 16,
@@ -149,7 +394,9 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Começa uma conversa com o Vico!', _conversations.isEmpty
? 'Começa uma conversa com o Vico!'
: 'Tenta ajustar os filtros',
style: TextStyle( style: TextStyle(
color: cs.onSurfaceVariant.withValues(alpha: 0.7), color: cs.onSurfaceVariant.withValues(alpha: 0.7),
fontSize: 14, fontSize: 14,
@@ -161,13 +408,39 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
) )
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: _conversations.length, itemCount: _filteredConversations.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final conversation = _conversations[index]; final conversation = _filteredConversations[index];
return _buildConversationCard(conversation, cs); return _buildConversationCard(conversation, cs);
}, },
), ),
),
],
),
);
}
Widget _buildDateFilterChip(String label, String value, ColorScheme cs) {
final isSelected = _selectedDateFilter == value;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedDateFilter = value;
});
_filterConversations();
},
backgroundColor: cs.surface,
selectedColor: cs.primary.withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected ? cs.primary : cs.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
side: BorderSide(
color: isSelected ? cs.primary : cs.outline.withValues(alpha: 0.3),
),
); );
} }
@@ -175,10 +448,16 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
Map<String, dynamic> conversation, Map<String, dynamic> conversation,
ColorScheme cs, ColorScheme cs,
) { ) {
final isSelected = _selectedConversationIds.contains(conversation['id']);
return Dismissible( return Dismissible(
key: Key(conversation['id']), key: Key(conversation['id']),
direction: DismissDirection.endToStart, direction: _isSelectionMode
onDismissed: (_) => _deleteConversation(conversation['id']), ? DismissDirection.none
: DismissDirection.endToStart,
onDismissed: _isSelectionMode
? null
: (_) => _deleteConversation(conversation['id']),
background: Container( background: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red, color: Colors.red,
@@ -190,16 +469,24 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
), ),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
if (_isSelectionMode) {
_toggleConversationSelection(conversation['id']);
} else {
ChatMemoryService.setCurrentConversationId(conversation['id']); ChatMemoryService.setCurrentConversationId(conversation['id']);
context.go('/ai-tutor/${conversation['id']}'); context.go('/ai-tutor/${conversation['id']}');
}
}, },
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surface, color: isSelected ? cs.primary.withValues(alpha: 0.1) : cs.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.outline.withValues(alpha: 0.15)), border: Border.all(
color: isSelected
? cs.primary
: cs.outline.withValues(alpha: 0.15),
),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: cs.shadow.withValues(alpha: 0.05), color: cs.shadow.withValues(alpha: 0.05),
@@ -213,12 +500,25 @@ class _ChatHistoryPageState extends State<ChatHistoryPage> {
children: [ children: [
Row( Row(
children: [ children: [
if (_isSelectionMode)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Checkbox(
value: isSelected,
onChanged: (_) =>
_toggleConversationSelection(conversation['id']),
),
)
else
Container( Container(
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [cs.primary, cs.primary.withValues(alpha: 0.7)], colors: [
cs.primary,
cs.primary.withValues(alpha: 0.7),
],
), ),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),

View File

@@ -77,28 +77,55 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
final conversation = await ChatMemoryService.getConversation( final conversation = await ChatMemoryService.getConversation(
conversationId, conversationId,
); );
if (conversation == null) return; if (conversation == null) {
Logger.warning('Conversation not found: $conversationId');
return;
}
final messages = await ChatMemoryService.getConversationMessages( final messages = await ChatMemoryService.getConversationMessages(
conversationId: conversationId, conversationId: conversationId,
limit: 50, limit: 50,
); );
final materialIds = conversation['selectedMaterialIds'] as List<dynamic>?; // Safely extract material IDs with type checking
if (materialIds != null) { try {
final materialIdsRaw = conversation['selectedMaterialIds'];
if (materialIdsRaw != null && materialIdsRaw is List) {
final materialIds = (materialIdsRaw as List)
.where((item) => item is String)
.cast<String>()
.toSet();
if (mounted) {
setState(() { setState(() {
_selectedMaterialIds = materialIds.cast<String>().toSet(); _selectedMaterialIds = materialIds;
_materialsConfirmed = true; _materialsConfirmed = materialIds.isNotEmpty;
}); });
} }
}
} catch (e) {
Logger.error('Error loading material IDs: $e');
// Continue without materials rather than crash
}
// Load messages from Firestore // Load messages from Firestore with safe casting
final loadedMessages = messages.map((msg) { final loadedMessages = messages.map((msg) {
try {
return { return {
'content': msg['content'] as String, 'content': msg['content']?.toString() ?? '',
'isUser': msg['role'] == 'user', 'isUser': msg['role']?.toString() == 'user',
'timestamp': msg['createdAt'] as Timestamp? ?? DateTime.now(), 'timestamp': msg['createdAt'] is Timestamp
? (msg['createdAt'] as Timestamp).toDate()
: DateTime.now(),
}; };
} catch (e) {
Logger.error('Error mapping message: $e');
// Return a safe fallback message
return {
'content': '[Mensagem indisponível]',
'isUser': false,
'timestamp': DateTime.now(),
};
}
}).toList(); }).toList();
if (mounted) { if (mounted) {
@@ -109,6 +136,12 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
} }
} catch (e) { } catch (e) {
Logger.error('Error loading conversation: $e'); Logger.error('Error loading conversation: $e');
// Show error to user
if (mounted) {
setState(() {
_messages = [];
});
}
} }
} }
@@ -256,6 +289,42 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
size: 20, size: 20,
), ),
), ),
IconButton(
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nova conversa'),
content: const Text(
'Isto vai limpar o chat atual e guardá-lo no histórico. Continuar?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('Limpar'),
),
],
),
);
if (confirmed == true) {
ChatMemoryService.setCurrentConversationId(null);
context.go('/ai-tutor');
}
},
icon: const Icon(
Icons.add_comment,
color: Colors.white,
size: 20,
),
tooltip: 'Nova conversa',
),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
), ),
@@ -1289,12 +1358,6 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
setState(() { setState(() {
_selectedMaterialIds = tempSelected; _selectedMaterialIds = tempSelected;
_materialsConfirmed = true; _materialsConfirmed = true;
if (!isFirst && selectionChanged) {
final welcome = _messages.isNotEmpty
? [_messages.first]
: <Map<String, dynamic>>[];
_messages = welcome;
}
}); });
if (isFirst) _addWelcomeMessage(); if (isFirst) _addWelcomeMessage();
if (selectionChanged) { if (selectionChanged) {
@@ -1319,9 +1382,10 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
} }
void _addWelcomeMessage() { void _addWelcomeMessage() {
final welcomeText =
'Olá! Estou pronto para te ajudar com os materiais que selecionaste. Faz a tua pergunta sempre que quiseres! 💪';
final welcomeMessage = { final welcomeMessage = {
'content': 'content': welcomeText,
'''Olá! Estou pronto para te ajudar com os materiais que selecionaste. Faz a tua pergunta sempre que quiseres! 💪''',
'isUser': false, 'isUser': false,
'timestamp': DateTime.now(), 'timestamp': DateTime.now(),
}; };
@@ -1330,30 +1394,82 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
_messages.add(welcomeMessage); _messages.add(welcomeMessage);
}); });
// Save welcome message to Firestore so the AI knows it already greeted the user
ChatMemoryService.saveMessage(role: 'assistant', content: welcomeText);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom(); _scrollToBottom();
}); });
} }
/// Get names of selected materials
List<String> _getSelectedMaterialNames() {
return _selectedMaterialIds.map((id) {
final material = _availableMaterials.firstWhere(
(m) => m['id'] == id,
orElse: () => {'id': id, 'name': 'Material'},
);
return material['name'] ?? 'Material';
}).toList();
}
/// Determine discipline name from selected materials
String? _getDisciplineName() {
if (_selectedMaterialIds.isEmpty) return null;
// Find classId of first selected material
final firstId = _selectedMaterialIds.first;
final material = _availableMaterials.firstWhere(
(m) => m['id'] == firstId,
orElse: () => {},
);
final classId = material['classId'];
if (classId != null && _classNames.containsKey(classId)) {
return _classNames[classId];
}
return null;
}
/// Detect if current discipline is mathematics
bool _isMathematics() {
final discipline = _getDisciplineName();
if (discipline == null) return false;
final lower = discipline.toLowerCase();
final mathKeywords = [
'matemática',
'matematica',
'math',
'álgebra',
'algebra',
'geometria',
'cálculo',
'calculo',
'estatística',
'estatistica',
'física',
'fisica',
'química',
'quimica',
];
return mathKeywords.any((k) => lower.contains(k));
}
Future<void> _handleSendMessage() async { Future<void> _handleSendMessage() async {
if (_messageController.text.trim().isEmpty) return; if (_messageController.text.trim().isEmpty) return;
final userMessage = _messageController.text.trim(); final userMessage = _messageController.text.trim();
// Save user message to Firestore // Update conversation title if it's still the default "Nova conversa"
await ChatMemoryService.saveMessage(role: 'user', content: userMessage);
// Update conversation title if it's the first message
final currentId = ChatMemoryService.currentConversationId; final currentId = ChatMemoryService.currentConversationId;
if (currentId != null && _messages.isEmpty) { if (currentId != null) {
final title = userMessage.length > 30 final conversation = await ChatMemoryService.getConversation(currentId);
? '${userMessage.substring(0, 30)}...' if (conversation != null && conversation['title'] == 'Nova conversa') {
: userMessage; final title = ChatMemoryService.generateConversationTitle(userMessage);
await ChatMemoryService.updateConversationTitle( await ChatMemoryService.updateConversationTitle(
conversationId: currentId, conversationId: currentId,
title: title, title: title,
); );
} }
}
// Add user message // Add user message
setState(() { setState(() {
@@ -1377,6 +1493,11 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
selectedMaterialIds: _selectedMaterialIds.isEmpty selectedMaterialIds: _selectedMaterialIds.isEmpty
? null ? null
: _selectedMaterialIds.toList(), : _selectedMaterialIds.toList(),
selectedMaterialNames: _selectedMaterialIds.isEmpty
? null
: _getSelectedMaterialNames(),
disciplineName: _getDisciplineName(),
isMathematics: _isMathematics(),
); );
final preview = replyText.length > 50 final preview = replyText.length > 50
@@ -1384,12 +1505,6 @@ class _TutorChatPageSimpleState extends State<TutorChatPageSimple>
: replyText; : replyText;
Logger.info('Ollama response received: $preview...'); Logger.info('Ollama response received: $preview...');
// Save assistant message to Firestore
await ChatMemoryService.saveMessage(
role: 'assistant',
content: replyText,
);
setState(() { setState(() {
_messages.add({ _messages.add({
'content': replyText, 'content': replyText,