messagesFromChatMessage function

Future<List<Message>> messagesFromChatMessage(
  1. ChatMessage message
)

Converts a genai_primitives ChatMessage into flutter_gemma Messages.

One ChatMessage may yield >1 Message (tool results become sibling messages). The text/media parts collapse into a single Message emitted FIRST, then one sibling Message per tool result in part order. A ThinkingPart on a model turn is stripped by design (thoughts aren't fed back as history), so a thought-only model turn yields no Message; a ThinkingPart on a user turn is misuse and throws. A LinkPart also throws: this inference layer does not fetch URLs or read files — resolve links to bytes caller-side and pass a DataPart. Other unsupported content throws rather than being silently dropped.

Implementation

Future<List<Message>> messagesFromChatMessage(ChatMessage message) async {
  if (message.role == ChatMessageRole.system) {
    throw ArgumentError(
      'System messages are not input — set them via createChat(systemInstruction:).',
    );
  }
  final parts = message.parts;
  if (parts.isEmpty) {
    throw ArgumentError('ChatMessage has no parts.');
  }
  final isUser = message.role == ChatMessageRole.user;

  final images = <Uint8List>[];
  Uint8List? audio;
  final messages = <Message>[];

  for (final part in parts) {
    switch (part) {
      case DataPart(:final bytes, :final mimeType):
        audio = _routeMedia(bytes, mimeType, images, audio);
      case LinkPart():
        throw UnsupportedError(
          'LinkPart is not resolved by flutter_gemma: an on-device model needs '
          'the media bytes, and this inference layer does not fetch URLs or read '
          'files. Resolve the link yourself and pass a DataPart with the bytes. '
          '(For URL/web content behind a permission gate, use flutter_gemma_agent.)',
        );
      case ToolPart(kind: ToolPartKind.result, :final toolName, :final result):
        if (!isUser) {
          throw UnsupportedError(
            'A tool result is caller input, not model output.',
          );
        }
        final resp = result is Map<String, dynamic>
            ? result
            : {'result': result};
        messages.add(Message.toolResponse(toolName: toolName, response: resp));
      case ToolPart(kind: ToolPartKind.call, :final toolName, :final arguments):
        if (isUser) {
          throw UnsupportedError(
            'A tool call is model output, not user input.',
          );
        }
        messages.add(
          Message.toolCall(
            text: jsonEncode({'name': toolName, 'parameters': arguments ?? {}}),
          ),
        );
      case ThinkingPart():
        // A model turn's thought is stripped from history: Gemma re-feeds the
        // answer, not the reasoning (see the thinking docs' thought-stripping),
        // so a model turn returned by the output converter round-trips back in.
        // A user-role thought is misuse — thoughts are model output, fail loud.
        if (isUser) {
          throw UnsupportedError(
            'A ThinkingPart is model output, not user input.',
          );
        }
      case TextPart():
      // No-op: TextPart text is read from `message.text` after the loop.
    }
  }

  final text = message.text;
  if (text.isNotEmpty || images.isNotEmpty || audio != null) {
    messages.insert(
      0,
      Message(
        text: text,
        isUser: isUser,
        images: images,
        imageBytes: images.isNotEmpty ? images.first : null,
        audioBytes: audio,
      ),
    );
  }
  // A user turn that reduces to zero staged Messages (e.g. a lone empty
  // TextPart) would otherwise stage nothing and generate on stale context —
  // fail loud instead. A model turn legitimately reduces to [] (a thought-only
  // turn strips its ThinkingPart), so only guard user input.
  if (isUser && messages.isEmpty) {
    throw ArgumentError(
      'ChatMessage reduced to no staged content — a user turn needs text, '
      'media, or a tool result.',
    );
  }
  return messages;
}