mirror of
https://github.com/foss42/apidash.git
synced 2025-07-01 05:30:54 +08:00
Feat: Clear Markup for code responses
This commit is contained in:
28
lib/dashbot/widgets/chat_bubble.dart
Normal file
28
lib/dashbot/widgets/chat_bubble.dart
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// lib/dashbot/widgets/chat_bubble.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'content_renderer.dart';
|
||||||
|
|
||||||
|
class ChatBubble extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final bool isUser;
|
||||||
|
|
||||||
|
const ChatBubble({super.key, required this.message, this.isUser = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Align(
|
||||||
|
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUser
|
||||||
|
? Theme.of(context).colorScheme.primaryContainer
|
||||||
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: renderContent(context, message),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
97
lib/dashbot/widgets/content_renderer.dart
Normal file
97
lib/dashbot/widgets/content_renderer.dart
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
// lib/dashbot/widgets/content_renderer.dart
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_highlighter/flutter_highlighter.dart';
|
||||||
|
import 'package:flutter_highlighter/themes/monokai-sublime.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
|
|
||||||
|
Widget renderContent(BuildContext context, String text) {
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return const Text("No content to display.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final codeBlockPattern = RegExp(r'```(\w+)?\n([\s\S]*?)```', multiLine: true);
|
||||||
|
final matches = codeBlockPattern.allMatches(text);
|
||||||
|
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
return _renderMarkdown(context, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> children = [];
|
||||||
|
int lastEnd = 0;
|
||||||
|
|
||||||
|
for (var match in matches) {
|
||||||
|
if (match.start > lastEnd) {
|
||||||
|
children.add(_renderMarkdown(context, text.substring(lastEnd, match.start)));
|
||||||
|
}
|
||||||
|
|
||||||
|
final language = match.group(1) ?? 'text';
|
||||||
|
final code = match.group(2)!.trim();
|
||||||
|
children.add(_renderCodeBlock(context, language, code));
|
||||||
|
|
||||||
|
lastEnd = match.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastEnd < text.length) {
|
||||||
|
children.add(_renderMarkdown(context, text.substring(lastEnd)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _renderMarkdown(BuildContext context, String markdown) {
|
||||||
|
return MarkdownBody(
|
||||||
|
data: markdown,
|
||||||
|
selectable: true,
|
||||||
|
styleSheet: MarkdownStyleSheet(
|
||||||
|
p: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _renderCodeBlock(BuildContext context, String language, String code) {
|
||||||
|
if (language == 'json') {
|
||||||
|
try {
|
||||||
|
final prettyJson = const JsonEncoder.withIndent(' ').convert(jsonDecode(code));
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
child: SelectableText(
|
||||||
|
prettyJson,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return _renderFallbackCode(context, code);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
child: HighlightView(
|
||||||
|
code,
|
||||||
|
language: language,
|
||||||
|
theme: monokaiSublimeTheme,
|
||||||
|
textStyle: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return _renderFallbackCode(context, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _renderFallbackCode(BuildContext context, String code) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
child: SelectableText(
|
||||||
|
code,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: Colors.red),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
@ -1,12 +1,10 @@
|
|||||||
|
// lib/dashbot/widgets/dashbot_widget.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:apidash/dashbot/providers/dashbot_providers.dart';
|
import 'package:apidash/dashbot/providers/dashbot_providers.dart';
|
||||||
import 'package:apidash/providers/providers.dart';
|
import 'package:apidash/providers/providers.dart';
|
||||||
import 'package:highlighter/highlighter.dart' show highlight;
|
import 'package:flutter/services.dart';
|
||||||
import 'package:json_explorer/json_explorer.dart';
|
import 'chat_bubble.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
class DashBotWidget extends ConsumerStatefulWidget {
|
class DashBotWidget extends ConsumerStatefulWidget {
|
||||||
const DashBotWidget({Key? key}) : super(key: key);
|
const DashBotWidget({Key? key}) : super(key: key);
|
||||||
@ -17,8 +15,22 @@ class DashBotWidget extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
||||||
final TextEditingController _controller = TextEditingController();
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
late ScrollController _scrollController;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController = ScrollController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage(String message) async {
|
Future<void> _sendMessage(String message) async {
|
||||||
if (message.trim().isEmpty) return;
|
if (message.trim().isEmpty) return;
|
||||||
final dashBotService = ref.read(dashBotServiceProvider);
|
final dashBotService = ref.read(dashBotServiceProvider);
|
||||||
@ -34,26 +46,29 @@ class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await dashBotService.handleRequest(message, requestModel, responseModel);
|
final response = await dashBotService.handleRequest(message, requestModel, responseModel);
|
||||||
final formattedResponse = _limitResponse(response);
|
|
||||||
|
|
||||||
ref.read(chatMessagesProvider.notifier).addMessage({
|
ref.read(chatMessagesProvider.notifier).addMessage({
|
||||||
'role': 'bot',
|
'role': 'bot',
|
||||||
'message': formattedResponse,
|
'message': response,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error, stackTrace) {
|
||||||
|
print('Error in _sendMessage: $error');
|
||||||
|
print('StackTrace: $stackTrace');
|
||||||
ref.read(chatMessagesProvider.notifier).addMessage({
|
ref.read(chatMessagesProvider.notifier).addMessage({
|
||||||
'role': 'bot',
|
'role': 'bot',
|
||||||
'message': "Error: ${error.toString()}",
|
'message': "Error: ${error.toString()}",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _limitResponse(String response) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final messages = ref.watch(chatMessagesProvider);
|
final messages = ref.watch(chatMessagesProvider);
|
||||||
@ -101,7 +116,10 @@ class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
|||||||
(msg) => msg['role'] == 'bot',
|
(msg) => msg['role'] == 'bot',
|
||||||
orElse: () => {'message': ''},
|
orElse: () => {'message': ''},
|
||||||
)['message'];
|
)['message'];
|
||||||
Share.share(lastBotMessage);
|
Clipboard.setData(ClipboardData(text: lastBotMessage));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Copied to clipboard')),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -143,6 +161,7 @@ class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
|||||||
|
|
||||||
Widget _buildChatArea(List<Map<String, dynamic>> messages) {
|
Widget _buildChatArea(List<Map<String, dynamic>> messages) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
reverse: true,
|
reverse: true,
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@ -191,162 +210,3 @@ class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatBubble extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
final bool isUser;
|
|
||||||
|
|
||||||
const ChatBubble({super.key, required this.message, this.isUser = false});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Align(
|
|
||||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isUser
|
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: _renderContent(context, message),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _renderContent(BuildContext context, String text) {
|
|
||||||
final codeBlockPattern = RegExp(r'```(\w+)?\n([\s\S]*?)```', multiLine: true);
|
|
||||||
final matches = codeBlockPattern.allMatches(text);
|
|
||||||
|
|
||||||
if (matches.isEmpty) {
|
|
||||||
return MarkdownBody(
|
|
||||||
data: text,
|
|
||||||
selectable: true,
|
|
||||||
styleSheet: MarkdownStyleSheet(
|
|
||||||
h1: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
h2: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
h3: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
strong: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
em: TextStyle(fontStyle: FontStyle.italic),
|
|
||||||
listBullet: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
p: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Widget> children = [];
|
|
||||||
int lastEnd = 0;
|
|
||||||
|
|
||||||
for (var match in matches) {
|
|
||||||
if (match.start > lastEnd) {
|
|
||||||
children.add(
|
|
||||||
MarkdownBody(
|
|
||||||
data: text.substring(lastEnd, match.start),
|
|
||||||
selectable: true,
|
|
||||||
styleSheet: MarkdownStyleSheet(
|
|
||||||
h1: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
h2: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
h3: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
strong: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
em: TextStyle(fontStyle: FontStyle.italic),
|
|
||||||
listBullet: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
p: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final language = match.group(1) ?? 'text';
|
|
||||||
final code = match.group(2)!.trim();
|
|
||||||
|
|
||||||
if (language == 'json' && _isValidJson(code)) {
|
|
||||||
final prettyJson = const JsonEncoder.withIndent(' ').convert(jsonDecode(code));
|
|
||||||
children.add(
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
|
||||||
child: SelectableText(
|
|
||||||
prettyJson,
|
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
final highlighted = highlight.parse(code, language: language);
|
|
||||||
final spans = _buildTextSpans(highlighted, context);
|
|
||||||
children.add(
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
|
||||||
child: SelectableText.rich(
|
|
||||||
TextSpan(children: spans),
|
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastEnd = match.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastEnd < text.length) {
|
|
||||||
children.add(
|
|
||||||
MarkdownBody(
|
|
||||||
data: text.substring(lastEnd),
|
|
||||||
selectable: true,
|
|
||||||
styleSheet: MarkdownStyleSheet(
|
|
||||||
h1: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
h2: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
h3: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
strong: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
em: TextStyle(fontStyle: FontStyle.italic),
|
|
||||||
listBullet: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
p: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
||||||
children: children,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isValidJson(String text) {
|
|
||||||
try {
|
|
||||||
jsonDecode(text);
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<TextSpan> _buildTextSpans(dynamic highlighted, BuildContext context) {
|
|
||||||
final List<TextSpan> spans = [];
|
|
||||||
for (var span in highlighted.spans ?? []) {
|
|
||||||
spans.add(TextSpan(
|
|
||||||
text: span.text,
|
|
||||||
style: TextStyle(
|
|
||||||
color: _getColorForClassName(span.className, context),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return spans.isEmpty ? [TextSpan(text: highlighted.source)] : spans;
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _getColorForClassName(String? className, BuildContext context) {
|
|
||||||
switch (className) {
|
|
||||||
case 'keyword':
|
|
||||||
return Colors.blue;
|
|
||||||
case 'string':
|
|
||||||
return Colors.green;
|
|
||||||
case 'comment':
|
|
||||||
return Colors.grey;
|
|
||||||
default:
|
|
||||||
return Theme.of(context).colorScheme.onSurface;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -509,6 +509,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_highlighter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_highlighter
|
||||||
|
sha256: "93173afd47a9ada53f3176371755e7ea4a1065362763976d06d6adfb4d946e10"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.1"
|
||||||
flutter_hooks:
|
flutter_hooks:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -67,6 +67,7 @@ dependencies:
|
|||||||
path: plugins/window_size
|
path: plugins/window_size
|
||||||
share_plus: ^10.1.4
|
share_plus: ^10.1.4
|
||||||
ollama_dart: ^0.2.2
|
ollama_dart: ^0.2.2
|
||||||
|
flutter_highlighter: ^0.1.0
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
extended_text_field: ^16.0.0
|
extended_text_field: ^16.0.0
|
||||||
|
Reference in New Issue
Block a user