feat: dahbot chat page

This commit is contained in:
Udhay-Adithya
2025-09-01 15:59:07 +05:30
parent 8b74dbbfcd
commit 0def6c1713
7 changed files with 711 additions and 27 deletions

View File

@@ -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<String, List<ChatMessage>> 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<String, List<ChatMessage>>? 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);
}

View File

@@ -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<String> streamChat({required AIRequestModel request});
/// Execute a non-streaming chat completion.
Future<String?> sendChat({required AIRequestModel request});
}
class ChatRemoteRepositoryImpl implements ChatRemoteRepository {
ChatRemoteRepositoryImpl();
@override
Stream<String> 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<String?> 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<ChatRemoteRepository>((ref) {
return ChatRemoteRepositoryImpl();
});

View File

@@ -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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
final TextEditingController _textController = TextEditingController();
final List<ChatMessage> _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',
),
],
),
),
],
),
);
}
}

View File

@@ -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 }

View File

@@ -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<ChatState> {
ChatViewmodel(this._ref) : super(const ChatState());
final Ref _ref;
StreamSubscription<String>? _sub;
ChatRemoteRepository get _repo => _ref.read(chatRepositoryProvider);
DashbotRequestContext? get _ctx => _ref.read(dashbotRequestContextProvider);
List<ChatMessage> get currentMessages {
final id = _ctx?.requestId;
if (id == null) return const [];
return state.chatSessions[id] ?? const [];
}
Future<void> 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('''<conversation_context>
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('</conversation_context>');
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_context>
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 ?? ''}
</request_context>''';
}
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<ChatViewmodel, ChatState>((
ref,
) {
return ChatViewmodel(ref);
});