diff --git a/lib/src/providers/interface/chat_message.dart b/lib/src/providers/interface/chat_message.dart index a1e6aa1..74e2733 100644 --- a/lib/src/providers/interface/chat_message.dart +++ b/lib/src/providers/interface/chat_message.dart @@ -6,65 +6,123 @@ // ignore_for_file: avoid_dynamic_calls import 'dart:convert'; +import 'dart:math' as math; -import '../../providers/interface/attachments.dart'; +import 'package:flutter/foundation.dart'; + +import 'attachments.dart'; import 'message_origin.dart'; +import '../../utility.dart'; /// Represents a message in a chat conversation. /// /// This class encapsulates the properties and behavior of a chat message, /// including its unique identifier, origin (user or LLM), text content, /// and any attachments. -class ChatMessage { +class ChatMessage extends ChangeNotifier { /// Constructs a [ChatMessage] instance. /// + /// The [id] parameter is a unique identifier for the message. /// The [origin] parameter specifies the origin of the message (user or LLM). /// The [text] parameter is the content of the message. It can be null or /// empty if the message is from an LLM. For user-originated messages, [text] - /// must not be null or empty. The [attachments] parameter is a list of any - /// files or media attached to the message. + /// must not be null or empty. + /// The [attachments] parameter is a list of any files or media attached to the message. + /// The [children] parameter is a list of child messages associated with + /// this message. + /// The [currentChild] parameter is the currently active child message. ChatMessage({ + ValueKey? id, required this.origin, - required this.text, - required this.attachments, - }) : assert(origin.isUser && text != null && text.isNotEmpty || origin.isLlm); + String? text, + this.attachments = const [], + List? children, + ChatMessage? currentChild, + }) : id = id ?? ValueKey(generateUuidV4()), + _text = text, + _children = children ?? [], + _currentChild = currentChild { + if (currentChild == null) { + _currentChild = _children.isNotEmpty ? _children.first : null; + } + else if (!_children.contains(currentChild)) { + _children.add(currentChild); + } + + for (final child in _children) { + child.parent = this; + } + } - /// Converts a JSON map representation to a [ChatMessage]. - /// - /// The map should contain the following keys: - /// - 'origin': The origin of the message (user or model). - /// - 'text': The text content of the message. - /// - 'attachments': A list of attachments, each represented as a map with: - /// - 'type': The type of the attachment ('file' or 'link'). - /// - 'name': The name of the attachment. - /// - 'mimeType': The MIME type of the attachment. - /// - 'data': The data of the attachment, either as a base64 encoded string - /// (for files) or a URL (for links). - factory ChatMessage.fromJson(Map map) => ChatMessage( - origin: MessageOrigin.values.byName(map['origin'] as String), - text: map['text'] as String, - attachments: [ - for (final attachment in map['attachments'] as List) + /// Converts a JSON map list representation to a [ChatMessage]. + /// + /// If no [id] is provided, it will be derived from the first map in the list with a null parent. + /// Which is assumed to be the root message. + factory ChatMessage.fromMapList(List> mapList, [ValueKey? id]) { + id ??= ValueKey(mapList.firstWhere( + (map) => map['parent'] == null, + orElse: () => throw ArgumentError('No root found in mapList'), + )['id']); + + final map = mapList.firstWhere( + (map) => map['id'] == id!.value, + orElse: () => throw ArgumentError('No message found with id: ${id!.value}'), + ); + + List attachments = []; + if (map['attachments'] != null) { + for (final attachment in map['attachments']) { switch (attachment['type'] as String) { - 'file' => FileAttachment.fileOrImage( - name: attachment['name'] as String, - mimeType: attachment['mimeType'] as String, - bytes: base64Decode(attachment['data'] as String), - ), - 'link' => LinkAttachment( - name: attachment['name'] as String, - url: Uri.parse(attachment['data'] as String), - ), - _ => throw UnimplementedError(), - }, - ], - ); + case 'file': + attachments.add( + FileAttachment.fileOrImage( + name: attachment['name'] as String, + mimeType: attachment['mimeType'] as String, + bytes: base64Decode(attachment['data'] as String), + ), + ); + break; + case 'link': + attachments.add( + LinkAttachment( + name: attachment['name'] as String, + url: Uri.parse(attachment['data'] as String), + ), + ); + break; + default: + throw UnimplementedError('Unknown attachment type: ${attachment['type']}'); + } + } + } + + List children = []; + for (final childId in map['children']) { + children.add(ChatMessage.fromMapList(mapList, ValueKey(childId))); + } + + ChatMessage? currentChild; + if (map['current_child'] != null) { + currentChild = children.firstWhere( + (child) => child.id.value == map['current_child'], + orElse: () => throw ArgumentError('No child found with id: ${map['current_child']}'), + ); + } + + return ChatMessage( + id: id, + origin: MessageOrigin.fromString(map['role']), + text: map['text'], + attachments: attachments, + children: children, + currentChild: currentChild, + ); + } /// Factory constructor for creating an LLM-originated message. /// /// Creates a message with an empty text content and no attachments. - factory ChatMessage.llm() => - ChatMessage(origin: MessageOrigin.llm, text: null, attachments: []); + factory ChatMessage.llm() => ChatMessage(origin: MessageOrigin.llm); /// Factory constructor for creating a user-originated message. /// @@ -77,8 +135,42 @@ class ChatMessage { attachments: attachments, ); + /// Appends additional text to the existing message content. + /// + /// This is typically used for LLM messages that are streamed in parts. + void append(String text) => this.text = (this.text ?? '') + text; + + final List _children; + + /// List of child messages associated with this message. + List get children => _children; + + ChatMessage? _parent; + + /// The parent message of this message. + ChatMessage? get parent => _parent; + + set parent(ChatMessage? value) { + if (_parent != null) { + throw ArgumentError('Parent already set'); + } + + _parent = value; + notifyListeners(); + } + + /// A Unique identifier for the message. + final ValueKey id; + + String? _text; + /// Text content of the message. - String? text; + String? get text => _text; + + set text(String? value) { + _text = value; + notifyListeners(); + } /// The origin of the message (user or LLM). final MessageOrigin origin; @@ -86,50 +178,149 @@ class ChatMessage { /// Any attachments associated with the message. final Iterable attachments; - /// Appends additional text to the existing message content. - /// - /// This is typically used for LLM messages that are streamed in parts. - void append(String text) => this.text = (this.text ?? '') + text; + ChatMessage? _currentChild; + + /// The currently active child message. + ChatMessage? get currentChild => _currentChild; + + /// Sets the currently active child message to the next child in the list. + /// If there are no children or if the current child is the last one, it does nothing. + /// If the current child is null, it sets the first child as the current child. + void nextChild() { + if (_children.isEmpty) return; + + int index = 0; + if (_currentChild != null) { + final lastIndex = _children.indexOf(_currentChild!); + index = math.min(_children.length - 1, lastIndex + 1); + } + _currentChild = _children[index]; + notifyListeners(); + } + + /// Sets the currently active child message to the previous child in the list. + /// If there are no children or if the current child is the first one, it does nothing. + /// If the current child is null, it sets the first child as the current child. + void previousChild() { + if (_children.isEmpty) return; + + int index = 0; + if (_currentChild != null) { + final lastIndex = _children.indexOf(_currentChild!); + index = math.max(0, lastIndex - 1); + } + _currentChild = _children[index]; + notifyListeners(); + } + + /// Adds a child message to the list of children. + /// Sets the added child as the current child. + void addChild(ChatMessage child) { + _children.add(child); + child.parent = this; + _currentChild = child; + notifyListeners(); + } + + /// Removes a child message from the list of children. + /// If the removed child was the current child, sets the first child as the current child. + void removeChild(ChatMessage child) { + _children.remove(child); + _currentChild = _children.isNotEmpty ? _children.first : null; + notifyListeners(); + } + + /// Returns the last message in the chain of messages. + ChatMessage get tail => chain.last; + + /// Returns the first message in the chain of messages. + ChatMessage get root => chainReverse.last; + + /// Returns the current conversation chain of messages. + /// + /// The chain starts from the current message and goes down to the last message. + List get chain { + final List chain = []; + + ChatMessage current = this; + do { + chain.add(current); + + if (current.currentChild != null) { + current = current.currentChild!; + } + } while (current.currentChild != null); + + return chain; + } + + /// Returns the reverse of the current conversation chain of messages. + /// + /// The chain starts from the this message and goes up to the first message. + List get chainReverse { + final List chain = []; + + ChatMessage current = this; + do { + chain.add(current); + + if (current.parent != null) { + current = current.parent!; + } + } while (current.parent != null); + + return chain; + } + + /// Returns the index of the current child in the list of children. + /// If the current child is null, it returns -1. + int get currentChildIndex => _children.indexOf(_currentChild!); + + /// Converts the message and its children to a list of maps. + List> toMapList() { + final mapList = [{ + 'id': id.value, + 'parent': parent?.id.value, + 'children': children.map((child) => child.id.value).toList(), + 'current_child': currentChild?.id.value, + 'origin': origin.name, + 'text': text, + 'attachments': [ + for (final attachment in attachments) + { + 'type': switch (attachment) { + (FileAttachment _) => 'file', + (LinkAttachment _) => 'link', + }, + 'name': attachment.name, + 'mimeType': switch (attachment) { + (final FileAttachment a) => a.mimeType, + (final LinkAttachment a) => a.mimeType, + }, + 'data': switch (attachment) { + (final FileAttachment a) => base64Encode(a.bytes), + (final LinkAttachment a) => a.url, + }, + }, + ] + }]; + + for (final child in _children) { + mapList.addAll(child.toMapList()); + } + + return mapList; + } @override String toString() => 'ChatMessage(' + 'id: $id, ' + 'parent: ${parent?.id.value}, ' + 'currentChild: ${currentChild?.id.value}, ' + 'children: ${_children.map((child) => child.id.value).toList()}, ' 'origin: $origin, ' 'text: $text, ' 'attachments: $attachments' ')'; - - /// Converts a [ChatMessage] to a JSON map representation. - /// - /// The map contains the following keys: - /// - 'origin': The origin of the message (user or model). - /// - 'text': The text content of the message. - /// - 'attachments': A list of attachments, each represented as a map with: - /// - 'type': The type of the attachment ('file' or 'link'). - /// - 'name': The name of the attachment. - /// - 'mimeType': The MIME type of the attachment. - /// - 'data': The data of the attachment, either as a base64 encoded string - /// (for files) or a URL (for links). - Map toJson() => { - 'origin': origin.name, - 'text': text, - 'attachments': [ - for (final attachment in attachments) - { - 'type': switch (attachment) { - (FileAttachment _) => 'file', - (LinkAttachment _) => 'link', - }, - 'name': attachment.name, - 'mimeType': switch (attachment) { - (final FileAttachment a) => a.mimeType, - (final LinkAttachment a) => a.mimeType, - }, - 'data': switch (attachment) { - (final FileAttachment a) => base64Encode(a.bytes), - (final LinkAttachment a) => a.url, - }, - }, - ], - }; -} +} \ No newline at end of file diff --git a/lib/src/providers/interface/message_origin.dart b/lib/src/providers/interface/message_origin.dart index e048577..7cd6ea0 100644 --- a/lib/src/providers/interface/message_origin.dart +++ b/lib/src/providers/interface/message_origin.dart @@ -15,4 +15,16 @@ enum MessageOrigin { /// Checks if the message origin is from the LLM. bool get isLlm => this == MessageOrigin.llm; + + /// Gets the MessageOrigin from a string. + static MessageOrigin fromString(String origin) { + switch (origin.toLowerCase()) { + case 'user': + return MessageOrigin.user; + case 'llm': + return MessageOrigin.llm; + default: + throw ArgumentError('Invalid message origin: $origin'); + } + } } diff --git a/lib/src/utility.dart b/lib/src/utility.dart index c2caf11..a5e070e 100644 --- a/lib/src/utility.dart +++ b/lib/src/utility.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math'; + import 'package:flutter/cupertino.dart' show BuildContext, CupertinoApp; import 'package:flutter/services.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -75,3 +77,32 @@ Color? invertColor(Color? color) => blue: 1 - color.b, ) : null; + +/// Generates a random UUID (Universally Unique Identifier) version 4. +/// +/// This function creates a random UUID using secure random bytes. +/// The UUID is formatted as a string in the standard 8-4-4-4-12 format. +/// +/// Returns: A [String] representing the generated UUID version 4. +String generateUuidV4() { + final random = Random.secure(); + final bytes = Uint8List(16); + + for (int i = 0; i < 16; i++) { + bytes[i] = random.nextInt(256); + } + + // Set version to 4 ---- 0100xxxx + bytes[6] = (bytes[6] & 0x0f) | 0x40; + + // Set variant to DCE 1.1 ---- 10xxxxxx + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + StringBuffer buffer = StringBuffer(); + for (int i = 0; i < bytes.length; i++) { + buffer.write(bytes[i].toRadixString(16).padLeft(2, '0')); + if ([3, 5, 7, 9].contains(i)) buffer.write('-'); + } + + return buffer.toString(); +} \ No newline at end of file