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 {
    final supabase = ref.read(supabaseDatasourceProvider);

    // Auto-create a session on first message.
    if (_sessionId == null) {
      final sessionType = _modeToSessionType(params.mode);
      _sessionStartedAt = DateTime.now();
      final session = await supabase.createSession({
        '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,
      });
      _sessionId = session.id;
    }

    // Persist the user message.
    await supabase.saveMessage(_sessionId!, 'user', userMessage);

    // Resolve AI datasource.
    final ai = _resolveAi();

    // When using the proxy, skip client-side prompt building and chunk
    // retrieval — the proxy handles both server-side.
    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();

      // Load content chunks via adaptive retrieval.
      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 >
                RetrievalConfig.maxChunkChars) {
              break;
            }
            if (buf.isNotEmpty) buf.write('\n\n');
            buf.write(c.content);
          }
          chunkContext = buf.toString();
        }
      }

      fullSystemPrompt = chunkContext != null
          ? '$systemPrompt\n\n<reference_material>\n$chunkContext\n</reference_material>'
          : systemPrompt;
    }

    // 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,
    );

    // Append assistant message.
    final assistantMsg = ChatMessage(role: 'assistant', content: response);
    state = state.copyWith(
      messages: [...state.messages, assistantMsg],
      isLoading: false,
    );

    // Persist assistant message.
    await supabase.saveMessage(_sessionId!, 'assistant', response);
  } catch (e) {
    state = state.copyWith(
      isLoading: false,
      error: userFriendlyMessage(e, logName: 'ChatNotifier'),
    );
  }
}