ollama-app/lib/services/error.dart

571 lines
19 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart';
import 'package:ollama_dart/ollama_dart.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/gen/app_localizations.dart';
import 'markdown.dart';
final _logIdRegex = RegExp(r"^[A-Z0-9]{2,16}$");
String _removeNewlines(String content) =>
content.replaceAll(RegExp(r"\s*\n\s*"), " ");
typedef ErrorGuardErrorMessageGenerator = String Function(Object exception);
typedef ErrorGuardDetailsMessageGenerator =
String? Function(Object exception, StackTrace stackTrace);
typedef ErrorGuardIgnoreIfGenerator = bool Function(Object exception);
String _defaultErrorMessage(Object exception) => switch (exception) {
OllamaClientException _ => "Unknown client error",
AssertionError _ =>
exception.toString().split(": ").elementAtOrNull(4) ??
"An assertion failed",
TimeoutException _ => "Request timed out",
SocketException _ ||
HttpException _ ||
ClientException _ => "Could not connect to server",
TlsException _ => "Could not establish secure connection",
StateError _ => "Invalid state encountered",
_ => "An unknown error occurred",
};
String? _defaultDetailsMessage(
Object exception,
StackTrace stackTrace,
) => switch (exception) {
OllamaClientException e =>
[
"SocketException",
"HttpException",
"ClientException",
].contains(e.body.toString().split(":").first)
? "A network error occurred while trying to connect to the server."
"\n\nYou may check your network connection and server reachability and try again."
: "The Ollama API client received a faulty response with code `${e.code}`."
"\n\nPlease check your Ollama server or proxy configuration and try again.",
AssertionError _ =>
"An assertion failed, meaning that the app is misconfigured or a bug occurred."
"\n\nAssertions are used to check for conditions that should never happen."
"\n\n<https://en.wikipedia.org/wiki/Assertion_(software_development)>",
TimeoutException _ =>
"Time ran out while waiting for a response from the server."
"\n\nThis might be caused by a slow or unresponsive server, or a network issue.\nYou may try increasing the Timeout Multiplier in the settings.",
SocketException _ || HttpException _ || ClientException _ =>
"A ${exception.toString().split(":").first} might be caused by a slow or unresponsive server, or a network issue."
"\n\nYou may check your network connection and server reachability and try again.",
TlsException _ =>
"An error occurred while trying to establish a secure connection via TLS."
"\n\nThis might be caused by an invalid or expired certificate, though this should not happen. Please report this issue to the developers.",
StateError _ =>
"Now, this is not good.\n\nYou should report this issue to the developers. Try restarting the app.",
_ => null,
};
bool _defaultIgnoreIf(Object exception) => false;
ErrorGuardErrorMessageGenerator errorGuardErrorMessageWithFallback(
String? Function(Object exception) errorMessage,
) => (Object exception) {
try {
return errorMessage.call(exception)!;
} catch (e) {
return _defaultErrorMessage(exception);
}
};
ErrorGuardDetailsMessageGenerator errorGuardDetailsMessageWithFallback(
ErrorGuardDetailsMessageGenerator detailsMessage,
) => (Object exception, StackTrace stackTrace) {
try {
// throws error if null, so catch block is called
return detailsMessage.call(exception, stackTrace)!;
} catch (_) {
return _defaultDetailsMessage(exception, stackTrace);
}
};
ErrorGuardErrorMessageGenerator errorGuardErrorMessageWithFallbackSingle(
Type exceptionType,
String message,
) => errorGuardErrorMessageWithFallback(
(exception) => (exception.runtimeType == exceptionType) ? message : null,
);
ErrorGuardDetailsMessageGenerator errorGuardDetailsMessageWithFallbackSingle(
Type exception,
String message,
) => errorGuardDetailsMessageWithFallback(
(e, _) => (e.runtimeType == exception) ? message : null,
);
/// Runs the given [action] and catches any exceptions that occur.
///
/// If the execution of [action] is successful, the result of the function is
/// returned.
///
/// If an exception occurs, a SnackBar is shown with the error message. The user
/// can then view more details about the error and where is occurred and is able
/// to report it.
///
/// If this function returns `null`, all code flow following this call should
/// be skipped.
///
/// ***Important:*** You must catch all async functions carefully! Do this using
/// [Future.catchError] with [Error.throwWithStackTrace] as the callback.
///
/// [logId] is an optional identifier for this [errorGuard] call. It is
/// displayed in the UI dialog and submitted with the report, if done. This
/// should not be a word, but rather a random string. This should be constant
/// across app restarts or re-compiles. It must be a string of 2 to 16
/// alphanumeric characters, starting with a letter. Other values will be
/// ignored. The recommended length is 8 characters.
///
/// To generate a random log ID, run: `dart run tools/logid.dart`
///
/// The [errorMessage] function is used to generate the error message. This
/// should be a short message that describes the error in a user-friendly way.
/// It should not contain any technical details or stack traces, but rather
/// a simple description of what task went wrong. The [errorMessage.exception]
/// parameter should only be used to determine the type of error and should not
/// be used to generate the message directly. The message should not be longer
/// than about 60 characters, while it is recommended to keep it under 35
/// characters.
/// An example would be "Unable to connect to the server" or "Server didn't
/// return a valid response".
///
/// The [detailsMessage] function is used to generate a more detailed message.
/// This should contain more information about the error, such as the
/// circumstances and maybe a prediction of the cause. This message should not
/// include any exception or stack trace information, because those are printed
/// separately, but rather a more detailed description of the error. The message
/// can be longer, it is not limited to a specific length, but should still be
/// direct and to the point. This message may contain a link to information
/// about the error, such as a documentation page or a Wiki article separated
/// by a new paragraph using the `<https://example.com>` syntax.
/// An example would be "The server might be down for maintenance" or "This
/// might be because of an invalid server configuration".
///
/// Both [errorMessage] and [detailsMessage] support a rudimentary Markdown-like
/// formatting for inline code (`` `code` ``), italic (`*italic*`) and links
/// (`<https://example.com>`).
///
/// [enableReporting] can be used to disable the reporting feature. This can be
/// useful if it's certain that the error is not caused by a bug in the app,
/// but rather by a user error or a misconfiguration. [forceReporting] can be
/// used to force the reporting of the error.
Future<T?> errorGuard<T>(
BuildContext context,
String? logId,
FutureOr<T> Function() action, {
ErrorGuardErrorMessageGenerator errorMessage = _defaultErrorMessage,
ErrorGuardDetailsMessageGenerator detailsMessage = _defaultDetailsMessage,
ErrorGuardIgnoreIfGenerator ignoreIf = _defaultIgnoreIf,
bool instantDialog = false,
bool enableDetails = true,
bool enableReporting = true,
bool forceReporting = false,
}) async {
assert(
logId == null || _logIdRegex.hasMatch(logId),
"`logId` must be a string of 2 to 16 alphanumeric characters",
);
assert(
enableDetails || !instantDialog,
"`enableDetails` must be `true` if `instantDialog` is `true`",
);
if (instantDialog) enableDetails = true;
assert(
enableReporting || !forceReporting,
"`enableReporting` must be `true` if `forceReporting` is true",
);
if (forceReporting) {
enableReporting = true;
instantDialog = true;
}
try {
return await Future.value(
action.call(),
).catchError(Error.throwWithStackTrace);
} catch (exception, stackTrace) {
if (context.mounted && !ignoreIf.call(exception)) {
var dateTime = DateTime.now();
var colorScheme = Theme.of(context).colorScheme;
logId = logId?.toUpperCase();
if (logId != null && !_logIdRegex.hasMatch(logId)) {
logId = null;
}
String? exceptionText;
try {
exceptionText = exception.toString().trim();
if (exceptionText.isEmpty) {
exceptionText = null;
}
} catch (_) {}
String errorMessageText;
try {
errorMessageText = errorMessage.call(exception);
errorMessageText = errorMessageText.trim();
assert(errorMessageText.isNotEmpty, "Error message must not be empty");
if (!errorMessageText.endsWith(".")) errorMessageText += ".";
} catch (_) {
errorMessageText = _defaultErrorMessage(exception).trim();
}
String? detailsMessageText;
try {
detailsMessageText = detailsMessage.call(exception, stackTrace);
detailsMessageText = detailsMessageText?.trim();
if (detailsMessageText != null) {
assert(
detailsMessageText.isNotEmpty,
"Details message must not be empty",
);
if (detailsMessageText.isEmpty) {
detailsMessageText = null;
} else if (!detailsMessageText.endsWith(".") &&
!detailsMessageText.endsWith(">")) {
detailsMessageText += ".";
}
}
} catch (_) {
detailsMessageText = _defaultDetailsMessage(exception, stackTrace);
}
void showErrorDialog() {
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => _ErrorGuardDetailsDialog(
logId: logId,
dateTime: dateTime,
exception: exceptionText,
stackTrace: stackTrace,
errorMessage: errorMessageText,
detailsMessage: detailsMessageText,
enableReporting: enableReporting,
forceReporting: forceReporting,
),
);
}
if (instantDialog) {
showErrorDialog();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_removeNewlines(
Markdown(errorMessageText).toTextSpan(context).toPlainText(),
),
style: TextStyle(color: colorScheme.onErrorContainer),
),
backgroundColor: colorScheme.errorContainer,
action: !enableDetails
? null
: SnackBarAction(
label: AppLocalizations.of(context).errorGuardDetails,
textColor: colorScheme.onErrorContainer,
onPressed: showErrorDialog,
),
),
);
}
}
return null;
}
}
class _ErrorGuardDetailsDialog extends StatefulWidget {
final String? logId;
final DateTime dateTime;
final String? exception;
final StackTrace stackTrace;
final String errorMessage;
final String? detailsMessage;
final bool enableReporting;
final bool forceReporting;
const _ErrorGuardDetailsDialog({
required this.logId,
required this.dateTime,
required this.exception,
required this.stackTrace,
required this.errorMessage,
required this.detailsMessage,
required this.enableReporting,
required this.forceReporting,
});
@override
State<_ErrorGuardDetailsDialog> createState() =>
_ErrorGuardDetailsDialogState();
}
class _ErrorGuardDetailsDialogState extends State<_ErrorGuardDetailsDialog> {
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !widget.forceReporting,
child: AlertDialog(
title: Stack(
children: [
Align(
alignment: Alignment.centerRight,
child: Transform.translate(
offset: const Offset(0, 2),
child: Text(
widget.dateTime
.toIso8601String()
.split(".")
.first
.replaceFirst("T", "\n"),
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.labelSmall,
),
),
),
Text(AppLocalizations.of(context).errorGuardTitle),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.logId != null)
Transform.translate(
offset: const Offset(0, -20),
child: Text("@${widget.logId}"),
),
Text.rich(Markdown(widget.errorMessage).toTextSpan(context)),
const SizedBox(height: 8),
if (widget.detailsMessage != null) ...[
_ErrorGuardDetailsPanel(
icon: const Icon(Icons.announcement_outlined),
title: AppLocalizations.of(context).errorGuardDetails,
content: widget.detailsMessage!,
isExpanded: true,
monospaced: false,
),
const SizedBox(height: 8),
],
_ErrorGuardDetailsPanel(
icon: const Icon(Icons.cancel_outlined),
title: AppLocalizations.of(context).errorGuardException,
content:
widget.exception ?? "Could not retrieve exception message",
isExpanded: kDebugMode,
monospaced: widget.exception != null,
italic: widget.exception == null,
),
if (kDebugMode) ...[
const SizedBox(height: 8),
_ErrorGuardDetailsPanel(
icon: const Icon(Icons.format_list_numbered),
title: AppLocalizations.of(context).errorGuardStackTrace,
content: widget.stackTrace.toString(),
),
],
],
),
actions: [
if (widget.enableReporting)
TextButton.icon(
onPressed: _report,
onLongPress: () =>
Clipboard.setData(ClipboardData(text: _reportText())),
icon: const Icon(Icons.bug_report),
label: Text(AppLocalizations.of(context).errorGuardReport),
),
if (!widget.forceReporting)
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
),
],
scrollable: true,
alignment: Alignment.bottomCenter,
),
);
}
String _reportText() =>
"""
An exception was thrown during the execution of the app.
<details open>
<summary>Exception</summary>
${(widget.exception != null) ? "```\n${widget.exception}\n```" : "> Not available"}
</details>
<details>
<summary>Stack Trace</summary>
```
${widget.stackTrace.toString().trim()}
```
</details>
---
The app suggested the following cause of the issue:
- ***Error Message:*** ${_removeNewlines(widget.errorMessage)}
- ***Details Message:*** ${_removeNewlines((widget.detailsMessage ?? "None provided").trim())}"""
.trim();
void _report() {
var url =
"https://github.com/JHubi1/ollama-app/issues/new?template=bug.yaml";
url +=
"&description=${Uri.encodeComponent('Received error: "${widget.errorMessage.replaceFirst(RegExp(r".$"), "")}"${(widget.logId != null) ? " (@${widget.logId})" : ""}')}";
var contextText = _reportText();
url += "&context=${Uri.encodeComponent(contextText)}";
Clipboard.setData(ClipboardData(text: url));
launchUrl(Uri.parse(url), mode: LaunchMode.inAppBrowserView);
}
}
class _ErrorGuardDetailsPanel extends StatefulWidget {
final Widget? icon;
final String title;
final String content;
final bool isExpanded;
final bool monospaced;
final bool italic;
const _ErrorGuardDetailsPanel({
this.icon,
required this.title,
required this.content,
this.isExpanded = false,
this.monospaced = true,
this.italic = false,
});
@override
State<_ErrorGuardDetailsPanel> createState() =>
_ErrorGuardDetailsPanelState();
}
class _ErrorGuardDetailsPanelState extends State<_ErrorGuardDetailsPanel>
with TickerProviderStateMixin {
bool _expanded = false;
late final AnimationController _animationController;
late final Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
value: widget.isExpanded ? 1.0 : 0.0,
duration: kThemeAnimationDuration,
vsync: this,
);
_expanded = widget.isExpanded;
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.fastEaseInToSlowEaseOut,
);
}
void _toggleAnimation() {
setState(() {
_expanded = !_expanded;
_expanded
? _animationController.forward()
: _animationController.reverse();
});
}
Widget _monospacedContent({required Widget child}) {
if (!widget.monospaced) return child;
return SizedBox(
width: double.infinity,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: GestureDetector(
onLongPress: () {
Feedback.forLongPress(context);
Clipboard.setData(ClipboardData(text: widget.content.trim()));
},
child: child,
),
),
);
}
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: widget.icon,
title: Text(widget.title),
trailing: ExpandIcon(
onPressed: null,
isExpanded: _expanded,
padding: EdgeInsets.zero,
),
onTap: _toggleAnimation,
dense: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.only(left: 12),
),
SizeTransition(
sizeFactor: _animation,
axisAlignment: -1,
child: _monospacedContent(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12),
child: widget.monospaced
? Text(
widget.content.trim(),
style: const TextStyle(
fontFamily: "monospace",
height: kTextHeightNone,
),
)
: Text.rich(
Markdown(widget.content.trim()).toTextSpan(context),
style: TextStyle(
fontStyle: widget.italic ? FontStyle.italic : null,
color: widget.italic ? theme.disabledColor : null,
),
),
),
),
),
],
),
);
}
}