sendMessage method

Future<void> sendMessage(
  1. 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'),
    );
  }
}