From 0def6c1713e8b235b9f769c14b142da242f2eb0d Mon Sep 17 00:00:00 2001 From: Udhay-Adithya Date: Mon, 1 Sep 2025 15:59:07 +0530 Subject: [PATCH] feat: dahbot chat page --- .../lib/core/routes/dashbot_router.dart | 28 +-- .../lib/features/chat/models/chat_models.dart | 134 +++++++++++ .../repository/chat_remote_repository.dart | 37 +++ .../chat/view/pages/dashbot_chat_page.dart | 193 +++++++++++++++ .../chat/view/widgets/chat_bubble.dart | 98 ++++++++ .../chat/viewmodel/chat_viewmodel.dart | 226 ++++++++++++++++++ .../features/home/view/pages/home_page.dart | 22 +- 7 files changed, 711 insertions(+), 27 deletions(-) create mode 100644 packages/dashbot/lib/features/chat/models/chat_models.dart create mode 100644 packages/dashbot/lib/features/chat/repository/chat_remote_repository.dart create mode 100644 packages/dashbot/lib/features/chat/view/pages/dashbot_chat_page.dart create mode 100644 packages/dashbot/lib/features/chat/view/widgets/chat_bubble.dart create mode 100644 packages/dashbot/lib/features/chat/viewmodel/chat_viewmodel.dart diff --git a/packages/dashbot/lib/core/routes/dashbot_router.dart b/packages/dashbot/lib/core/routes/dashbot_router.dart index ead2599c..43952276 100644 --- a/packages/dashbot/lib/core/routes/dashbot_router.dart +++ b/packages/dashbot/lib/core/routes/dashbot_router.dart @@ -1,31 +1,23 @@ +import 'package:dashbot/features/chat/view/pages/dashbot_chat_page.dart'; + import 'dashbot_routes.dart'; import '../common/pages/dashbot_default_page.dart'; import '../../features/home/view/pages/home_page.dart'; import 'package:flutter/material.dart'; -Route? generateRoute( - RouteSettings settings, -) { +Route? generateRoute(RouteSettings settings) { switch (settings.name) { case (DashbotRoutes.dashbotHome): - return MaterialPageRoute( - builder: (context) => DashbotHomePage(), - ); + return MaterialPageRoute(builder: (context) => DashbotHomePage()); case (DashbotRoutes.dashbotDefault): + return MaterialPageRoute(builder: (context) => DashbotDefaultPage()); + case (DashbotRoutes.dashbotChat): + final args = settings.arguments as Map?; + final initialPrompt = args?['initialPrompt'] as String; return MaterialPageRoute( - builder: (context) => DashbotDefaultPage(), + builder: (context) => ChatScreen(initialPrompt: initialPrompt), ); - // case (DashbotRoutes.dashbotChat): - // final args = settings.arguments as Map?; - // final initialPrompt = args?['initialPrompt'] as String; - // return MaterialPageRoute( - // builder: (context) => ChatScreen( - // initialPrompt: initialPrompt, - // ), - // ); default: - return MaterialPageRoute( - builder: (context) => DashbotDefaultPage(), - ); + return MaterialPageRoute(builder: (context) => DashbotDefaultPage()); } } diff --git a/packages/dashbot/lib/features/chat/models/chat_models.dart b/packages/dashbot/lib/features/chat/models/chat_models.dart new file mode 100644 index 00000000..d7d7d72b --- /dev/null +++ b/packages/dashbot/lib/features/chat/models/chat_models.dart @@ -0,0 +1,134 @@ +import '../view/widgets/chat_bubble.dart'; + +// Create a Message class that extends the existing ChatMessage for compatibility +class Message extends ChatMessage { + const Message({ + required super.id, + required super.content, + required super.role, + required super.timestamp, + required super.messageType, + }); +} + +class ChatState { + final Map> chatSessions; // requestId -> messages + final bool isGenerating; + final String currentStreamingResponse; + final String? currentRequestId; + final ChatFailure? lastError; + + const ChatState({ + this.chatSessions = const {}, + this.isGenerating = false, + this.currentStreamingResponse = '', + this.currentRequestId, + this.lastError, + }); + + ChatState copyWith({ + Map>? chatSessions, + bool? isGenerating, + String? currentStreamingResponse, + String? currentRequestId, + ChatFailure? lastError, + }) { + return ChatState( + chatSessions: chatSessions ?? this.chatSessions, + isGenerating: isGenerating ?? this.isGenerating, + currentStreamingResponse: + currentStreamingResponse ?? this.currentStreamingResponse, + currentRequestId: currentRequestId ?? this.currentRequestId, + lastError: lastError ?? this.lastError, + ); + } +} + +class ChatMessage { + final String id; + final String content; + final MessageRole role; + final DateTime timestamp; + final ChatMessageType? messageType; + + const ChatMessage({ + required this.id, + required this.content, + required this.role, + required this.timestamp, + this.messageType, + }); + + ChatMessage copyWith({ + String? id, + String? content, + MessageRole? role, + DateTime? timestamp, + ChatMessageType? messageType, + }) { + return ChatMessage( + id: id ?? this.id, + content: content ?? this.content, + role: role ?? this.role, + timestamp: timestamp ?? this.timestamp, + messageType: messageType ?? this.messageType, + ); + } +} + +class ChatResponse { + final String content; + final ChatMessageType? messageType; + + const ChatResponse({required this.content, this.messageType}); + + ChatResponse copyWith({String? content, ChatMessageType? messageType}) { + return ChatResponse( + content: content ?? this.content, + messageType: messageType ?? this.messageType, + ); + } +} + +enum ChatMessageType { explainResponse, debugError, generateTest, general } + +// Failure classes using fpdart Either pattern +abstract class ChatFailure implements Exception { + final String message; + final String? code; + const ChatFailure(this.message, {this.code}); + + @override + String toString() => 'ChatFailure: $message'; +} + +class NetworkFailure extends ChatFailure { + const NetworkFailure(super.message, {super.code}); +} + +class AIModelNotConfiguredFailure extends ChatFailure { + const AIModelNotConfiguredFailure() + : super("Please configure an AI model in the AI Request tab"); +} + +class APIKeyMissingFailure extends ChatFailure { + const APIKeyMissingFailure(String provider) + : super("API key missing for $provider"); +} + +class NoRequestSelectedFailure extends ChatFailure { + const NoRequestSelectedFailure() : super("No request selected"); +} + +class InvalidRequestContextFailure extends ChatFailure { + const InvalidRequestContextFailure(super.message); +} + +class RateLimitFailure extends ChatFailure { + const RateLimitFailure() + : super("Rate limit exceeded. Please try again later."); +} + +class StreamingFailure extends ChatFailure { + const StreamingFailure(super.message); +} diff --git a/packages/dashbot/lib/features/chat/repository/chat_remote_repository.dart b/packages/dashbot/lib/features/chat/repository/chat_remote_repository.dart new file mode 100644 index 00000000..66b9327c --- /dev/null +++ b/packages/dashbot/lib/features/chat/repository/chat_remote_repository.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:genai/genai.dart'; + +/// Repository for talking to the GenAI layer. +abstract class ChatRemoteRepository { + /// Stream a chat completion with the provided AI request. + Stream streamChat({required AIRequestModel request}); + + /// Execute a non-streaming chat completion. + Future sendChat({required AIRequestModel request}); +} + +class ChatRemoteRepositoryImpl implements ChatRemoteRepository { + ChatRemoteRepositoryImpl(); + + @override + Stream streamChat({required AIRequestModel request}) async* { + final stream = await streamGenAIRequest(request); + await for (final chunk in stream) { + if (chunk != null && chunk.isNotEmpty) yield chunk; + } + } + + @override + Future sendChat({required AIRequestModel request}) async { + final result = await executeGenAIRequest(request); + if (result == null || result.isEmpty) return null; + return result; + } +} + +/// Provider for the repository +final chatRepositoryProvider = Provider((ref) { + return ChatRemoteRepositoryImpl(); +}); diff --git a/packages/dashbot/lib/features/chat/view/pages/dashbot_chat_page.dart b/packages/dashbot/lib/features/chat/view/pages/dashbot_chat_page.dart new file mode 100644 index 00000000..629bf81a --- /dev/null +++ b/packages/dashbot/lib/features/chat/view/pages/dashbot_chat_page.dart @@ -0,0 +1,193 @@ +import 'dart:developer'; +import 'package:dashbot/features/chat/models/chat_models.dart'; +import 'package:dashbot/features/chat/view/widgets/chat_bubble.dart'; +import 'package:dashbot/features/chat/viewmodel/chat_viewmodel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:nanoid/nanoid.dart'; + +class ChatScreen extends ConsumerStatefulWidget { + final String initialPrompt; + const ChatScreen({super.key, required this.initialPrompt}); + + @override + ConsumerState createState() => _ChatScreenState(); +} + +class _ChatScreenState extends ConsumerState { + final TextEditingController _textController = TextEditingController(); + final List _messages = []; + bool _isGenerating = false; + String _currentStreamingResponse = ''; + + @override + void initState() { + super.initState(); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _sendMessage(promptOverride: widget.initialPrompt); + } + }); + } + + void _sendMessage({String? promptOverride}) async { + final messageContent = promptOverride ?? _textController.text; + + if (messageContent.trim().isEmpty) return; + + final userChatMessage = ChatMessage( + id: nanoid(), + content: messageContent, + role: MessageRole.user, + timestamp: DateTime.now(), + messageType: ChatMessageType.general, + ); + + if (promptOverride == null) { + _textController.clear(); + } + + setState(() { + _messages.add(userChatMessage); + _isGenerating = true; + _currentStreamingResponse = ''; + }); + + log("Sending message: $messageContent"); + + final stream = ref + .read(chatViewmodelProvider.notifier) + .sendMessage(messageContent, ChatMessageType.general); + + try { + await for (final result in stream) { + if (!mounted) return; + + result.fold( + (failure) { + log("Error: ${failure.message}"); + if (!mounted) return; + final errorChatMessage = ChatMessage( + id: nanoid(), + content: "Error: ${failure.message}", + role: MessageRole.system, + timestamp: DateTime.now(), + messageType: ChatMessageType.general, + ); + setState(() { + _messages.add(errorChatMessage); + _isGenerating = false; + _currentStreamingResponse = ''; + }); + }, + (response) { + setState(() { + _currentStreamingResponse += response.content; + }); + }, + ); + } + + if (!mounted) return; + + if (_currentStreamingResponse.isNotEmpty) { + final assistantChatMessage = ChatMessage( + id: nanoid(), + content: _currentStreamingResponse, + role: MessageRole.system, + timestamp: DateTime.now(), + messageType: ChatMessageType.general, + ); + _messages.add(assistantChatMessage); + } + + setState(() { + _isGenerating = false; + }); + } catch (e) { + log("Error receiving stream: $e"); + if (!mounted) return; + final errorChatMessage = ChatMessage( + id: nanoid(), + content: "Error: $e", + role: MessageRole.system, + timestamp: DateTime.now(), + messageType: ChatMessageType.general, + ); + setState(() { + _messages.add(errorChatMessage); + _isGenerating = false; + _currentStreamingResponse = ''; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Expanded( + child: + _messages.isEmpty && !_isGenerating + ? const Center(child: Text("Ask me anything!")) + : ListView.builder( + itemCount: _messages.length + (_isGenerating ? 1 : 0), + padding: const EdgeInsets.all(16.0), + reverse: false, + itemBuilder: (context, index) { + if (_isGenerating && index == _messages.length) { + return ChatBubble( + message: _currentStreamingResponse, + role: MessageRole.system, + ); + } + final message = _messages[index]; + return ChatBubble( + message: message.content, + role: message.role, + ); + }, + ), + ), + Divider( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + height: 5, + thickness: 6, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + decoration: InputDecoration( + hintText: + _isGenerating ? 'Generating...' : 'Ask anything', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + enabled: !_isGenerating, + onSubmitted: (_) => _isGenerating ? null : _sendMessage(), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send_rounded), + onPressed: _isGenerating ? null : _sendMessage, + tooltip: 'Send message', + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/dashbot/lib/features/chat/view/widgets/chat_bubble.dart b/packages/dashbot/lib/features/chat/view/widgets/chat_bubble.dart new file mode 100644 index 00000000..1018eef8 --- /dev/null +++ b/packages/dashbot/lib/features/chat/view/widgets/chat_bubble.dart @@ -0,0 +1,98 @@ +import 'package:apidash_design_system/tokens/tokens.dart'; +import 'package:dashbot/core/utils/dashbot_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +class ChatBubble extends StatelessWidget { + final String message; + final MessageRole role; + final String? promptOverride; + + const ChatBubble({ + super.key, + required this.message, + required this.role, + this.promptOverride, + }); + + @override + Widget build(BuildContext context) { + if (promptOverride != null && + role == MessageRole.user && + message == promptOverride) { + return SizedBox.shrink(); + } + if (message.isEmpty) { + return Align( + alignment: Alignment.centerLeft, + child: Column( + children: [ + kVSpacer8, + DashbotIcons.getDashbotIcon1(width: 42), + kVSpacer8, + CircularProgressIndicator.adaptive(), + ], + ), + ); + } + return Align( + alignment: + role == MessageRole.user + ? Alignment.centerRight + : Alignment.centerLeft, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (role == MessageRole.system) ...[ + kVSpacer6, + Image.asset("assets/dashbot_icon_1.png", width: 42), + kVSpacer8, + ], + Container( + margin: const EdgeInsets.symmetric(vertical: 5.0), + padding: const EdgeInsets.all(12.0), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: + role == MessageRole.user + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16.0), + ), + child: MarkdownBody( + data: message.isEmpty ? " " : message, + selectable: true, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context), + ).copyWith( + p: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: + role == MessageRole.user + ? Theme.of(context).colorScheme.surfaceBright + : Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ), + if (role == MessageRole.system) ...[ + IconButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: message)); + }, + icon: Icon( + Icons.copy_rounded, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } +} + +enum MessageRole { user, system } diff --git a/packages/dashbot/lib/features/chat/viewmodel/chat_viewmodel.dart b/packages/dashbot/lib/features/chat/viewmodel/chat_viewmodel.dart new file mode 100644 index 00000000..a5911c1b --- /dev/null +++ b/packages/dashbot/lib/features/chat/viewmodel/chat_viewmodel.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:nanoid/nanoid.dart'; + +import '../../../core/constants/dashbot_prompts.dart' as dash; +import '../../../core/model/dashbot_request_context.dart'; +import '../../../core/providers/dashbot_request_provider.dart'; +import '../view/widgets/chat_bubble.dart'; +import '../models/chat_models.dart'; +import '../repository/chat_remote_repository.dart'; + +class ChatViewmodel extends StateNotifier { + ChatViewmodel(this._ref) : super(const ChatState()); + + final Ref _ref; + StreamSubscription? _sub; + + ChatRemoteRepository get _repo => _ref.read(chatRepositoryProvider); + DashbotRequestContext? get _ctx => _ref.read(dashbotRequestContextProvider); + + List get currentMessages { + final id = _ctx?.requestId; + if (id == null) return const []; + return state.chatSessions[id] ?? const []; + } + + Future sendMessage({ + required String text, + ChatMessageType type = ChatMessageType.general, + bool countAsUser = true, + }) async { + final ctx = _ctx; + final ai = ctx?.aiRequestModel; + if (text.trim().isEmpty && countAsUser) return; + if (ai == null) { + _appendSystem( + 'AI model is not configured. Please set one in AI Request tab.', + type, + ); + return; + } + + final requestId = ctx?.requestId ?? 'global'; + + if (countAsUser) { + _addMessage( + requestId, + ChatMessage( + id: nanoid(), + content: text, + role: MessageRole.user, + timestamp: DateTime.now(), + messageType: type, + ), + ); + } + + final systemPrompt = _composeSystemPrompt(ctx, type); + final enriched = ai.copyWith( + systemPrompt: systemPrompt, + userPrompt: text, + stream: true, + ); + + // start stream + _sub?.cancel(); + state = state.copyWith(isGenerating: true, currentStreamingResponse: ''); + _sub = _repo + .streamChat(request: enriched) + .listen( + (chunk) { + state = state.copyWith( + currentStreamingResponse: + state.currentStreamingResponse + (chunk), + ); + }, + onError: (e) { + state = state.copyWith(isGenerating: false); + _appendSystem('Error: $e', type); + }, + onDone: () { + if (state.currentStreamingResponse.isNotEmpty) { + _addMessage( + requestId, + ChatMessage( + id: nanoid(), + content: state.currentStreamingResponse, + role: MessageRole.system, + timestamp: DateTime.now(), + messageType: type, + ), + ); + } + state = state.copyWith( + isGenerating: false, + currentStreamingResponse: '', + ); + }, + cancelOnError: true, + ); + } + + void cancel() { + _sub?.cancel(); + state = state.copyWith(isGenerating: false); + } + + // Helpers + void _addMessage(String requestId, ChatMessage m) { + final msgs = state.chatSessions[requestId] ?? const []; + state = state.copyWith( + chatSessions: { + ...state.chatSessions, + requestId: [...msgs, m], + }, + ); + } + + void _appendSystem(String text, ChatMessageType type) { + final id = _ctx?.requestId ?? 'global'; + _addMessage( + id, + ChatMessage( + id: nanoid(), + content: text, + role: MessageRole.system, + timestamp: DateTime.now(), + messageType: type, + ), + ); + } + + String _composeSystemPrompt( + DashbotRequestContext? ctx, + ChatMessageType type, + ) { + final history = _buildHistoryBlock(); + final contextBlock = _buildContextBlock(ctx); + final task = _buildTaskPrompt(ctx, type); + return [ + if (task != null) task, + if (contextBlock != null) contextBlock, + if (history.isNotEmpty) history, + ].join('\n\n'); + } + + String _buildHistoryBlock({int maxTurns = 8}) { + final id = _ctx?.requestId ?? 'global'; + final messages = state.chatSessions[id] ?? const []; + if (messages.isEmpty) return ''; + final start = messages.length > maxTurns ? messages.length - maxTurns : 0; + final recent = messages.sublist(start); + final buf = StringBuffer(''' + Only use the following short chat history to maintain continuity. Do not repeat it back. + '''); + for (final m in recent) { + final role = m.role == MessageRole.user ? 'user' : 'assistant'; + buf.writeln('- $role: ${m.content}'); + } + buf.writeln(''); + return buf.toString(); + } + + String? _buildContextBlock(DashbotRequestContext? ctx) { + final http = ctx?.httpRequestModel; + if (ctx == null || http == null) return null; + final headers = http.headersMap.entries + .map((e) => '"${e.key}": "${e.value}"') + .join(', '); + return ''' + Request Name: ${ctx.requestName ?? ''} + URL: ${http.url} + Method: ${http.method.name.toUpperCase()} + Status: ${ctx.responseStatus ?? ''} + Content-Type: ${http.bodyContentType.name} + Headers: { $headers } + Body: ${http.body ?? ''} + Response: ${ctx.httpResponseModel?.body ?? ''} + '''; + } + + String? _buildTaskPrompt(DashbotRequestContext? ctx, ChatMessageType type) { + if (ctx == null) return null; + final http = ctx.httpRequestModel; + final resp = ctx.httpResponseModel; + final prompts = dash.DashbotPrompts(); + switch (type) { + case ChatMessageType.explainResponse: + return prompts.explainApiResponsePrompt( + url: http?.url, + method: http?.method.name.toUpperCase(), + responseStatus: ctx.responseStatus, + bodyContentType: http?.bodyContentType.name, + message: resp?.body, + headersMap: http?.headersMap, + body: http?.body, + ); + case ChatMessageType.debugError: + return prompts.debugApiErrorPrompt( + url: http?.url, + method: http?.method.name.toUpperCase(), + responseStatus: ctx.responseStatus, + bodyContentType: http?.bodyContentType.name, + message: resp?.body, + headersMap: http?.headersMap, + body: http?.body, + ); + case ChatMessageType.generateTest: + return prompts.generateTestCasesPrompt( + url: http?.url, + method: http?.method.name.toUpperCase(), + headersMap: http?.headersMap, + body: http?.body, + ); + case ChatMessageType.general: + return null; + } + } +} + +final chatViewmodelProvider = StateNotifierProvider(( + ref, +) { + return ChatViewmodel(ref); +}); diff --git a/packages/dashbot/lib/features/home/view/pages/home_page.dart b/packages/dashbot/lib/features/home/view/pages/home_page.dart index fb5b15a1..e4b4123e 100644 --- a/packages/dashbot/lib/features/home/view/pages/home_page.dart +++ b/packages/dashbot/lib/features/home/view/pages/home_page.dart @@ -4,6 +4,7 @@ import '../../../../core/routes/dashbot_routes.dart'; import 'package:apidash_design_system/tokens/measurements.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../chat/models/chat_models.dart'; class DashbotHomePage extends ConsumerStatefulWidget { const DashbotHomePage({super.key}); @@ -13,13 +14,6 @@ class DashbotHomePage extends ConsumerStatefulWidget { } class _DashbotHomePageState extends ConsumerState { - void navigateToChat(String prompt) { - Navigator.of(context).pushNamed( - DashbotRoutes.dashbotChat, - arguments: {'initialPrompt': prompt}, - ); - } - @override Widget build(BuildContext context) { return Container( @@ -59,7 +53,12 @@ class _DashbotHomePageState extends ConsumerState { // child: const Text("🤖 Chat with Dashbot"), // ), TextButton( - onPressed: () {}, + onPressed: () { + Navigator.of(context).pushNamed( + DashbotRoutes.dashbotChat, + arguments: ChatMessageType.explainResponse, + ); + }, style: TextButton.styleFrom( side: BorderSide( color: Theme.of(context).colorScheme.primary, @@ -101,7 +100,12 @@ class _DashbotHomePageState extends ConsumerState { ), ), TextButton( - onPressed: () {}, + onPressed: () { + Navigator.of(context).pushNamed( + DashbotRoutes.dashbotChat, + arguments: ChatMessageType.generateTest, + ); + }, style: TextButton.styleFrom( side: BorderSide( color: Theme.of(context).colorScheme.primary,