Melhorias no comportamento do chat com IA;
Adição do histórico de conversas com IA.
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user