sendMessage method
- String userMessage
Send a user message, call Claude, and update state.
Implementation
Future<void> sendMessage(String userMessage) async {
// Append user message.
final userMsg = ChatMessage(role: 'user', content: userMessage);
state = state.copyWith(
messages: [...state.messages, userMsg],
isLoading: true,
error: null,
);
try {
// Auto-create a session on first message. Generate the id client-
// side and route through the outbox so offline-first message sends
// can attach to it even when the Supabase insert doesn't reach the
// server yet.
if (_sessionId == null) {
final sessionType = _modeToSessionType(params.mode);
_sessionStartedAt = DateTime.now();
final sessionId = _uuid.v4();
_sessionId = sessionId;
await _persistSession(sessionId, {
'id': sessionId,
'session_type': sessionType,
if (params.topicId != null) 'topic_id': params.topicId,
'started_at': _sessionStartedAt!.toIso8601String(),
'is_validation': params.isValidation,
if (params.isValidation && params.validationTarget != null)
'validation_target': params.validationTarget,
});
}
// Persist the user message via outbox (durable across offline drops).
await _persistMessage(
sessionId: _sessionId!,
role: 'user',
content: userMessage,
);
// Resolve AI datasource. The proxy needs the active sessionId for
// per-session token-budget enforcement and audit logging.
final ai = _resolveAi(sessionId: _sessionId);
// When using the proxy, skip client-side prompt building and chunk
// retrieval — the proxy handles both server-side, including the
// `<ctx>` block resolved by `get_tutor_context`.
final String fullSystemPrompt;
if (ai is ProxyAiDatasource) {
fullSystemPrompt = ''; // Ignored by ProxyAiDatasource.
} else {
// Build system prompt based on mode (or validation variant).
final systemPrompt = params.isValidation
? _buildValidationSystemPrompt()
: _buildSystemPrompt();
// Resolve session context (topic title, mastery, rubric step)
// client-side — mirrors the server's `<ctx>` block so the model
// gets the same anchor whether the user is on proxy or direct.
final sessionCtxBlock = await _resolveDirectSessionContext();
// Load content chunks via adaptive retrieval. The on-device llama
// LLM has a 4096-token context window; cap reference material at
// ~4k chars so the system prompt + ctx block survive truncation.
// Remote providers (Claude/OpenAI/Gemini) keep the original 50k
// budget — they have 6+ orders of magnitude more context to spare.
final int chunkCharBudget = ai is LocalLlmDatasource
? RetrievalConfig.maxChunkCharsLocal
: RetrievalConfig.maxChunkChars;
String? chunkContext;
if (params.topicId != null) {
final retriever = ref.read(contentRetrieverProvider);
final chunks = await retriever.retrieveChunks(
mode: params.mode,
topicId: params.topicId,
userMessage: userMessage,
);
if (chunks.isNotEmpty) {
final buf = StringBuffer();
for (final c in chunks) {
if (buf.length + c.content.length > chunkCharBudget) {
break;
}
if (buf.isNotEmpty) buf.write('\n\n');
buf.write(c.content);
}
chunkContext = buf.toString();
}
}
// Assemble: mode prompt + reference material + ctx block. The ctx
// block is small and essential (topic title + mastery + rubric
// step) — placing it LAST keeps the topic anchor adjacent to the
// user turn that follows and protects it from tail-side truncation
// on small local-LLM context windows.
final sb = StringBuffer(systemPrompt);
if (chunkContext != null) {
sb.write('\n\n<reference_material>\n');
sb.write(chunkContext);
sb.write('\n</reference_material>');
}
if (sessionCtxBlock != null) {
sb.write('\n\n');
sb.write(sessionCtxBlock);
}
fullSystemPrompt = sb.toString();
}
// Build message list for the API, with truncation for long conversations.
var apiMessages = state.messages
.map((m) => {'role': m.role, 'content': m.content})
.toList();
// Truncate conversation history if too long: keep first + most recent.
if (apiMessages.length > 2) {
int totalChars = apiMessages.fold(
0,
(sum, m) => sum + (m['content']?.length ?? 0),
);
while (totalChars > RetrievalConfig.maxConversationChars &&
apiMessages.length > 2) {
final removed = apiMessages.removeAt(1);
totalChars -= removed['content']?.length ?? 0;
}
}
// Call AI.
final response = await ai.sendMessage(
systemPrompt: fullSystemPrompt,
messages: apiMessages,
);
// Strip [PREREQUISITE_GAP: …] tags before showing the message to
// the user — concepts are surfaced separately as a nudge banner.
final parsed = parsePrereqGaps(response);
final visibleText = parsed.cleanedText;
final mergedConcepts = parsed.concepts.isEmpty
? state.prereqGapConcepts
: <String>[
...state.prereqGapConcepts,
for (final c in parsed.concepts)
if (!state.prereqGapConcepts.any(
(existing) => existing.toLowerCase() == c.toLowerCase(),
))
c,
];
// Append assistant message.
final assistantMsg = ChatMessage(role: 'assistant', content: visibleText);
state = state.copyWith(
messages: [...state.messages, assistantMsg],
isLoading: false,
prereqGapConcepts: mergedConcepts,
);
// Persist the cleaned (user-visible) text via the outbox so it
// survives offline drops. The raw PREREQUISITE_GAP tag stays in
// the model's in-memory context for that turn but doesn't pollute
// the restored conversation later.
await _persistMessage(
sessionId: _sessionId!,
role: 'assistant',
content: visibleText,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: userFriendlyMessage(e, logName: 'ChatNotifier'),
);
}
}