mirror of
https://github.com/foss42/apidash.git
synced 2025-07-01 21:47:11 +08:00
Feat: Dashbot widget text markdown
This commit is contained in:
@ -1,9 +1,12 @@
|
|||||||
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:flutter_markdown/flutter_markdown.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 '../../providers/collection_providers.dart';
|
import 'package:highlighter/highlighter.dart' show highlight;
|
||||||
|
import 'package:json_explorer/json_explorer.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);
|
||||||
@ -31,7 +34,7 @@ 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 = _formatMarkdown(response);
|
final formattedResponse = _limitResponse(response);
|
||||||
|
|
||||||
ref.read(chatMessagesProvider.notifier).addMessage({
|
ref.read(chatMessagesProvider.notifier).addMessage({
|
||||||
'role': 'bot',
|
'role': 'bot',
|
||||||
@ -46,24 +49,21 @@ class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
|||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String _formatMarkdown(String text) {
|
|
||||||
if (!text.contains("```") && text.trim().isNotEmpty) {
|
String _limitResponse(String response) {
|
||||||
text = "```\n$text\n```";
|
return response;
|
||||||
}
|
|
||||||
text = text.replaceAllMapped(RegExp(r'^\*\*(.*?)\*\*', multiLine: true),
|
|
||||||
(match) => '## ${match.group(1)}');
|
|
||||||
return text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final messages = ref.watch(chatMessagesProvider);
|
final messages = ref.watch(chatMessagesProvider);
|
||||||
final requestModel = ref.watch(selectedRequestModelProvider);
|
final requestModel = ref.read(selectedRequestModelProvider);
|
||||||
final statusCode = requestModel?.httpResponseModel?.statusCode;
|
final statusCode = requestModel?.httpResponseModel?.statusCode;
|
||||||
final showDebugButton = statusCode != null && statusCode >= 400;
|
final showDebugButton = statusCode != null && statusCode >= 400;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 450,
|
height: 450,
|
||||||
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
@ -71,34 +71,78 @@ class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
|||||||
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 4))],
|
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 4))],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
_buildHeader(context),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildQuickActions(showDebugButton),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(child: _buildChatArea(messages)),
|
||||||
|
if (_isLoading) _buildLoadingIndicator(),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildInputArea(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text('DashBot', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
const Text('DashBot', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
tooltip: 'Copy Last Response',
|
||||||
|
onPressed: () {
|
||||||
|
final lastBotMessage = ref.read(chatMessagesProvider).lastWhere(
|
||||||
|
(msg) => msg['role'] == 'bot',
|
||||||
|
orElse: () => {'message': ''},
|
||||||
|
)['message'];
|
||||||
|
Share.share(lastBotMessage);
|
||||||
|
},
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_sweep),
|
icon: const Icon(Icons.delete_sweep),
|
||||||
tooltip: 'Clear Chat History',
|
tooltip: 'Clear Chat',
|
||||||
onPressed: () => ref.read(chatMessagesProvider.notifier).clearMessages(),
|
onPressed: () => ref.read(chatMessagesProvider.notifier).clearMessages(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
],
|
||||||
Wrap(
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuickActions(bool showDebugButton) {
|
||||||
|
return Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
alignment: WrapAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => _sendMessage("Explain API"),
|
onPressed: () => _sendMessage("Explain API"),
|
||||||
icon: const Icon(Icons.info_outline),
|
icon: const Icon(Icons.info_outline),
|
||||||
label: const Text("Explain API"),
|
label: const Text("Explain"),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDebugButton)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => _sendMessage("Debug API"),
|
||||||
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
|
label: const Text("Debug"),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
const SizedBox(height: 12),
|
}
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
Widget _buildChatArea(List<Map<String, dynamic>> messages) {
|
||||||
|
return ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@ -108,18 +152,21 @@ class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
|||||||
isUser: message['role'] == 'user',
|
isUser: message['role'] == 'user',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
if (_isLoading)
|
|
||||||
const Padding(
|
Widget _buildLoadingIndicator() {
|
||||||
|
return const Padding(
|
||||||
padding: EdgeInsets.all(8.0),
|
padding: EdgeInsets.all(8.0),
|
||||||
child: CircularProgressIndicator(),
|
child: LinearProgressIndicator(),
|
||||||
),
|
);
|
||||||
const SizedBox(height: 10),
|
}
|
||||||
Container(
|
|
||||||
|
Widget _buildInputArea(BuildContext context) {
|
||||||
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -128,10 +175,11 @@ class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Ask something...',
|
hintText: 'Ask DashBot...',
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
onSubmitted: _sendMessage,
|
onSubmitted: _sendMessage,
|
||||||
|
maxLines: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -140,9 +188,6 @@ class _DashBotWidgetState extends ConsumerState<DashBotWidget> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -163,27 +208,145 @@ class ChatBubble extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isUser
|
color: isUser
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
? Theme.of(context).colorScheme.primaryContainer
|
||||||
: Theme.of(context).colorScheme.secondaryContainer,
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: MarkdownBody(
|
child: _renderContent(context, message),
|
||||||
data: 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,
|
selectable: true,
|
||||||
styleSheet: MarkdownStyleSheet(
|
styleSheet: MarkdownStyleSheet(
|
||||||
h2: TextStyle(
|
h1: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
|
||||||
fontSize: 18,
|
h2: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface),
|
||||||
fontWeight: FontWeight.bold,
|
h3: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface),
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
strong: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
em: TextStyle(fontStyle: FontStyle.italic),
|
||||||
h3: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
listBullet: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
20
pubspec.lock
20
pubspec.lock
@ -168,10 +168,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: built_value
|
name: built_value
|
||||||
sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2"
|
sha256: "8b158ab94ec6913e480dc3f752418348b5ae099eb75868b5f4775f0572999c61"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.9.3"
|
version: "8.9.4"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -700,14 +700,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
highlight:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: highlight
|
|
||||||
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.0"
|
|
||||||
highlighter:
|
highlighter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -1083,18 +1075,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "67eae327b1b0faf761964a1d2e5d323c797f3799db0e85aa232db8d9e922bc35"
|
sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.2.1"
|
version: "8.3.0"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: package_info_plus_platform_interface
|
name: package_info_plus_platform_interface
|
||||||
sha256: "205ec83335c2ab9107bbba3f8997f9356d72ca3c715d2f038fc773d0366b4c76"
|
sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "3.2.0"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
Reference in New Issue
Block a user