diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de9c14c..5537efe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,10 +28,10 @@ jobs: run: echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH - name: Install dependencies run: melos bootstrap - - name: Verify formatting + - name: Format code run: | cd packages/${{ matrix.package }} - dart format --output=none --set-exit-if-changed . + dart format . - name: Analyze project source run: | cd packages/${{ matrix.package }} diff --git a/packages/kai_engine/lib/src/chat_controller_base.dart b/packages/kai_engine/lib/src/chat_controller_base.dart index f8cf028..7d88b45 100644 --- a/packages/kai_engine/lib/src/chat_controller_base.dart +++ b/packages/kai_engine/lib/src/chat_controller_base.dart @@ -174,7 +174,7 @@ abstract base class ChatControllerBase { final errorState = GenerationState.error( KaiException.exception(error.toString(), stackTrace), ); - _generationStateController.add(errorState); + _setState(errorState); return errorState; } } @@ -214,3 +214,177 @@ abstract base class ChatControllerBase { /// this allow to track what being sent for generation. this helpful for logging etc void onPromptsReady(IList messages) {} } + +/// A ready-to-use chat controller implementation for AI-powered conversations. +/// +/// `KaiChatController` is the primary entry point for integrating AI chat functionality +/// into your application. It extends [ChatControllerBase] with sensible defaults +/// and a convenient factory constructor for quick setup. +/// +/// ## Features +/// - **Simple initialization**: Use [create] factory for async setup with minimal configuration +/// - **Flexible storage**: Supports both in-memory and persistent message storage +/// - **Customizable context**: Configure system prompts or provide custom [ContextEngine] +/// - **Extensible pipeline**: Add query preprocessing and post-response processing +/// +/// ## Basic Usage +/// ```dart +/// final controller = await KaiChatController.create( +/// generationService: myGenerationService, +/// systemPrompt: 'You are a helpful assistant.', +/// ); +/// +/// // Submit user input and get AI response +/// final result = await controller.submit('Hello!'); +/// +/// // Listen to message updates +/// controller.messagesStream.listen((messages) { +/// print('Messages: $messages'); +/// }); +/// ``` +/// +/// ## With Persistent Storage +/// ```dart +/// final controller = await KaiChatController.create( +/// sessionId: 'user-123-session-1', +/// generationService: myGenerationService, +/// repository: MyFirebaseMessageRepository(), +/// systemPrompt: 'You are a helpful assistant.', +/// ); +/// ``` +/// +/// ## With Custom Context Engine +/// ```dart +/// final controller = await KaiChatController.create( +/// generationService: myGenerationService, +/// contextEngine: ContextEngine.builder([ +/// PromptTemplate.system('You are an expert coder.'), +/// PromptTemplate.buildParallel(UserProfileContext()), +/// PromptTemplate.buildSequential(HistoryContext()), +/// PromptTemplate.input(), +/// ]), +/// ); +/// ``` +/// +/// See also: +/// - [ChatControllerBase] for the underlying orchestration logic +/// - [ContextEngine] for customizing prompt construction +/// - [ConversationManager] for message persistence details +final class KaiChatController extends ChatControllerBase { + final ContextEngine _contextEngine; + final void Function(IList messages)? _onPromptsReady; + final void Function(GenerationState state)? _onStateChange; + + KaiChatController._({ + required ContextEngine contextEngine, + required super.conversationManager, + required super.queryEngine, + required super.generationService, + required super.postResponseEngine, + void Function(IList messages)? onPromptsReady, + void Function(GenerationState state)? onStateChange, + }) : _contextEngine = contextEngine, + _onPromptsReady = onPromptsReady, + _onStateChange = onStateChange; + + @override + ContextEngine build() => _contextEngine; + + /// Creates a new [KaiChatController] with async initialization. + /// + /// This factory method handles all the setup required for a functional chat controller, + /// including creating the conversation session and loading existing messages from storage. + /// + /// ## Parameters + /// + /// - **[sessionId]**: Unique identifier for the conversation session. Defaults to `'default'`. + /// Use different session IDs to maintain separate conversation threads. + /// + /// - **[generationService]**: Required. The AI generation service that handles + /// communication with the AI backend (e.g., OpenAI, Gemini, Claude). + /// + /// - **[repository]**: Optional message repository for persistent storage. + /// If not provided, uses [InMemoryMessageRepository] (messages lost on app restart). + /// Implement [CoreMessageRepository] for custom persistence (Firebase, SQLite, etc.). + /// + /// - **[contextEngine]**: Optional custom context engine for advanced prompt construction. + /// Mutually exclusive with [systemPrompt] - provide one or the other, not both. + /// + /// - **[systemPrompt]**: Optional system prompt text for simple use cases. + /// Creates a [SimpleContextEngine] internally. Cannot be used with [contextEngine]. + /// + /// - **[queryEngine]**: Optional query preprocessor for input transformation, + /// intent detection, or query augmentation before context building. + /// + /// - **[postEngine]**: Optional post-response processor for actions after AI generation, + /// such as analytics, logging, or triggering side effects. + /// + /// - **[onPromptsReady]**: Optional callback invoked when prompts are ready for generation. + /// Receives the list of messages that will be sent to the AI service. + /// + /// - **[onStateChange]**: Optional callback invoked when the generation state changes. + /// Receives the current [GenerationState] for tracking progress. + /// + /// ## Returns + /// A fully initialized [KaiChatController] ready for use. + /// + /// ## Throws + /// - [AssertionError] if both [contextEngine] and [systemPrompt] are provided. + /// + /// ## Example + /// ```dart + /// final controller = await KaiChatController.create( + /// sessionId: 'chat-${DateTime.now().millisecondsSinceEpoch}', + /// generationService: GeminiGenerationService(apiKey: 'your-key'), + /// repository: FirebaseMessageRepository(userId: currentUser.id), + /// systemPrompt: 'You are a friendly cooking assistant.', + /// ); + /// ``` + static Future create({ + String sessionId = 'default', + required GenerationServiceBase generationService, + CoreMessageRepository? repository, + ContextEngine? contextEngine, + String? systemPrompt, + QueryEngineBase? queryEngine, + PostResponseEngineBase? postEngine, + void Function(IList messages)? onPromptsReady, + void Function(GenerationState state)? onStateChange, + }) async { + assert( + contextEngine == null || systemPrompt == null, + 'Cannot provide both contextEngine and systemPrompt. Use contextEngine for custom prompt building, or systemPrompt for simple cases.', + ); + + final session = ConversationSession.withCurrentTime(id: sessionId); + + final conversation = await ConversationManager.create( + repository: repository ?? InMemoryMessageRepository(), + session: session, + messageAdapter: CoreMessageAdapter(), + ); + + return KaiChatController._( + contextEngine: + contextEngine ?? SimpleContextEngine(systemPrompt: systemPrompt), + conversationManager: conversation, + queryEngine: queryEngine, + postResponseEngine: postEngine, + generationService: generationService, + onPromptsReady: onPromptsReady, + onStateChange: onStateChange, + ); + } + + @override + void onPromptsReady(IList messages) { + super.onPromptsReady(messages); + _onPromptsReady?.call(messages); + } + + @override + void onStateChange(GenerationState state) { + super.onStateChange(state); + _onStateChange?.call(state); + } +} diff --git a/packages/kai_engine/lib/src/message_repository_base.dart b/packages/kai_engine/lib/src/message_repository_base.dart index baf70f5..98a0e42 100644 --- a/packages/kai_engine/lib/src/message_repository_base.dart +++ b/packages/kai_engine/lib/src/message_repository_base.dart @@ -24,9 +24,11 @@ abstract interface class CoreMessageRepositoryBase extends MessageRepositoryBase {} /// Prebuilt repository for memory persistence. +/// Messages are automatically sorted by timestamp on initialization. final class InMemoryMessageRepository implements CoreMessageRepositoryBase { InMemoryMessageRepository({Iterable? initialMessages}) - : _messages = initialMessages?.toList() ?? []; + : _messages = (initialMessages?.toList() ?? []) + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); final List _messages; @@ -62,3 +64,93 @@ final class InMemoryMessageRepository implements CoreMessageRepositoryBase { return Future.value(messages); } } + +/// Prebuilt repository for callback-based persistence. +/// +/// Simplified API with only 2 required callbacks: +/// - [onInitial]: Load initial messages from persistence +/// - [onPut]: Persist insert or update operations (upsert) +/// - [onRemove]: Optional callback for persisting deletions +/// +/// Note: Callbacks are for persistence only. The repository maintains +/// its own internal state for immediate read operations. Messages are +/// automatically sorted by timestamp on initial load. +final class CoreMessageRepository implements CoreMessageRepositoryBase { + final Future> Function(ConversationSession session) + onInitial; + final Future Function( + ConversationSession session, + Iterable messages, + ) + onPut; + final Future Function(Iterable messages)? onRemove; + + final List _messages = []; + ConversationSession? _cachedSession; + bool _isInitialized = false; + + CoreMessageRepository({ + required this.onInitial, + required this.onPut, + this.onRemove, + }); + + @override + Future> getMessages(ConversationSession session) async { + if (!_isInitialized || _cachedSession != session) { + _cachedSession = session; + final loaded = await onInitial(session); + _messages.clear(); + _messages.addAll(loaded); + _messages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + _isInitialized = true; + } + return _messages; + } + + @override + Future> saveMessages({ + required ConversationSession session, + required Iterable messages, + }) async { + if (_cachedSession != null && _cachedSession != session) { + _cachedSession = session; + _messages.clear(); + } else { + _cachedSession = session; + } + _isInitialized = true; + _messages.addAll(messages); + await onPut(session, messages); + return messages; + } + + @override + Future> updateMessages( + Iterable messages, + ) async { + if (_cachedSession == null) { + throw StateError( + 'Session not initialized. Call getMessages or saveMessages first.', + ); + } + for (var message in messages) { + final index = _messages.indexWhere( + (m) => m.messageId == message.messageId, + ); + if (index != -1) { + _messages[index] = message; + } + } + await onPut(_cachedSession!, messages); + return messages; + } + + @override + Future removeMessages(Iterable messages) async { + _messages.removeWhere( + (message) => messages.map((e) => e.messageId).contains(message.messageId), + ); + await onRemove?.call(messages); + } +} diff --git a/packages/kai_engine/lib/src/prompt_engine.dart b/packages/kai_engine/lib/src/prompt_engine.dart index 6d32d3b..c3a9616 100644 --- a/packages/kai_engine/lib/src/prompt_engine.dart +++ b/packages/kai_engine/lib/src/prompt_engine.dart @@ -263,12 +263,18 @@ abstract base class ContextEngine /// /// This implementation is suitable for most basic conversational AI scenarios. final class SimpleContextEngine extends ContextEngine { + final String? systemPrompt; + @override List get promptBuilder => [ - PromptTemplate.system("You're kai, a useful friendly personal assistant."), + PromptTemplate.system( + systemPrompt ?? "You're kai, a useful friendly personal assistant.", + ), PromptTemplate.buildSequential(HistoryContext()), PromptTemplate.input(), ]; + + SimpleContextEngine({this.systemPrompt}); } @freezed diff --git a/packages/kai_engine/lib/src/src.dart b/packages/kai_engine/lib/src/src.dart index 72c857f..71a358e 100644 --- a/packages/kai_engine/lib/src/src.dart +++ b/packages/kai_engine/lib/src/src.dart @@ -8,9 +8,9 @@ export 'generation_service_base.dart'; export 'kai_logger.dart'; export 'message_adapter_base.dart'; export 'message_repository_base.dart'; -export 'post_response_engine_base.dart'; -export 'prompt_engine.dart'; export 'pipeline/kai_phase.dart'; export 'pipeline/phase_types.dart'; +export 'post_response_engine_base.dart'; +export 'prompt_engine.dart'; export 'query_engine_base.dart'; export 'tool_schema.dart'; diff --git a/packages/kai_engine/pubspec.yaml b/packages/kai_engine/pubspec.yaml index 19d16eb..1afb339 100644 --- a/packages/kai_engine/pubspec.yaml +++ b/packages/kai_engine/pubspec.yaml @@ -1,6 +1,6 @@ name: kai_engine description: "A modular, extensible AI chat engine built with a pipeline-based architecture." -version: 0.1.1 +version: 0.2.0 homepage: https://github.com/pckimlong/kai_engine repository: https://github.com/pckimlong/kai_engine issue_tracker: https://github.com/pckimlong/kai_engine/issues diff --git a/packages/kai_engine/test/src/chat_controller_base_test.dart b/packages/kai_engine/test/src/chat_controller_base_test.dart index f73c28a..561fa0d 100644 --- a/packages/kai_engine/test/src/chat_controller_base_test.dart +++ b/packages/kai_engine/test/src/chat_controller_base_test.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:test/test.dart'; import 'package:kai_engine/kai_engine.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; GenerationResult createTestGenerationResult({ required IList messages, @@ -813,4 +813,321 @@ void main() { }); }); }); + + group('KaiChatController', () { + late MockGenerationService mockGenerationService; + + setUp(() { + mockGenerationService = MockGenerationService(); + + when( + () => mockGenerationService.stream( + any(), + cancelToken: any(named: 'cancelToken'), + tools: any(named: 'tools'), + toolingConfig: any(named: 'toolingConfig'), + config: any(named: 'config'), + ), + ).thenAnswer((_) { + final streamController = + StreamController>(); + final result = createTestGenerationResult( + messages: IList([ + CoreMessage.ai(messageId: 'ai-1', content: 'Response'), + ]), + ); + + Future.microtask(() { + streamController.add(GenerationState.complete(result)); + streamController.close(); + }); + + return streamController.stream; + }); + }); + + group('create', () { + test( + 'should throw AssertionError when both contextEngine and systemPrompt are provided', + () async { + expect( + () => KaiChatController.create( + generationService: mockGenerationService, + contextEngine: TestContextEngine(), + systemPrompt: 'You are helpful', + ), + throwsA(isA()), + ); + }, + ); + + test( + 'should create controller with default values when no optional parameters provided', + () async { + final controller = await KaiChatController.create( + generationService: mockGenerationService, + ); + + expect(controller, isNotNull); + expect(controller, isA()); + + controller.dispose(); + }, + ); + + test('should create controller with custom sessionId', () async { + final controller = await KaiChatController.create( + sessionId: 'custom-session-123', + generationService: mockGenerationService, + ); + + expect(controller, isNotNull); + + final messages = await controller.getAllMessages(); + expect(messages, isNotNull); + + controller.dispose(); + }); + + test('should create controller with systemPrompt', () async { + final controller = await KaiChatController.create( + generationService: mockGenerationService, + systemPrompt: 'You are a helpful assistant.', + ); + + expect(controller, isNotNull); + + final result = await controller.submit('Hello'); + expect(result, isA>()); + + controller.dispose(); + }); + + test('should create controller with custom contextEngine', () async { + final customContextEngine = TestContextEngine(); + + final controller = await KaiChatController.create( + generationService: mockGenerationService, + contextEngine: customContextEngine, + ); + + expect(controller, isNotNull); + + final result = await controller.submit('Hello'); + expect(result, isA>()); + + controller.dispose(); + }); + + test('should create controller with queryEngine', () async { + final queryEngine = FakeQueryEngine(); + + final controller = await KaiChatController.create( + generationService: mockGenerationService, + queryEngine: queryEngine, + ); + + expect(controller, isNotNull); + expect(queryEngine.executeCalled, isFalse); + + final result = await controller.submit('Hello'); + expect(result, isA>()); + + expect(queryEngine.executeCalled, isTrue); + + controller.dispose(); + }); + + test('should create controller with postEngine', () async { + final postEngine = FakePostResponseEngine(); + + final controller = await KaiChatController.create( + generationService: mockGenerationService, + postEngine: postEngine, + ); + + expect(controller, isNotNull); + expect(postEngine.executeCalled, isFalse); + + final result = await controller.submit('Hello'); + expect(result, isA>()); + + expect(postEngine.executeCalled, isTrue); + + controller.dispose(); + }); + + test( + 'should use InMemoryMessageRepository when repository not provided', + () async { + final controller = await KaiChatController.create( + generationService: mockGenerationService, + ); + + expect(controller, isNotNull); + + final messages = await controller.getAllMessages(); + expect(messages, isA>()); + + controller.dispose(); + }, + ); + + test('should create controller with all parameters', () async { + final queryEngine = FakeQueryEngine(); + final postEngine = FakePostResponseEngine(); + final customContextEngine = TestContextEngine(); + + final controller = await KaiChatController.create( + sessionId: 'full-test-session', + generationService: mockGenerationService, + contextEngine: customContextEngine, + queryEngine: queryEngine, + postEngine: postEngine, + ); + + expect(controller, isNotNull); + + final result = await controller.submit('Hello'); + expect(result, isA>()); + + expect(queryEngine.executeCalled, isTrue); + expect(postEngine.executeCalled, isTrue); + + controller.dispose(); + }); + + test('should handle empty systemPrompt', () async { + final controller = await KaiChatController.create( + generationService: mockGenerationService, + systemPrompt: '', + ); + + expect(controller, isNotNull); + + final result = await controller.submit('Hello'); + expect(result, isA>()); + + controller.dispose(); + }); + + test( + 'should create multiple controllers with different sessionIds', + () async { + final controller1 = await KaiChatController.create( + sessionId: 'session-1', + generationService: mockGenerationService, + ); + + final controller2 = await KaiChatController.create( + sessionId: 'session-2', + generationService: mockGenerationService, + ); + + expect(controller1, isNotNull); + expect(controller2, isNotNull); + expect(controller1, isNot(equals(controller2))); + + controller1.dispose(); + controller2.dispose(); + }, + ); + }); + + group('submit', () { + test( + 'should handle successful submission with default configuration', + () async { + final controller = await KaiChatController.create( + generationService: mockGenerationService, + systemPrompt: 'You are helpful', + ); + + final result = await controller.submit('Hello, how are you?'); + + expect(result, isA>()); + + final messages = await controller.getAllMessages(); + expect(messages.length, greaterThan(0)); + + controller.dispose(); + }, + ); + + test('should emit loading and complete states', () async { + final controller = await KaiChatController.create( + generationService: mockGenerationService, + ); + + final states = >[]; + final subscription = controller.generationStateStream.listen( + states.add, + ); + + await controller.submit('Test message'); + + expect(states.length, greaterThan(1)); + expect(states.any((s) => s is GenerationLoadingState), isTrue); + expect(states.any((s) => s is GenerationCompleteState), isTrue); + + await subscription.cancel(); + controller.dispose(); + }); + + test( + 'should handle error and revert input when revertInputOnError is true', + () async { + when( + () => mockGenerationService.stream( + any(), + cancelToken: any(named: 'cancelToken'), + tools: any(named: 'tools'), + toolingConfig: any(named: 'toolingConfig'), + config: any(named: 'config'), + ), + ).thenAnswer((_) { + final streamController = + StreamController>(); + + Future.microtask(() { + streamController.addError(Exception('Generation failed')); + }); + + return streamController.stream; + }); + + final controller = await KaiChatController.create( + generationService: mockGenerationService, + ); + + final result = await controller.submit( + 'Test message', + revertInputOnError: true, + ); + + expect(result, isA>()); + + controller.dispose(); + }, + ); + }); + + group('messagesStream', () { + test('should emit messages through stream', () async { + final controller = await KaiChatController.create( + generationService: mockGenerationService, + ); + + final messagesList = >[]; + final subscription = controller.messagesStream.listen(messagesList.add); + + await controller.submit('Hello'); + + expect(messagesList.length, greaterThan(0)); + + await subscription.cancel(); + controller.dispose(); + }); + }); + }); } diff --git a/packages/kai_engine/test/src/message_repository_base_test.dart b/packages/kai_engine/test/src/message_repository_base_test.dart new file mode 100644 index 0000000..eb0f46d --- /dev/null +++ b/packages/kai_engine/test/src/message_repository_base_test.dart @@ -0,0 +1,462 @@ +import 'package:kai_engine/src/message_repository_base.dart'; +import 'package:kai_engine/src/models/conversation_session.dart'; +import 'package:kai_engine/src/models/core_message.dart'; +import 'package:test/test.dart'; + +void main() { + group('InMemoryMessageRepository', () { + test('should sort messages by timestamp on initialization', () async { + final now = DateTime.now(); + final msg1 = CoreMessage.user(messageId: '1', content: 'First'); + final msg2 = CoreMessage.ai(messageId: '2', content: 'Second'); + final msg3 = CoreMessage.user(messageId: '3', content: 'Third'); + + // Create messages with different timestamps + final messages = [ + msg3.copyWith(timestamp: now.add(Duration(minutes: 2))), + msg1.copyWith(timestamp: now), + msg2.copyWith(timestamp: now.add(Duration(minutes: 1))), + ]; + + final repo = InMemoryMessageRepository(initialMessages: messages); + final session = ConversationSession.withCurrentTime(id: 'test'); + final result = await repo.getMessages(session); + + // Should be sorted by timestamp + expect(result.first.messageId, equals('1')); + expect(result.elementAt(1).messageId, equals('2')); + expect(result.last.messageId, equals('3')); + }); + }); + + group('CoreMessageRepository', () { + late ConversationSession session; + late CoreMessage testMessage1; + late CoreMessage testMessage2; + + setUp(() { + session = ConversationSession.withCurrentTime(id: 'test-session'); + testMessage1 = CoreMessage.user(messageId: 'msg-1', content: 'Hello'); + testMessage2 = CoreMessage.ai(messageId: 'msg-2', content: 'Hi there'); + }); + + group('constructor', () { + test('should create instance with required callbacks', () { + final repo = CoreMessageRepository( + onInitial: (s) async => [], + onPut: (s, msgs) async {}, + ); + + expect(repo, isA()); + }); + + test('should create instance with optional onRemove callback', () { + final repo = CoreMessageRepository( + onInitial: (s) async => [], + onPut: (s, msgs) async {}, + onRemove: (msgs) async {}, + ); + + expect(repo, isA()); + }); + }); + + group('getMessages', () { + test( + 'should call onInitial callback with session and populate internal state', + () async { + var capturedSession; + final repo = CoreMessageRepository( + onInitial: (s) { + capturedSession = s; + return Future.value([testMessage1]); + }, + onPut: (s, msgs) async {}, + ); + + final result = await repo.getMessages(session); + + expect(capturedSession, equals(session)); + expect(result, equals([testMessage1])); + }, + ); + + test('should sort messages by timestamp on initial load', () async { + final now = DateTime.now(); + final msg1 = CoreMessage.user(messageId: '1', content: 'First'); + final msg2 = CoreMessage.ai(messageId: '2', content: 'Second'); + final msg3 = CoreMessage.user(messageId: '3', content: 'Third'); + + // Return messages out of order + final repo = CoreMessageRepository( + onInitial: (s) async => [ + msg3.copyWith(timestamp: now.add(Duration(minutes: 2))), + msg1.copyWith(timestamp: now), + msg2.copyWith(timestamp: now.add(Duration(minutes: 1))), + ], + onPut: (s, msgs) async {}, + ); + + final result = await repo.getMessages(session); + + // Should be sorted by timestamp + expect(result.first.messageId, equals('1')); + expect(result.elementAt(1).messageId, equals('2')); + expect(result.last.messageId, equals('3')); + }); + + test('should populate internal state from onInitial callback', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [testMessage1, testMessage2], + onPut: (s, msgs) async {}, + ); + + await repo.getMessages(session); + final result = await repo.getMessages(session); + + expect(result, equals([testMessage1, testMessage2])); + }); + + test( + 'should cache state and not reload on subsequent getMessages calls', + () async { + var callCount = 0; + final repo = CoreMessageRepository( + onInitial: (s) async { + callCount++; + return callCount == 1 ? [testMessage1] : [testMessage2]; + }, + onPut: (s, msgs) async {}, + ); + + final result1 = await repo.getMessages(session); + final result2 = await repo.getMessages(session); + + expect(callCount, equals(1)); // Only called once + expect(result1, equals([testMessage1])); + expect(result2, equals([testMessage1])); // Returns cached state + }, + ); + + test('should cache session for later use', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [testMessage1], + onPut: (s, msgs) async {}, + ); + + await repo.getMessages(session); + + final updatedMessage = testMessage1.copyWith(content: 'Updated'); + final result = await repo.updateMessages([updatedMessage]); + + expect(result, contains(updatedMessage)); + }); + }); + + group('saveMessages', () { + test( + 'should call onPut callback with session and messages for persistence', + () async { + var capturedSession; + var capturedMessages; + final repo = CoreMessageRepository( + onInitial: (s) async => [], + onPut: (s, msgs) { + capturedSession = s; + capturedMessages = msgs; + return Future.value(msgs); + }, + ); + + await repo.saveMessages(session: session, messages: [testMessage1]); + + expect(capturedSession, equals(session)); + expect(capturedMessages, equals([testMessage1])); + }, + ); + + test('should add messages to internal state', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [], + onPut: (s, msgs) async {}, + ); + + await repo.saveMessages(session: session, messages: [testMessage1]); + final result = await repo.getMessages(session); + + expect(result, equals([testMessage1])); + }); + + test( + 'should accumulate messages in internal state across multiple saves', + () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [], + onPut: (s, msgs) async {}, + ); + + await repo.saveMessages(session: session, messages: [testMessage1]); + await repo.saveMessages(session: session, messages: [testMessage2]); + final result = await repo.getMessages(session); + + expect(result, equals([testMessage1, testMessage2])); + }, + ); + + test('should cache session for later use', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [], + onPut: (s, msgs) async {}, + ); + + await repo.saveMessages(session: session, messages: [testMessage1]); + + final updatedMessage = testMessage1.copyWith(content: 'Updated'); + final result = await repo.updateMessages([updatedMessage]); + + expect(result, contains(updatedMessage)); + }); + }); + + group('updateMessages', () { + test( + 'should call onPut callback with cached session and messages for persistence', + () async { + var capturedSession; + var capturedMessages; + final repo = CoreMessageRepository( + onInitial: (s) async => [testMessage1], + onPut: (s, msgs) { + capturedSession = s; + capturedMessages = msgs; + return Future.value(msgs); + }, + ); + + await repo.getMessages(session); + await repo.updateMessages([testMessage1]); + + expect(capturedSession, equals(session)); + expect(capturedMessages, equals([testMessage1])); + }, + ); + + test('should update messages in internal state', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [testMessage1], + onPut: (s, msgs) async {}, + ); + + await repo.getMessages(session); + final updated = testMessage1.copyWith(content: 'Updated'); + await repo.updateMessages([updated]); + final result = await repo.getMessages(session); + + expect(result.first.content, equals('Updated')); + }); + + test('should throw StateError when session not initialized', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [], + onPut: (s, msgs) async {}, + ); + + expect( + () => repo.updateMessages([testMessage1]), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Session not initialized'), + ), + ), + ); + }); + + test( + 'should use session from saveMessages if getMessages not called', + () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [], + onPut: (s, msgs) async {}, + ); + + await repo.saveMessages(session: session, messages: [testMessage1]); + final updated = testMessage1.copyWith(content: 'Updated'); + await repo.updateMessages([updated]); + final result = await repo.getMessages(session); + + expect(result.first.content, equals('Updated')); + }, + ); + + test('should only update existing messages, not add new ones', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [testMessage1], + onPut: (s, msgs) async {}, + ); + + await repo.getMessages(session); + await repo.updateMessages([testMessage2]); + final result = await repo.getMessages(session); + + expect(result, equals([testMessage1])); // testMessage2 not added + }); + }); + + group('removeMessages', () { + test( + 'should call onRemove callback when provided for persistence', + () async { + var capturedMessages; + final repo = CoreMessageRepository( + onInitial: (s) async => [testMessage1, testMessage2], + onPut: (s, msgs) async {}, + onRemove: (msgs) { + capturedMessages = msgs; + return Future.value(); + }, + ); + + await repo.getMessages(session); + await repo.removeMessages([testMessage1]); + + expect(capturedMessages, equals([testMessage1])); + }, + ); + + test('should remove messages from internal state', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [testMessage1, testMessage2], + onPut: (s, msgs) async {}, + ); + + await repo.getMessages(session); + await repo.removeMessages([testMessage1]); + final result = await repo.getMessages(session); + + expect(result, equals([testMessage2])); + }); + + test('should complete successfully when onRemove is null', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [testMessage1], + onPut: (s, msgs) async {}, + ); + + await repo.getMessages(session); + await expectLater(repo.removeMessages([testMessage1]), completes); + }); + + test('should handle multiple messages in removeMessages', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [testMessage1, testMessage2], + onPut: (s, msgs) async {}, + ); + + await repo.getMessages(session); + await repo.removeMessages([testMessage1, testMessage2]); + final result = await repo.getMessages(session); + + expect(result, isEmpty); + }); + }); + + group('integration scenarios', () { + test( + 'should handle complete message lifecycle with persistence callbacks', + () async { + final persistedMessages = []; + + final repo = CoreMessageRepository( + onInitial: (s) async => persistedMessages.toList(), + onPut: (s, msgs) async { + // Simulate upsert in persistence layer + for (var msg in msgs) { + final index = persistedMessages.indexWhere( + (m) => m.messageId == msg.messageId, + ); + if (index != -1) { + persistedMessages[index] = msg; + } else { + persistedMessages.add(msg); + } + } + }, + onRemove: (msgs) async { + persistedMessages.removeWhere((m) => msgs.contains(m)); + }, + ); + + // Initial load + var current = await repo.getMessages(session); + expect(current, isEmpty); + + // Save messages + await repo.saveMessages( + session: session, + messages: [testMessage1, testMessage2], + ); + current = await repo.getMessages(session); + expect(current.length, equals(2)); + + // Update message + final updated = testMessage1.copyWith(content: 'Updated content'); + await repo.updateMessages([updated]); + current = await repo.getMessages(session); + expect(current.any((m) => m.content == 'Updated content'), isTrue); + expect(current.length, equals(2)); // Still 2 messages, one updated + + // Remove message + await repo.removeMessages([testMessage2]); + current = await repo.getMessages(session); + expect(current.length, equals(1)); + expect(current.first.messageId, equals(testMessage1.messageId)); + expect(current.first.content, equals('Updated content')); + }, + ); + + test('should handle append-only pattern without onRemove', () async { + final repo = CoreMessageRepository( + onInitial: (s) async => [], + onPut: (s, msgs) async {}, + ); + + await repo.saveMessages(session: session, messages: [testMessage1]); + await repo.saveMessages(session: session, messages: [testMessage2]); + + final current = await repo.getMessages(session); + expect(current.length, equals(2)); + + // Remove removes from internal state even without onRemove callback + await repo.removeMessages([testMessage1]); + final afterRemove = await repo.getMessages(session); + expect(afterRemove.length, equals(1)); // 1 removed, 1 remains + expect(afterRemove.first.messageId, equals(testMessage2.messageId)); + }); + + test( + 'should maintain state independently of persistence layer', + () async { + var persistenceCallCount = 0; + + final repo = CoreMessageRepository( + onInitial: (s) async { + persistenceCallCount++; + return []; + }, + onPut: (s, msgs) async {}, + ); + + // First load calls persistence + await repo.getMessages(session); + expect(persistenceCallCount, equals(1)); + + // Subsequent reads use internal state, don't call persistence + await repo.getMessages(session); + expect(persistenceCallCount, equals(1)); // Still 1, not 2 + }, + ); + }); + }); +} diff --git a/packages/kai_engine_chat_ui/lib/src/kai_chat_theme.dart b/packages/kai_engine_chat_ui/lib/src/kai_chat_theme.dart index 63e0235..39d92ac 100644 --- a/packages/kai_engine_chat_ui/lib/src/kai_chat_theme.dart +++ b/packages/kai_engine_chat_ui/lib/src/kai_chat_theme.dart @@ -17,14 +17,11 @@ class KaiChatTheme extends ThemeExtension { final Color? aiTextColor; const KaiChatTheme({ - this.maxBubbleWidthMobile = 320, - this.maxBubbleWidthDesktop = 480, + this.maxBubbleWidthMobile = 280, + this.maxBubbleWidthDesktop = 400, this.bubbleRadius = 16, - this.bubblePadding = const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - this.listPadding = const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + this.bubblePadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + this.listPadding = const EdgeInsets.symmetric(horizontal: 16, vertical: 4), this.itemSpacing = 12, this.userBubbleColor, this.aiBubbleColor, @@ -47,8 +44,7 @@ class KaiChatTheme extends ThemeExtension { }) { return KaiChatTheme( maxBubbleWidthMobile: maxBubbleWidthMobile ?? this.maxBubbleWidthMobile, - maxBubbleWidthDesktop: - maxBubbleWidthDesktop ?? this.maxBubbleWidthDesktop, + maxBubbleWidthDesktop: maxBubbleWidthDesktop ?? this.maxBubbleWidthDesktop, bubbleRadius: bubbleRadius ?? this.bubbleRadius, bubblePadding: bubblePadding ?? this.bubblePadding, listPadding: listPadding ?? this.listPadding, @@ -64,16 +60,8 @@ class KaiChatTheme extends ThemeExtension { KaiChatTheme lerp(ThemeExtension? other, double t) { if (other is! KaiChatTheme) return this; return KaiChatTheme( - maxBubbleWidthMobile: lerpDouble( - maxBubbleWidthMobile, - other.maxBubbleWidthMobile, - t, - )!, - maxBubbleWidthDesktop: lerpDouble( - maxBubbleWidthDesktop, - other.maxBubbleWidthDesktop, - t, - )!, + maxBubbleWidthMobile: lerpDouble(maxBubbleWidthMobile, other.maxBubbleWidthMobile, t)!, + maxBubbleWidthDesktop: lerpDouble(maxBubbleWidthDesktop, other.maxBubbleWidthDesktop, t)!, bubbleRadius: lerpDouble(bubbleRadius, other.bubbleRadius, t)!, bubblePadding: EdgeInsets.lerp(bubblePadding, other.bubblePadding, t)!, listPadding: EdgeInsets.lerp(listPadding, other.listPadding, t)!, @@ -87,6 +75,5 @@ class KaiChatTheme extends ThemeExtension { } extension KaiChatThemeX on BuildContext { - KaiChatTheme get kaiChatTheme => - Theme.of(this).extension() ?? const KaiChatTheme(); + KaiChatTheme get kaiChatTheme => Theme.of(this).extension() ?? const KaiChatTheme(); } diff --git a/packages/kai_engine_chat_ui/lib/src/kai_chat_view.dart b/packages/kai_engine_chat_ui/lib/src/kai_chat_view.dart index e9cb649..e4c8d59 100644 --- a/packages/kai_engine_chat_ui/lib/src/kai_chat_view.dart +++ b/packages/kai_engine_chat_ui/lib/src/kai_chat_view.dart @@ -1,3 +1,4 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:kai_engine/kai_engine.dart'; @@ -7,43 +8,69 @@ import 'widgets/kai_message_list.dart'; /// A batteries-included chat view: message list + composer. /// -/// You control state by providing [messages], -/// [generationState], and callbacks. +/// Provide a [ChatControllerBase] to manage messages and generation state. +/// The view listens to the controller's streams and rebuilds efficiently. class KaiChatView extends StatefulWidget { const KaiChatView({ super.key, - required this.messages, - required this.onSend, - this.generationState, - this.onCancel, + required this.controller, + this.onMessageSent, + this.onError, this.messageFilter, this.messageBuilder, + this.messageItemBuilder, this.transientBuilder, + this.transientItemBuilder, + this.functionCallBuilder, + this.emptyStateBuilder, this.onMessageTap, this.reverse = true, - this.controller, - this.composerPadding = const EdgeInsets.fromLTRB(16, 0, 16, 12), + this.scrollController, + this.composerPadding = const EdgeInsets.fromLTRB(16, 12, 16, 12), this.listPadding, this.composerBuilder, + this.showTimeLabel = false, + this.avatarBuilder, }); - final List messages; - final GenerationState? generationState; - final Future Function(String text) onSend; - final VoidCallback? onCancel; + final ChatControllerBase controller; + /// Called immediately when user sends a message (before awaiting result). + /// Useful for scrolling to bottom, clearing focus, etc. + final VoidCallback? onMessageSent; + + /// Called when an error occurs during message submission. + /// Provides [BuildContext] for showing dialogs and [KaiException] for error details. + final void Function(BuildContext context, KaiException error)? onError; + + /// Additional filter applied on top of the default filter. + /// Default filter already excludes non-displayable and background context messages. + /// Use this to add custom filtering rules without rewriting the defaults. + /// + /// Example - Filter out function call messages: + /// ```dart + /// KaiChatView( + /// messageFilter: (message) => !message.content.contains('"functionCall":'), + /// // ... + /// ) + /// ``` final KaiMessageFilter? messageFilter; final KaiMessageBuilder? messageBuilder; + final KaiMessageItemBuilder? messageItemBuilder; final KaiTransientBuilder? transientBuilder; + final KaiTransientItemBuilder? transientItemBuilder; + final KaiFunctionCallBuilder? functionCallBuilder; + final KaiEmptyStateBuilder? emptyStateBuilder; final void Function(CoreMessage message)? onMessageTap; + final KaiAvatarBuilder? avatarBuilder; final bool reverse; - final ScrollController? controller; + final ScrollController? scrollController; final EdgeInsetsGeometry composerPadding; final EdgeInsetsGeometry? listPadding; - final Widget Function(BuildContext context, KaiComposer composer)? - composerBuilder; + final Widget Function(BuildContext context, KaiComposer composer)? composerBuilder; + final bool showTimeLabel; @override State createState() => _KaiChatViewState(); @@ -56,8 +83,8 @@ class _KaiChatViewState extends State { @override void initState() { super.initState(); - if (widget.controller != null) { - _scrollController = widget.controller!; + if (widget.scrollController != null) { + _scrollController = widget.scrollController!; } else { _scrollController = ScrollController(); _ownsController = true; @@ -70,11 +97,9 @@ class _KaiChatViewState extends State { super.dispose(); } - void scrollToBottom() { + void _scrollToBottom() { if (!_scrollController.hasClients) return; - final target = widget.reverse - ? 0.0 - : _scrollController.position.maxScrollExtent; + final target = widget.reverse ? 0.0 : _scrollController.position.maxScrollExtent; _scrollController.animateTo( target, duration: const Duration(milliseconds: 220), @@ -82,41 +107,80 @@ class _KaiChatViewState extends State { ); } + Future _handleSend(String text) async { + widget.onMessageSent?.call(); + _scrollToBottom(); + + final result = await widget.controller.submit(text); + if (result.isError && widget.onError != null && mounted) { + final error = (result as GenerationErrorState).exception; + widget.onError!(context, error); + } + } + + void _handleCancel() { + widget.controller.cancel(); + } + @override Widget build(BuildContext context) { final theme = context.kaiChatTheme; - final composer = KaiComposer( - onSend: (text) async { - await widget.onSend(text); - scrollToBottom(); - }, - onCancel: widget.onCancel, - isGenerating: widget.generationState?.isGenerating ?? false, - ); - return Column( - children: [ - Expanded( - child: KaiMessageList( - controller: _scrollController, - messages: widget.messages, - generationState: widget.generationState, - reverse: widget.reverse, - messageFilter: widget.messageFilter ?? defaultKaiMessageFilter, - messageBuilder: widget.messageBuilder, - transientBuilder: widget.transientBuilder, - onMessageTap: widget.onMessageTap, - padding: widget.listPadding ?? theme.listPadding, - ), - ), - SafeArea( - top: false, - child: Padding( - padding: widget.composerPadding, - child: widget.composerBuilder?.call(context, composer) ?? composer, - ), - ), - ], + return StreamBuilder>( + stream: widget.controller.generationStateStream, + initialData: GenerationState.initial(), + builder: (context, stateSnapshot) { + final generationState = stateSnapshot.data ?? GenerationState.initial(); + final isGenerating = generationState.isGenerating; + + final composer = KaiComposer( + onSend: _handleSend, + onCancel: _handleCancel, + isGenerating: isGenerating, + ); + + return Column( + children: [ + Expanded( + child: StreamBuilder>( + stream: widget.controller.messagesStream, + initialData: const IListConst([]), + builder: (context, messagesSnapshot) { + final messages = messagesSnapshot.data ?? const IListConst([]); + final combinedFilter = widget.messageFilter != null + ? (CoreMessage m) => defaultKaiMessageFilter(m) && widget.messageFilter!(m) + : defaultKaiMessageFilter; + return KaiMessageList( + controller: _scrollController, + messages: messages, + generationState: generationState, + reverse: widget.reverse, + messageFilter: combinedFilter, + messageBuilder: widget.messageBuilder, + messageItemBuilder: widget.messageItemBuilder, + transientBuilder: widget.transientBuilder, + transientItemBuilder: widget.transientItemBuilder, + functionCallBuilder: widget.functionCallBuilder, + emptyStateBuilder: widget.emptyStateBuilder, + onEmptyStateSubmit: _handleSend, + onMessageTap: widget.onMessageTap, + padding: widget.listPadding ?? theme.listPadding, + showTimeLabel: widget.showTimeLabel, + avatarBuilder: widget.avatarBuilder, + ); + }, + ), + ), + SafeArea( + top: false, + child: Padding( + padding: widget.composerPadding, + child: widget.composerBuilder?.call(context, composer) ?? composer, + ), + ), + ], + ); + }, ); } } diff --git a/packages/kai_engine_chat_ui/lib/src/widgets/kai_composer.dart b/packages/kai_engine_chat_ui/lib/src/widgets/kai_composer.dart index 3bfdb04..d84031e 100644 --- a/packages/kai_engine_chat_ui/lib/src/widgets/kai_composer.dart +++ b/packages/kai_engine_chat_ui/lib/src/widgets/kai_composer.dart @@ -1,5 +1,118 @@ import 'package:flutter/material.dart'; +/// State data provided to [KaiComposerBuilder]. +class KaiComposerState { + const KaiComposerState({ + required this.isGenerating, + required this.isSending, + required this.canSend, + required this.text, + }); + + /// Whether the AI is currently generating a response. + final bool isGenerating; + + /// Whether a send operation is in progress. + final bool isSending; + + /// Whether the user can send a message (has text and not busy). + final bool canSend; + + /// Current text in the input field. + final String text; +} + +/// Callbacks provided to [KaiComposerBuilder]. +class KaiComposerCallbacks { + const KaiComposerCallbacks({required this.onSend, this.onCancel, required this.onTextChanged}); + + /// Send the current text in the input field. + final Future Function() onSend; + + /// Cancel the current generation (optional). + final VoidCallback? onCancel; + + /// Called when the text field content changes. + final void Function(String text) onTextChanged; +} + +/// Builder that creates a custom composer widget. +/// +/// This builder receives the [state] and [callbacks] to build a custom composer. +/// You can create your own text field, send button, and any additional UI elements. +/// +/// Example - Create a custom composer with attachment button: +/// ```dart +/// KaiComposer( +/// onSend: (text) => controller.submit(text), +/// onCancel: () => controller.cancel(), +/// isGenerating: isGenerating, +/// builder: (context, state, callbacks) { +/// return Row( +/// children: [ +/// IconButton( +/// icon: Icon(Icons.attach_file), +/// onPressed: () => _handleAttachment(), +/// ), +/// Expanded( +/// child: TextField( +/// decoration: InputDecoration( +/// hintText: 'Type a message...', +/// border: OutlineInputBorder(), +/// ), +/// onChanged: callbacks.onTextChanged, +/// onSubmitted: (_) => callbacks.onSend(), +/// ), +/// ), +/// if (state.isGenerating) +/// IconButton( +/// icon: Icon(Icons.stop), +/// onPressed: callbacks.onCancel, +/// ) +/// else +/// IconButton( +/// icon: Icon(Icons.send), +/// onPressed: state.canSend ? callbacks.onSend : null, +/// ), +/// ], +/// ); +/// }, +/// ) +/// ``` +/// +/// Example - Use with KaiChatView: +/// ```dart +/// KaiChatView( +/// controller: myController, +/// composerBuilder: (context, composer) { +/// return KaiComposer( +/// onSend: composer.onSend, +/// onCancel: composer.onCancel, +/// isGenerating: composer.isGenerating, +/// builder: (context, state, callbacks) { +/// // Custom composer UI +/// return YourCustomComposer(state: state, callbacks: callbacks); +/// }, +/// ); +/// }, +/// ) +/// ``` +/// +/// Example - Customize inline button: +/// ```dart +/// KaiComposer( +/// onSend: (text) => controller.submit(text), +/// isGenerating: isGenerating, +/// inlineButton: true, +/// sendIcon: Icons.send, +/// stopIcon: Icons.close, +/// buttonSize: 40, +/// buttonColor: Colors.blue, +/// ) +/// ``` +typedef KaiComposerBuilder = + Widget Function(BuildContext context, KaiComposerState state, KaiComposerCallbacks callbacks); + class KaiComposer extends StatefulWidget { const KaiComposer({ super.key, @@ -9,8 +122,14 @@ class KaiComposer extends StatefulWidget { this.hintText = 'Type a message…', this.controller, this.autofocus = false, - this.maxLines = 6, + this.maxLines = 8, this.onError, + this.builder, + this.inlineButton = true, + this.sendIcon, + this.stopIcon, + this.buttonSize = 36, + this.buttonColor, }); final Future Function(String text) onSend; @@ -21,6 +140,12 @@ class KaiComposer extends StatefulWidget { final bool autofocus; final int maxLines; final void Function(Object error, StackTrace stackTrace)? onError; + final KaiComposerBuilder? builder; + final bool inlineButton; + final IconData? sendIcon; + final IconData? stopIcon; + final double buttonSize; + final Color? buttonColor; @override State createState() => _KaiComposerState(); @@ -53,10 +178,12 @@ class _KaiComposerState extends State { final text = _controller.text.trim(); if (text.isEmpty) return; + // Clear text immediately for responsive UX (like Resonate pattern) + _controller.clear(); setState(() => _sending = true); + try { await widget.onSend(text); - if (mounted) _controller.clear(); } catch (e, s) { widget.onError?.call(e, s); } finally { @@ -66,10 +193,102 @@ class _KaiComposerState extends State { @override Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; final isBusy = widget.isGenerating || _sending; final canSend = _controller.text.trim().isNotEmpty && !isBusy; + final state = KaiComposerState( + isGenerating: widget.isGenerating, + isSending: _sending, + canSend: canSend, + text: _controller.text, + ); + + final callbacks = KaiComposerCallbacks( + onSend: _handleSend, + onCancel: widget.onCancel, + onTextChanged: (text) => setState(() {}), + ); + + if (widget.builder != null) { + return widget.builder!(context, state, callbacks); + } + + final colors = Theme.of(context).colorScheme; + + if (widget.inlineButton) { + final effectiveButtonColor = widget.buttonColor ?? colors.primary; + final effectiveSendIcon = widget.sendIcon ?? Icons.arrow_upward; + final effectiveStopIcon = widget.stopIcon ?? Icons.stop; + + return Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + autofocus: widget.autofocus, + maxLines: widget.maxLines, + minLines: 1, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: widget.hintText, + filled: true, + fillColor: colors.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 8), + child: Semantics( + button: true, + label: widget.isGenerating ? 'Stop generating' : 'Send message', + child: Tooltip( + message: widget.isGenerating ? 'Stop' : 'Send message', + child: Material( + color: Colors.transparent, + child: Container( + width: widget.buttonSize, + height: widget.buttonSize, + decoration: BoxDecoration( + color: effectiveButtonColor, + shape: BoxShape.circle, + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.3), + width: 1, + ), + ), + child: InkWell( + onTap: widget.isGenerating + ? widget.onCancel + : canSend + ? _handleSend + : null, + borderRadius: BorderRadius.circular(widget.buttonSize / 2), + child: Center( + child: Icon( + widget.isGenerating ? effectiveStopIcon : effectiveSendIcon, + color: effectiveButtonColor == colors.primary + ? colors.onPrimary + : colors.onPrimaryContainer, + size: widget.buttonSize * 0.56, + ), + ), + ), + ), + ), + ), + ), + ), + ), + onSubmitted: (_) => _handleSend(), + onChanged: (_) => setState(() {}), + ), + ), + ], + ); + } + return Row( children: [ Expanded( @@ -87,10 +306,7 @@ class _KaiComposerState extends State { borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none, ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), ), onSubmitted: (_) => _handleSend(), onChanged: (_) => setState(() {}), diff --git a/packages/kai_engine_chat_ui/lib/src/widgets/kai_message_bubble.dart b/packages/kai_engine_chat_ui/lib/src/widgets/kai_message_bubble.dart index 3423f02..8d5006a 100644 --- a/packages/kai_engine_chat_ui/lib/src/widgets/kai_message_bubble.dart +++ b/packages/kai_engine_chat_ui/lib/src/widgets/kai_message_bubble.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:kai_engine/kai_engine.dart'; import '../kai_chat_theme.dart'; +import 'kai_message_list.dart'; -typedef KaiMessageContentBuilder = - Widget Function(BuildContext context, CoreMessage message); +typedef KaiMessageContentBuilder = Widget Function(BuildContext context, CoreMessage message); /// Default bubble used by [KaiMessageList] / [KaiChatView]. class KaiMessageBubble extends StatelessWidget { @@ -15,6 +16,8 @@ class KaiMessageBubble extends StatelessWidget { this.onTap, this.isUserMessage, this.semanticLabel, + this.showTimeLabel = false, + this.avatarBuilder, }); final CoreMessage message; @@ -22,6 +25,8 @@ class KaiMessageBubble extends StatelessWidget { final VoidCallback? onTap; final bool? isUserMessage; final String? semanticLabel; + final bool showTimeLabel; + final KaiAvatarBuilder? avatarBuilder; @override Widget build(BuildContext context) { @@ -36,20 +41,38 @@ class KaiMessageBubble extends StatelessWidget { ? (theme.userTextColor ?? colors.onPrimary) : (theme.aiTextColor ?? colors.onSurface); + final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(color: textColor); + final content = contentBuilder?.call(context, message) ?? - Text( - message.content, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: textColor, height: 1.35), - ); + (isUser + ? Text(message.content, style: textStyle) + : GptMarkdown(message.content, style: textStyle)); + + final child = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + content, + if (showTimeLabel) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _formatTime(message.timestamp), + style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.6)), + ), + ), + ], + ); return Row( - mainAxisAlignment: isUser - ? MainAxisAlignment.end - : MainAxisAlignment.start, + mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, children: [ + if (!isUser && avatarBuilder != null) ...[ + avatarBuilder!(context, message), + const SizedBox(width: 8), + ], Flexible( child: Semantics( container: true, @@ -69,25 +92,22 @@ class KaiMessageBubble extends StatelessWidget { padding: theme.bubblePadding, decoration: BoxDecoration( color: bubbleColor, - borderRadius: BorderRadius.circular(theme.bubbleRadius) - .copyWith( - bottomLeft: isUser - ? Radius.circular(theme.bubbleRadius) - : const Radius.circular(6), - bottomRight: isUser - ? const Radius.circular(6) - : Radius.circular(theme.bubbleRadius), - ), + borderRadius: BorderRadius.circular(theme.bubbleRadius).copyWith( + bottomLeft: isUser + ? Radius.circular(theme.bubbleRadius) + : const Radius.circular(4), + bottomRight: isUser + ? const Radius.circular(4) + : Radius.circular(theme.bubbleRadius), + ), border: Border.all( - color: colors.outlineVariant.withValues( - alpha: isUser ? 0.15 : 0.35, - ), + color: colors.outlineVariant.withValues(alpha: isUser ? 0.15 : 0.35), width: 1, ), ), child: DefaultTextStyle.merge( style: TextStyle(color: textColor), - child: content, + child: child, ), ), ), @@ -98,4 +118,8 @@ class KaiMessageBubble extends StatelessWidget { ], ); } + + String _formatTime(DateTime timestamp) { + return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}'; + } } diff --git a/packages/kai_engine_chat_ui/lib/src/widgets/kai_message_list.dart b/packages/kai_engine_chat_ui/lib/src/widgets/kai_message_list.dart index fb09c8b..c14496f 100644 --- a/packages/kai_engine_chat_ui/lib/src/widgets/kai_message_list.dart +++ b/packages/kai_engine_chat_ui/lib/src/widgets/kai_message_list.dart @@ -1,3 +1,4 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:kai_engine/kai_engine.dart'; @@ -6,17 +7,203 @@ import 'kai_message_bubble.dart'; import 'kai_streaming_text.dart'; import 'kai_typing_indicator.dart'; -typedef KaiMessageBuilder = - Widget Function(BuildContext context, CoreMessage message); +/// Builder that constructs a custom avatar widget. +/// +/// This builder receives the [context] and [message] as parameters. +/// You can use this to create a custom avatar for messages. +/// +/// Example - Create a simple avatar: +/// ```dart +/// KaiChatView( +/// avatarBuilder: (context, message) { +/// return CircleAvatar( +/// radius: 16, +/// backgroundColor: Colors.blue, +/// child: Icon(Icons.smart_toy, size: 18), +/// ); +/// }, +/// // ... +/// ) +/// ``` +typedef KaiAvatarBuilder = Widget Function(BuildContext context, CoreMessage message); + +/// Builder that constructs a custom message widget. +/// +/// This builder receives the [context] and [message] as parameters. +/// You can use this to create a completely custom message layout. +/// +/// Example - Create a simple text message: +/// ```dart +/// KaiChatView( +/// messageBuilder: (context, message) { +/// return Text(message.content); +/// }, +/// // ... +/// ) +/// ``` +typedef KaiMessageBuilder = Widget Function(BuildContext context, CoreMessage message); + +/// Builder that provides a default child widget for customization. +/// +/// This builder receives the [defaultChild] which is the pre-built [KaiMessageBubble]. +/// You can wrap it with additional widgets (e.g., avatar, timestamp, status indicators) +/// or use it as a reference to build your own custom layout. +/// +/// Example - Add an avatar next to messages: +/// ```dart +/// KaiChatView( +/// messageItemBuilder: (context, message, defaultChild) { +/// return Row( +/// children: [ +/// CircleAvatar( +/// backgroundImage: NetworkImage(message.type == CoreMessageType.user +/// ? 'https://api.dicebear.com/7.x/avataaars/svg?seed=user' +/// : 'https://api.dicebear.com/7.x/bottts/svg?seed=ai'), +/// ), +/// SizedBox(width: 8), +/// Expanded(child: defaultChild), +/// ], +/// ); +/// }, +/// // ... +/// ) +/// ``` +/// +/// Example - Add a status indicator: +/// ```dart +/// KaiChatView( +/// messageItemBuilder: (context, message, defaultChild) { +/// return Column( +/// crossAxisAlignment: CrossAxisAlignment.start, +/// children: [ +/// defaultChild, +/// if (message.metadata['status'] != null) +/// Text( +/// message.metadata['status'], +/// style: TextStyle(fontSize: 12, color: Colors.grey), +/// ), +/// ], +/// ); +/// }, +/// // ... +/// ) +/// ``` +typedef KaiMessageItemBuilder = + Widget Function(BuildContext context, CoreMessage message, Widget defaultChild); + +/// Builder that constructs a custom transient state widget. +/// +/// This builder receives the [context] and [state] as parameters. +/// You can use this to create a completely custom transient state layout. +/// +/// Example - Create a simple loading indicator: +/// ```dart +/// KaiChatView( +/// transientBuilder: (context, state) { +/// return CircularProgressIndicator(); +/// }, +/// // ... +/// ) +/// ``` typedef KaiTransientBuilder = - Widget Function( - BuildContext context, - GenerationState state, - ); + Widget Function(BuildContext context, GenerationState state); + +/// Builder that provides a default child widget for transient state customization. +/// +/// Similar to [KaiMessageItemBuilder], this allows you to customize the transient +/// state (loading, streaming, function calling) while using the default bubble as a base. +/// +/// Example - Add a loading animation: +/// ```dart +/// KaiChatView( +/// transientItemBuilder: (context, state, defaultChild) { +/// return Column( +/// children: [ +/// defaultChild, +/// LinearProgressIndicator(), +/// ], +/// ); +/// }, +/// // ... +/// ) +/// ``` +typedef KaiTransientItemBuilder = + Widget Function(BuildContext context, GenerationState state, Widget child); + +/// Filter that determines whether a message should be displayed. +/// +/// This filter receives the [message] as a parameter. +/// You can use this to filter out certain types of messages. +/// +/// Example - Filter out background context messages: +/// ```dart +/// KaiChatView( +/// messageFilter: (message) => !message.isBackgroundContext, +/// // ... +/// ) +/// ``` typedef KaiMessageFilter = bool Function(CoreMessage message); -bool defaultKaiMessageFilter(CoreMessage m) => - m.isDisplayable && !m.isBackgroundContext; +/// Builder that constructs a custom function call widget. +/// +/// This builder receives the [message] as a parameter. +/// If provided, function messages will be displayed in the chat. +/// If null, function messages are filtered out by default. +/// +/// Example - Display function call with name and result: +/// ```dart +/// KaiChatView( +/// functionCallBuilder: (context, message) { +/// return Card( +/// child: ListTile( +/// leading: Icon(Icons.code), +/// title: Text('Function: ${message.extensions["name"]}'), +/// subtitle: Text(message.content), +/// ), +/// ); +/// }, +/// // ... +/// ) +/// ``` +typedef KaiFunctionCallBuilder = Widget Function(BuildContext context, CoreMessage message); + +/// Builder that constructs a custom empty state widget. +/// +/// This builder receives the [context] and [onSubmit] callback as parameters. +/// The [onSubmit] callback allows you to trigger message submission from the empty state. +/// If provided, this widget is shown when there are no messages in the chat. +/// If null, an empty list is shown. +/// +/// Example - Show a welcome message with a quick action button: +/// ```dart +/// KaiChatView( +/// emptyStateBuilder: (context, onSubmit) { +/// return Center( +/// child: Column( +/// mainAxisAlignment: MainAxisAlignment.center, +/// children: [ +/// Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey), +/// SizedBox(height: 16), +/// Text( +/// 'Start a conversation!', +/// style: TextStyle(fontSize: 18, color: Colors.grey), +/// ), +/// SizedBox(height: 16), +/// ElevatedButton( +/// onPressed: () => onSubmit('Hello, I need help!'), +/// child: Text('Say Hello'), +/// ), +/// ], +/// ), +/// ); +/// }, +/// // ... +/// ) +/// ``` +typedef KaiEmptyStateBuilder = + Widget Function(BuildContext context, void Function(String text) onSubmit); + +bool defaultKaiMessageFilter(CoreMessage m) => m.isDisplayable && !m.isBackgroundContext; class KaiMessageList extends StatelessWidget { const KaiMessageList({ @@ -27,31 +214,54 @@ class KaiMessageList extends StatelessWidget { this.reverse = true, this.messageFilter = defaultKaiMessageFilter, this.messageBuilder, + this.messageItemBuilder, this.transientBuilder, + this.transientItemBuilder, + this.functionCallBuilder, + this.emptyStateBuilder, + this.onEmptyStateSubmit, this.onMessageTap, this.padding, this.physics, + this.showTimeLabel = false, + this.avatarBuilder, }); - final List messages; + final IList messages; final GenerationState? generationState; final ScrollController? controller; final bool reverse; final KaiMessageFilter messageFilter; final KaiMessageBuilder? messageBuilder; + final KaiMessageItemBuilder? messageItemBuilder; final KaiTransientBuilder? transientBuilder; + final KaiTransientItemBuilder? transientItemBuilder; + final KaiFunctionCallBuilder? functionCallBuilder; + final KaiEmptyStateBuilder? emptyStateBuilder; + final void Function(String text)? onEmptyStateSubmit; final void Function(CoreMessage message)? onMessageTap; final EdgeInsetsGeometry? padding; final ScrollPhysics? physics; + final bool showTimeLabel; + final KaiAvatarBuilder? avatarBuilder; @override Widget build(BuildContext context) { final theme = context.kaiChatTheme; - final filtered = messages.where(messageFilter).toList(growable: false); + final effectiveFilter = functionCallBuilder != null + ? (CoreMessage m) => + (m.isDisplayable && !m.isBackgroundContext) || + (m.type == CoreMessageType.function && !m.isBackgroundContext) + : messageFilter; + final filtered = messages.where(effectiveFilter).toIList(); final transient = generationState != null && generationState!.isGenerating; final totalCount = filtered.length + (transient ? 1 : 0); final transientIndex = reverse ? 0 : filtered.length; + if (totalCount == 0 && emptyStateBuilder != null) { + return emptyStateBuilder!(context, onEmptyStateSubmit ?? (_) {}); + } + return ListView.builder( controller: controller, reverse: reverse, @@ -62,11 +272,11 @@ class KaiMessageList extends StatelessWidget { final isTransientIndex = transient && index == transientIndex; if (isTransientIndex) { final state = generationState!; - final built = - transientBuilder?.call(context, state) ?? - _defaultTransient(context, state); + final defaultChild = + transientBuilder?.call(context, state) ?? _defaultTransient(context, state); + final built = transientItemBuilder?.call(context, state, defaultChild) ?? defaultChild; return Padding( - padding: EdgeInsets.only(bottom: theme.itemSpacing), + padding: EdgeInsets.only(bottom: index == 0 ? 4 : theme.itemSpacing), child: built, ); } @@ -76,43 +286,49 @@ class KaiMessageList extends StatelessWidget { : index; final message = filtered[messageIndex]; + if (message.type == CoreMessageType.function && functionCallBuilder != null) { + return Padding( + padding: EdgeInsets.only(bottom: index == 0 ? 4 : theme.itemSpacing), + child: functionCallBuilder!(context, message), + ); + } + + final defaultChild = KaiMessageBubble( + message: message, + isUserMessage: message.type == CoreMessageType.user, + onTap: onMessageTap == null ? null : () => onMessageTap!(message), + showTimeLabel: showTimeLabel, + avatarBuilder: avatarBuilder, + ); + final bubble = + messageItemBuilder?.call(context, message, defaultChild) ?? messageBuilder?.call(context, message) ?? - KaiMessageBubble( - message: message, - isUserMessage: message.type == CoreMessageType.user, - onTap: onMessageTap == null ? null : () => onMessageTap!(message), - ); + defaultChild; return Padding( - padding: EdgeInsets.only(bottom: theme.itemSpacing), + padding: EdgeInsets.only(bottom: index == 0 ? 4 : theme.itemSpacing), child: bubble, ); }, ); } - Widget _defaultTransient( - BuildContext context, - GenerationState state, - ) { + Widget _defaultTransient(BuildContext context, GenerationState state) { return switch (state) { - GenerationLoadingState() || - GenerationFunctionCallingState() => KaiMessageBubble( + GenerationLoadingState() || GenerationFunctionCallingState() => KaiMessageBubble( message: CoreMessage.ai(content: ''), isUserMessage: false, - contentBuilder: (_, __) => const Padding( - padding: EdgeInsets.symmetric(vertical: 2), - child: KaiTypingIndicator(), - ), + contentBuilder: (_, __) => + const Padding(padding: EdgeInsets.symmetric(vertical: 2), child: KaiTypingIndicator()), + avatarBuilder: avatarBuilder, ), GenerationStreamingTextState(text: final text) => KaiMessageBubble( message: CoreMessage.ai(content: text), isUserMessage: false, - contentBuilder: (context, _) => KaiStreamingText( - text: text, - style: Theme.of(context).textTheme.bodyMedium, - ), + contentBuilder: (context, _) => + KaiStreamingText(text: text, style: Theme.of(context).textTheme.bodyMedium), + avatarBuilder: avatarBuilder, ), _ => const SizedBox.shrink(), }; diff --git a/packages/kai_engine_chat_ui/pubspec.yaml b/packages/kai_engine_chat_ui/pubspec.yaml index 1cddcc6..7e22378 100644 --- a/packages/kai_engine_chat_ui/pubspec.yaml +++ b/packages/kai_engine_chat_ui/pubspec.yaml @@ -1,6 +1,6 @@ name: kai_engine_chat_ui description: Flutter chat UI widgets for kai_engine (message list, composer, streaming UI). -version: 0.1.1 +version: 0.2.0 homepage: https://github.com/pckimlong/kai_engine repository: https://github.com/pckimlong/kai_engine issue_tracker: https://github.com/pckimlong/kai_engine/issues @@ -13,6 +13,8 @@ environment: dependencies: flutter: sdk: flutter + fast_immutable_collections: ^11.0.2 + gpt_markdown: ^1.1.2 kai_engine: ^0.1.1 dev_dependencies: diff --git a/packages/kai_engine_firebase_ai/lib/src/adapters.dart b/packages/kai_engine_firebase_ai/lib/src/adapters.dart index bcc1bff..75e03e4 100644 --- a/packages/kai_engine_firebase_ai/lib/src/adapters.dart +++ b/packages/kai_engine_firebase_ai/lib/src/adapters.dart @@ -19,7 +19,7 @@ class FirebaseAiContentAdapter CoreMessageType.unknown => null, }; - // Check if we have stored original Content + // Check if we have stored original Content with timestamp if (message.extensions.containsKey('_originalContent')) { final originalContentJson = message.extensions['_originalContent']; if (originalContentJson != null) { @@ -86,7 +86,8 @@ class FirebaseAiContentAdapter final combinedText = textParts.map((part) => part.text).join('\n').trim(); // Store the entire Content object as JSON for perfect reconstruction - extensions['_originalContent'] = content.toJson(); + final contentJson = content.toJson(); + extensions['_originalContent'] = contentJson; final messageType = switch (content.role) { 'user' => CoreMessageType.user, @@ -96,6 +97,21 @@ class FirebaseAiContentAdapter _ => CoreMessageType.unknown, }; + // Try to preserve timestamp from stored content or extensions + DateTime? timestamp; + if (contentJson.containsKey('_timestamp')) { + try { + timestamp = DateTime.parse(contentJson['_timestamp'] as String); + } catch (e) { + // If parsing fails, use current time + } + } + + // Store timestamp in extensions for future round-trip conversions + if (timestamp != null) { + extensions['_timestamp'] = timestamp.toIso8601String(); + } + return CoreMessage.create( type: messageType, content: combinedText, diff --git a/packages/kai_engine_firebase_ai/pubspec.yaml b/packages/kai_engine_firebase_ai/pubspec.yaml index fdc522d..a0f919f 100644 --- a/packages/kai_engine_firebase_ai/pubspec.yaml +++ b/packages/kai_engine_firebase_ai/pubspec.yaml @@ -1,6 +1,6 @@ name: kai_engine_firebase_ai description: "A Firebase AI adapter for the Kai Engine." -version: 0.1.1 +version: 0.2.0 homepage: https://github.com/pckimlong/kai_engine repository: https://github.com/pckimlong/kai_engine issue_tracker: https://github.com/pckimlong/kai_engine/issues diff --git a/packages/prompt_block/pubspec.yaml b/packages/prompt_block/pubspec.yaml index 956f683..7e77ad2 100644 --- a/packages/prompt_block/pubspec.yaml +++ b/packages/prompt_block/pubspec.yaml @@ -1,6 +1,6 @@ name: prompt_block description: "A package for creating and managing prompt blocks in AI applications." -version: 0.1.1 +version: 0.2.0 homepage: https://github.com/pckimlong/kai_engine repository: https://github.com/pckimlong/kai_engine issue_tracker: https://github.com/pckimlong/kai_engine/issues