ollama-app/lib/main.dart

1606 lines
79 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:url_launcher/url_launcher.dart';
import 'screen_settings.dart';
import 'screen_welcome.dart';
import 'worker_setter.dart';
import 'package:shared_preferences/shared_preferences.dart';
// ignore: depend_on_referenced_packages
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:uuid/uuid.dart';
import 'package:image_picker/image_picker.dart';
import 'package:visibility_detector/visibility_detector.dart';
// import 'package:http/http.dart' as http;
import 'package:ollama_dart/ollama_dart.dart' as llama;
import 'package:dartx/dartx.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
// ignore: depend_on_referenced_packages
import 'package:markdown/markdown.dart' as md;
import 'package:bitsdojo_window/bitsdojo_window.dart';
// client configuration
// use host or not, if false dialog is shown
const useHost = false;
// host of ollama, must be accessible from the client, without trailing slash, will always be accepted as valid
const fixedHost = "http://example.com:11434";
// use model or not, if false selector is shown
const useModel = false;
// model name as string, must be valid ollama model!
const fixedModel = "gemma";
// recommended models, shown with as star in model selector
const recommendedModels = ["gemma", "llama3"];
// allow opening of settings
const allowSettings = true;
// allow multiple chats
const allowMultipleChats = true;
// client configuration end
SharedPreferences? prefs;
ThemeData? theme;
ThemeData? themeDark;
String? model;
String? host;
bool multimodal = false;
List<types.Message> messages = [];
String? chatUuid;
bool chatAllowed = true;
final user = types.User(id: const Uuid().v4());
final assistant = types.User(id: const Uuid().v4());
void main() {
runApp(const App());
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
doWhenWindowReady(() {
appWindow.minSize = const Size(600, 450);
appWindow.size = const Size(1200, 650);
appWindow.alignment = Alignment.center;
appWindow.show();
});
}
}
class App extends StatefulWidget {
const App({
super.key,
});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
@override
void initState() {
super.initState();
void load() async {
SharedPreferences.setPrefix("ollama.");
SharedPreferences tmp = await SharedPreferences.getInstance();
setState(() {
prefs = tmp;
});
}
load();
WidgetsBinding.instance.addPostFrameCallback(
(timeStamp) {
if (!(prefs?.getBool("useDeviceTheme") ?? false)) {
theme = ThemeData.from(
colorScheme: const ColorScheme(
brightness: Brightness.light,
primary: Colors.black,
onPrimary: Colors.white,
secondary: Colors.white,
onSecondary: Colors.black,
error: Colors.red,
onError: Colors.white,
surface: Colors.white,
onSurface: Colors.black));
themeDark = ThemeData.from(
colorScheme: const ColorScheme(
brightness: Brightness.dark,
primary: Colors.white,
onPrimary: Colors.black,
secondary: Colors.black,
onSecondary: Colors.white,
error: Colors.red,
onError: Colors.black,
surface: Colors.black,
onSurface: Colors.white));
setState(() {});
}
},
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: (deviceLocales, supportedLocales) {
if (deviceLocales != null) {
for (final locale in deviceLocales) {
var newLocale = Locale(locale.languageCode);
if (supportedLocales.contains(newLocale)) {
return locale;
}
}
}
return const Locale("en");
},
title: "Ollama",
theme: theme,
darkTheme: themeDark,
themeMode: ((prefs?.getString("brightness") ?? "system") == "system")
? ThemeMode.system
: ((prefs!.getString("brightness") == "dark")
? ThemeMode.dark
: ThemeMode.light),
home: const MainApp());
}
}
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
bool logoVisible = true;
bool menuVisible = false;
int tipId = Random().nextInt(5);
bool sendable = false;
List<Widget> sidebar(BuildContext context, Function setState) {
return List.from([
((Platform.isWindows || Platform.isLinux || Platform.isMacOS) &&
MediaQuery.of(context).size.width >= 1000)
? const SizedBox.shrink()
: (Padding(
padding: const EdgeInsets.only(left: 12, right: 12),
child: InkWell(
customBorder: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(50))),
onTap: () {},
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 16),
child: Row(children: [
const Padding(
padding: EdgeInsets.only(left: 16, right: 12),
child: ImageIcon(AssetImage("assets/logo512.png"))),
Expanded(
child: Text(AppLocalizations.of(context)!.appTitle,
softWrap: false,
overflow: TextOverflow.fade,
style:
const TextStyle(fontWeight: FontWeight.w500)),
),
const SizedBox(width: 16),
]))))),
((Platform.isWindows || Platform.isLinux || Platform.isMacOS) &&
MediaQuery.of(context).size.width >= 1000)
? const SizedBox.shrink()
: (!allowMultipleChats && !allowSettings)
? const SizedBox.shrink()
: const Divider(),
(allowMultipleChats)
? (Padding(
padding: const EdgeInsets.only(left: 12, right: 12),
child: InkWell(
customBorder: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(50))),
onTap: () {
HapticFeedback.selectionClick();
Navigator.of(context).pop();
if (!chatAllowed) return;
chatUuid = null;
messages = [];
setState(() {});
},
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 16),
child: Row(children: [
const Padding(
padding: EdgeInsets.only(left: 16, right: 12),
child: Icon(Icons.add_rounded)),
Expanded(
child: Text(
AppLocalizations.of(context)!.optionNewChat,
softWrap: false,
overflow: TextOverflow.fade,
style:
const TextStyle(fontWeight: FontWeight.w500)),
),
const SizedBox(width: 16),
])))))
: const SizedBox.shrink(),
(allowSettings)
? (Padding(
padding: const EdgeInsets.only(left: 12, right: 12),
child: InkWell(
customBorder: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(50))),
onTap: () {
HapticFeedback.selectionClick();
Navigator.of(context).pop();
setState(() {
logoVisible = false;
});
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ScreenSettings()));
},
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 16),
child: Row(children: [
const Padding(
padding: EdgeInsets.only(left: 16, right: 12),
child: Icon(Icons.dns_rounded)),
Expanded(
child: Text(
AppLocalizations.of(context)!.optionSettings,
softWrap: false,
overflow: TextOverflow.fade,
style:
const TextStyle(fontWeight: FontWeight.w500)),
),
const SizedBox(width: 16),
])))))
: const SizedBox.shrink(),
Divider(
color:
((Platform.isWindows || Platform.isLinux || Platform.isMacOS) &&
MediaQuery.of(context).size.width >= 1000)
? (Theme.of(context).brightness == Brightness.light)
? Colors.grey[400]
: Colors.grey[900]
: null),
((prefs?.getStringList("chats") ?? []).isNotEmpty)
? const SizedBox.shrink()
: (Padding(
padding: const EdgeInsets.only(left: 12, right: 12),
child: InkWell(
customBorder: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(50))),
onTap: () {
HapticFeedback.selectionClick();
},
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 16),
child: Row(children: [
const Padding(
padding: EdgeInsets.only(left: 16, right: 12),
child: Icon(Icons.question_mark_rounded,
color: Colors.grey)),
Expanded(
child: Text(
AppLocalizations.of(context)!.optionNoChatFound,
softWrap: false,
overflow: TextOverflow.fade,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey)),
),
const SizedBox(width: 16),
]))))),
Builder(builder: (context) {
String tip = (tipId == 0)
? AppLocalizations.of(context)!.tip0
: (tipId == 1)
? AppLocalizations.of(context)!.tip1
: (tipId == 2)
? AppLocalizations.of(context)!.tip2
: (tipId == 3)
? AppLocalizations.of(context)!.tip3
: AppLocalizations.of(context)!.tip4;
return (!(prefs?.getBool("tips") ?? true) ||
(prefs?.getStringList("chats") ?? []).isNotEmpty ||
!allowSettings)
? const SizedBox.shrink()
: (Padding(
padding: const EdgeInsets.only(left: 12, right: 12),
child: InkWell(
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
enableFeedback: false,
onTap: () {
HapticFeedback.selectionClick();
setState(() {
tipId = Random().nextInt(5);
});
},
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 16),
child: Row(children: [
const Padding(
padding: EdgeInsets.only(left: 16, right: 12),
child: Icon(Icons.tips_and_updates_rounded,
color: Colors.grey)),
Expanded(
child: Text(
AppLocalizations.of(context)!.tipPrefix + tip,
softWrap: true,
maxLines: 3,
overflow: TextOverflow.fade,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey)),
),
const SizedBox(width: 16),
])),
)));
}),
])
..addAll((prefs?.getStringList("chats") ?? []).map((item) {
return Dismissible(
key: Key(jsonDecode(item)["uuid"]),
direction: (chatAllowed)
? DismissDirection.startToEnd
: DismissDirection.none,
confirmDismiss: (direction) async {
bool returnValue = false;
if (!chatAllowed) return false;
if (prefs!.getBool("askBeforeDeletion") ?? false) {
await showDialog(
context: context,
builder: (context) {
return StatefulBuilder(builder: (context, setLocalState) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!
.deleteDialogTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context)!
.deleteDialogDescription),
]),
actions: [
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
Navigator.of(context).pop();
returnValue = false;
},
child: Text(AppLocalizations.of(context)!
.deleteDialogCancel)),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
Navigator.of(context).pop();
returnValue = true;
},
child: Text(AppLocalizations.of(context)!
.deleteDialogDelete))
]);
});
});
} else {
returnValue = true;
}
return returnValue;
},
onDismissed: (direction) {
HapticFeedback.selectionClick();
for (var i = 0;
i < (prefs!.getStringList("chats") ?? []).length;
i++) {
if (jsonDecode(
(prefs!.getStringList("chats") ?? [])[i])["uuid"] ==
jsonDecode(item)["uuid"]) {
List<String> tmp = prefs!.getStringList("chats")!;
tmp.removeAt(i);
prefs!.setStringList("chats", tmp);
break;
}
}
if (chatUuid == jsonDecode(item)["uuid"]) {
messages = [];
chatUuid = null;
Navigator.of(context).pop();
}
setState(() {});
},
child: Padding(
padding: const EdgeInsets.only(left: 12, right: 12),
child: InkWell(
customBorder: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(50))),
onTap: () {
HapticFeedback.selectionClick();
Navigator.of(context).pop();
if (!chatAllowed) return;
loadChat(jsonDecode(item)["uuid"], setState);
chatUuid = jsonDecode(item)["uuid"];
},
onLongPress: () async {
HapticFeedback.selectionClick();
if (!chatAllowed) return;
if (!allowSettings) return;
String oldTitle = jsonDecode(item)["title"];
var newTitle = await prompt(context,
title:
AppLocalizations.of(context)!.dialogEnterNewTitle,
value: oldTitle,
uuid: jsonDecode(item)["uuid"]);
var tmp = (prefs!.getStringList("chats") ?? []);
for (var i = 0; i < tmp.length; i++) {
if (jsonDecode((prefs!.getStringList("chats") ??
[])[i])["uuid"] ==
jsonDecode(item)["uuid"]) {
var tmp2 = jsonDecode(tmp[i]);
tmp2["title"] = newTitle;
tmp[i] = jsonEncode(tmp2);
break;
}
}
prefs!.setStringList("chats", tmp);
setState(() {});
},
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 16),
child: Row(children: [
Padding(
padding:
const EdgeInsets.only(left: 16, right: 16),
child: Icon((chatUuid == jsonDecode(item)["uuid"])
? Icons.location_on_rounded
: Icons.restore_rounded)),
Expanded(
child: Text(jsonDecode(item)["title"],
softWrap: false,
overflow: TextOverflow.fade,
style: const TextStyle(
fontWeight: FontWeight.w500)),
),
const SizedBox(width: 16),
])))));
}).toList());
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) async {
if (prefs == null) {
await Future.doWhile(
() => Future.delayed(const Duration(milliseconds: 1)).then((_) {
return prefs == null;
}));
}
void setBrightness() {
WidgetsBinding
.instance.platformDispatcher.onPlatformBrightnessChanged = () {
// invert colors used, because brightness not updated yet
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
systemNavigationBarColor:
(prefs!.getString("brightness") ?? "system") == "system"
? ((MediaQuery.of(context).platformBrightness ==
Brightness.light)
? (themeDark ?? ThemeData.dark())
.colorScheme
.surface
: (theme ?? ThemeData()).colorScheme.surface)
: (prefs!.getString("brightness") == "dark"
? (themeDark ?? ThemeData()).colorScheme.surface
: (theme ?? ThemeData.dark()).colorScheme.surface),
systemNavigationBarIconBrightness:
(((prefs!.getString("brightness") ?? "system") ==
"system" &&
MediaQuery.of(context).platformBrightness ==
Brightness.dark) ||
prefs!.getString("brightness") == "light")
? Brightness.dark
: Brightness.light));
};
// brightness changed function not run at first startup
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
systemNavigationBarColor:
(prefs!.getString("brightness") ?? "system") == "system"
? ((MediaQuery.of(context).platformBrightness ==
Brightness.light)
? (theme ?? ThemeData.dark()).colorScheme.surface
: (themeDark ?? ThemeData()).colorScheme.surface)
: (prefs!.getString("brightness") == "dark"
? (themeDark ?? ThemeData()).colorScheme.surface
: (theme ?? ThemeData.dark()).colorScheme.surface),
systemNavigationBarIconBrightness:
(((prefs!.getString("brightness") ?? "system") == "system" &&
MediaQuery.of(context).platformBrightness ==
Brightness.light) ||
prefs!.getString("brightness") == "light")
? Brightness.dark
: Brightness.light));
}
setBrightness();
// prefs!.remove("welcomeFinished");
if (!(prefs!.getBool("welcomeFinished") ?? false) && allowSettings) {
// ignore: use_build_context_synchronously
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const ScreenWelcome()));
return;
}
if (!(allowSettings || useHost)) {
showDialog(
// ignore: use_build_context_synchronously
context: context,
builder: (context) {
return const PopScope(
canPop: false,
child: Dialog.fullscreen(
backgroundColor: Colors.black,
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
"*Build Error:*\n\nuseHost: $useHost\nallowSettings: $allowSettings\n\nYou created this build? One of them must be set to true or the app is not functional!\n\nYou received this build by someone else? Please contact them and report the issue.",
style: TextStyle(color: Colors.red)))));
});
}
if (!allowMultipleChats &&
(prefs!.getStringList("chats") ?? []).isNotEmpty) {
chatUuid =
jsonDecode((prefs!.getStringList("chats") ?? [])[0])["uuid"];
loadChat(chatUuid!, setState);
}
setState(() {
model = useModel ? fixedModel : prefs!.getString("model");
chatAllowed = !(model == null);
multimodal = prefs?.getBool("multimodal") ?? false;
host = useHost ? fixedHost : prefs?.getString("host");
});
if (host == null) {
// ignore: use_build_context_synchronously
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
// ignore: use_build_context_synchronously
content: Text(AppLocalizations.of(context)!.noHostSelected),
showCloseIcon: true));
}
},
);
}
@override
Widget build(BuildContext context) {
Widget selector = InkWell(
onTap: () {
if (host == null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.noHostSelected),
showCloseIcon: true));
return;
}
setModel(context, setState);
},
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
enableFeedback: false,
child: SizedBox(
height: 200,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
(model ??
AppLocalizations.of(context)!.noSelectedModel)
.split(":")[0],
overflow: TextOverflow.fade,
style: const TextStyle(
fontFamily: "monospace", fontSize: 16))),
useModel
? const SizedBox.shrink()
: const Icon(Icons.expand_more_rounded)
])));
return WindowBorder(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
title: Row(
children: [
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)
? SizedBox(width: 85, height: 200, child: MoveWindow())
: const SizedBox.shrink(),
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)
? Expanded(
child: SizedBox(height: 200, child: MoveWindow()))
: const SizedBox.shrink(),
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)
? selector
: Expanded(child: selector),
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)
? Expanded(
child: SizedBox(height: 200, child: MoveWindow()))
: const SizedBox.shrink(),
],
),
actions: (Platform.isWindows ||
Platform.isLinux ||
Platform.isMacOS)
? [
SizedBox(
height: 200,
child: WindowTitleBarBox(
child: Row(
children: [
// Expanded(child: MoveWindow()),
SizedBox(
height: 200,
child: MinimizeWindowButton(
animate: true,
colors: WindowButtonColors(
iconNormal: Theme.of(context)
.colorScheme
.primary))),
SizedBox(
height: 72,
child: MaximizeWindowButton(
animate: true,
colors: WindowButtonColors(
iconNormal: Theme.of(context)
.colorScheme
.primary))),
SizedBox(
height: 72,
child: CloseWindowButton(
animate: true,
colors: WindowButtonColors(
iconNormal: Theme.of(context)
.colorScheme
.primary))),
],
)))
]
: [
const SizedBox(width: 4),
IconButton(
onPressed: () {
HapticFeedback.selectionClick();
if (!chatAllowed) return;
if (prefs!.getBool("askBeforeDeletion") ??
// ignore: dead_code
false && messages.isNotEmpty) {
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setLocalState) {
return AlertDialog(
title: Text(
AppLocalizations.of(context)!
.deleteDialogTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(
context)!
.deleteDialogDescription),
]),
actions: [
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
Navigator.of(context).pop();
},
child: Text(AppLocalizations.of(
context)!
.deleteDialogCancel)),
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
Navigator.of(context).pop();
for (var i = 0;
i <
(prefs!.getStringList(
"chats") ??
[])
.length;
i++) {
if (jsonDecode((prefs!
.getStringList(
"chats") ??
[])[i])["uuid"] ==
chatUuid) {
List<String> tmp = prefs!
.getStringList(
"chats")!;
tmp.removeAt(i);
prefs!.setStringList(
"chats", tmp);
break;
}
}
messages = [];
chatUuid = null;
setState(() {});
},
child: Text(AppLocalizations.of(
context)!
.deleteDialogDelete))
]);
});
});
} else {
for (var i = 0;
i <
(prefs!.getStringList("chats") ?? [])
.length;
i++) {
if (jsonDecode((prefs!.getStringList("chats") ??
[])[i])["uuid"] ==
chatUuid) {
List<String> tmp =
prefs!.getStringList("chats")!;
tmp.removeAt(i);
prefs!.setStringList("chats", tmp);
break;
}
}
messages = [];
chatUuid = null;
}
setState(() {});
},
icon: const Icon(Icons.restart_alt_rounded))
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: (!chatAllowed && model != null)
? const LinearProgressIndicator()
: const SizedBox.shrink()),
leading: ((Platform.isWindows ||
Platform.isLinux ||
Platform.isMacOS) &&
MediaQuery.of(context).size.width >= 1000)
? const SizedBox()
: null),
body: Row(
children: [
((Platform.isWindows || Platform.isLinux || Platform.isMacOS) &&
MediaQuery.of(context).size.width >= 1000)
? SizedBox(
width: 304,
height: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: VisibilityDetector(
key: const Key("menuVisible"),
onVisibilityChanged: (VisibilityInfo info) {
menuVisible = info.visibleFraction > 0;
try {
setState(() {});
} catch (_) {}
},
child: AnimatedOpacity(
opacity: menuVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: ListView(
children: sidebar(context, setState))))))
: const SizedBox.shrink(),
((Platform.isWindows || Platform.isLinux || Platform.isMacOS) &&
MediaQuery.of(context).size.width >= 1000)
? VerticalDivider(
width: 2,
color: (Theme.of(context).brightness == Brightness.light)
? Colors.grey[400]
: Colors.grey[900])
: const SizedBox.shrink(),
Expanded(
child: Chat(
messages: messages,
textMessageBuilder: (p0,
{required messageWidth, required showName}) {
var white = const TextStyle(color: Colors.white);
return Padding(
padding: const EdgeInsets.only(
left: 20, right: 23, top: 17, bottom: 17),
child: MarkdownBody(
data: p0.text,
onTapLink: (text, href, title) async {
HapticFeedback.selectionClick();
try {
var url = Uri.parse(href!);
if (await canLaunchUrl(url)) {
launchUrl(
mode: LaunchMode.inAppBrowserView,
url);
} else {
throw Exception();
}
} catch (_) {
// ignore: use_build_context_synchronously
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
// ignore: use_build_context_synchronously
AppLocalizations.of(context)!
.settingsHostInvalid(
"url")),
showCloseIcon: true));
}
},
extensionSet: md.ExtensionSet(
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
<md.InlineSyntax>[
md.EmojiSyntax(),
...md.ExtensionSet.gitHubFlavored
.inlineSyntaxes
],
),
imageBuilder: (uri, title, alt) {
if (uri.isAbsolute) {
return Image.network(uri.toString(),
errorBuilder:
(context, error, stackTrace) {
return InkWell(
onTap: () {
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(
context)!
.notAValidImage),
showCloseIcon: true));
},
child: Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(8),
color: Theme.of(context)
.brightness ==
Brightness.light
? Colors.white
: Colors.black),
padding: const EdgeInsets.only(
left: 100,
right: 100,
top: 32),
child: const Image(
image: AssetImage(
"assets/logo512error.png"))));
});
} else {
return InkWell(
onTap: () {
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(
context)!
.notAValidImage),
showCloseIcon: true));
},
child: Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(8),
color: Theme.of(context)
.brightness ==
Brightness.light
? Colors.white
: Colors.black),
padding: const EdgeInsets.only(
left: 100, right: 100, top: 32),
child: const Image(
image: AssetImage(
"assets/logo512error.png"))));
}
},
styleSheet: (p0.author == user)
? MarkdownStyleSheet(
p: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
blockquoteDecoration: BoxDecoration(
color: Colors.grey[800],
borderRadius:
BorderRadius.circular(8),
),
code: const TextStyle(
color: Colors.black,
backgroundColor: Colors.white),
codeblockDecoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.circular(8)),
h1: white,
h2: white,
h3: white,
h4: white,
h5: white,
h6: white,
listBullet: white,
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Colors.grey[800]!,
width: 1))),
tableBorder: TableBorder.all(
color: Colors.white),
tableBody: white)
: (Theme.of(context).brightness ==
Brightness.light)
? MarkdownStyleSheet(
p: const TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w500),
blockquoteDecoration: BoxDecoration(
color: Colors.grey[200],
borderRadius:
BorderRadius.circular(8),
),
code: const TextStyle(
color: Colors.white,
backgroundColor: Colors.black),
codeblockDecoration: BoxDecoration(
color: Colors.black,
borderRadius:
BorderRadius.circular(8)),
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
color:
Colors.grey[200]!,
width: 1))))
: MarkdownStyleSheet(
p: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
blockquoteDecoration: BoxDecoration(
color: Colors.grey[800]!,
borderRadius:
BorderRadius.circular(8),
),
code: const TextStyle(
color: Colors.black,
backgroundColor: Colors.white),
codeblockDecoration:
BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
horizontalRuleDecoration: BoxDecoration(border: Border(top: BorderSide(color: Colors.grey[200]!, width: 1))))));
},
imageMessageBuilder: (p0, {required messageWidth}) {
return SizedBox(
width: ((Platform.isWindows ||
Platform.isLinux ||
Platform.isMacOS) &&
MediaQuery.of(context).size.width >= 1000)
? 360.0
: 160.0,
child:
MarkdownBody(data: "![${p0.name}](${p0.uri})"));
},
disableImageGallery: true,
// keyboardDismissBehavior:
// ScrollViewKeyboardDismissBehavior.onDrag,
emptyState: Center(
child: VisibilityDetector(
key: const Key("logoVisible"),
onVisibilityChanged: (VisibilityInfo info) {
logoVisible = info.visibleFraction > 0;
try {
setState(() {});
} catch (_) {}
},
child: AnimatedOpacity(
opacity: logoVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: const ImageIcon(
AssetImage("assets/logo512.png"),
size: 44)))),
onSendPressed: (p0) async {
HapticFeedback.selectionClick();
setState(() {
sendable = false;
});
if (host == null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(context)!.noHostSelected),
showCloseIcon: true));
return;
}
if (!chatAllowed || model == null) {
if (model == null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!
.noModelSelected),
showCloseIcon: true));
}
return;
}
bool newChat = false;
if (chatUuid == null) {
newChat = true;
chatUuid = const Uuid().v4();
prefs!.setStringList(
"chats",
(prefs!.getStringList("chats") ?? []).append([
jsonEncode({
"title": AppLocalizations.of(context)!
.newChatTitle,
"uuid": chatUuid,
"messages": []
})
]).toList());
}
var system = prefs?.getString("system") ??
"You are a helpful assistant";
if (prefs!.getBool("noMarkdown") ?? false) {
system +=
" You must not use markdown or any other formatting language in any way!";
}
List<llama.Message> history = [
llama.Message(
role: llama.MessageRole.system, content: system)
];
List<String> images = [];
for (var i = 0; i < messages.length; i++) {
if (jsonDecode(jsonEncode(messages[i]))["text"] !=
null) {
history.add(llama.Message(
role: (messages[i].author.id == user.id)
? llama.MessageRole.user
: llama.MessageRole.system,
content:
jsonDecode(jsonEncode(messages[i]))["text"],
images: (images.isNotEmpty) ? images : null));
} else {
var uri = jsonDecode(jsonEncode(messages[i]))["uri"]
as String;
String content = (uri
.startsWith("data:image/png;base64,"))
? uri.removePrefix("data:image/png;base64,")
: base64.encode(await File(uri).readAsBytes());
uri = uri.removePrefix("data:image/png;base64,");
images.add(content);
}
}
history.add(llama.Message(
role: llama.MessageRole.user,
content: p0.text.trim(),
images: (images.isNotEmpty) ? images : null));
messages.insert(
0,
types.TextMessage(
author: user,
id: const Uuid().v4(),
text: p0.text.trim()));
saveChat(chatUuid!, setState);
setState(() {});
chatAllowed = false;
String newId = const Uuid().v4();
llama.OllamaClient client = llama.OllamaClient(
headers: (jsonDecode(
prefs!.getString("hostHeaders") ?? "{}")
as Map)
.cast<String, String>(),
baseUrl: "$host/api");
try {
if ((prefs!.getString("requestType") ?? "stream") ==
"stream") {
final stream = client
.generateChatCompletionStream(
request: llama.GenerateChatCompletionRequest(
model: model!,
messages: history,
keepAlive: 1,
),
)
.timeout(const Duration(seconds: 15));
String text = "";
await for (final res in stream) {
text += (res.message?.content ?? "");
for (var i = 0; i < messages.length; i++) {
if (messages[i].id == newId) {
messages.removeAt(i);
break;
}
}
if (chatAllowed) return;
if (text.trim() == "") {
throw Exception();
}
messages.insert(
0,
types.TextMessage(
author: assistant,
id: newId,
text: text));
setState(() {});
HapticFeedback.lightImpact();
}
} else {
llama.GenerateChatCompletionResponse request;
request = await client
.generateChatCompletion(
request: llama.GenerateChatCompletionRequest(
model: model!,
messages: history,
keepAlive: 1,
),
)
.timeout(const Duration(seconds: 15));
if (chatAllowed) return;
if (request.message!.content.trim() == "") {
throw Exception();
}
messages.insert(
0,
types.TextMessage(
author: assistant,
id: newId,
text: request.message!.content));
setState(() {});
HapticFeedback.lightImpact();
}
} catch (e) {
for (var i = 0; i < messages.length; i++) {
if (messages[i].id == newId) {
messages.removeAt(i);
break;
}
}
setState(() {
chatAllowed = true;
messages.removeAt(0);
if (messages.isEmpty) {
var tmp = (prefs!.getStringList("chats") ?? []);
chatUuid = null;
for (var i = 0; i < tmp.length; i++) {
if (jsonDecode((prefs!.getStringList("chats") ??
[])[i])["uuid"] ==
chatUuid) {
tmp.removeAt(i);
prefs!.setStringList("chats", tmp);
break;
}
}
}
});
// ignore: use_build_context_synchronously
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
// ignore: use_build_context_synchronously
content: Text(AppLocalizations.of(context)!
.settingsHostInvalid("timeout")),
showCloseIcon: true));
return;
}
saveChat(chatUuid!, setState);
if (newChat &&
(prefs!.getBool("generateTitles") ?? true)) {
void setTitle() async {
List<Map<String, String>> history = [];
for (var i = 0; i < messages.length; i++) {
if (jsonDecode(jsonEncode(messages[i]))["text"] ==
null) {
continue;
}
history.add({
"role": (messages[i].author == user)
? "user"
: "assistant",
"content":
jsonDecode(jsonEncode(messages[i]))["text"]
});
}
history = history.reversed.toList();
try {
final generated = await client.generateCompletion(
request: llama.GenerateCompletionRequest(
model: model!,
prompt:
"You must not use markdown or any other formatting language! Create a short title for the subject of the conversation described in the following json object. It is not allowed to be too general; no 'Assistance', 'Help' or similar!\n\n```json\n${jsonEncode(history)}\n```",
),
);
var title = generated.response!
.replaceAll("*", "")
.replaceAll("_", "")
.trim();
var tmp = (prefs!.getStringList("chats") ?? []);
for (var i = 0; i < tmp.length; i++) {
if (jsonDecode((prefs!.getStringList("chats") ??
[])[i])["uuid"] ==
chatUuid) {
var tmp2 = jsonDecode(tmp[i]);
tmp2["title"] = title;
tmp[i] = jsonEncode(tmp2);
break;
}
}
prefs!.setStringList("chats", tmp);
} catch (_) {}
setState(() {});
}
setTitle();
}
setState(() {});
chatAllowed = true;
},
onMessageDoubleTap: (context, p1) {
HapticFeedback.selectionClick();
if (!chatAllowed) return;
if (p1.author == assistant) return;
for (var i = 0; i < messages.length; i++) {
if (messages[i].id == p1.id) {
List messageList =
(jsonDecode(jsonEncode(messages)) as List)
.reversed
.toList();
bool found = false;
List index = [];
for (var j = 0; j < messageList.length; j++) {
if (messageList[j]["id"] == p1.id) {
found = true;
}
if (found) {
index.add(messageList[j]["id"]);
}
}
for (var j = 0; j < index.length; j++) {
for (var k = 0; k < messages.length; k++) {
if (messages[k].id == index[j]) {
messages.removeAt(k);
}
}
}
break;
}
}
saveChat(chatUuid!, setState);
setState(() {});
},
onMessageLongPress: (context, p1) async {
HapticFeedback.selectionClick();
if (!(prefs!.getBool("enableEditing") ?? false)) {
return;
}
var index = -1;
if (!chatAllowed) return;
for (var i = 0; i < messages.length; i++) {
if (messages[i].id == p1.id) {
index = i;
break;
}
}
var text = (messages[index] as types.TextMessage).text;
var input = await prompt(
context,
title: AppLocalizations.of(context)!
.dialogEditMessageTitle,
value: text,
keyboard: TextInputType.multiline,
maxLines: (text.length >= 100)
? 10
: ((text.length >= 50) ? 5 : 3),
);
if (input == "") return;
messages[index] = types.TextMessage(
author: p1.author,
createdAt: p1.createdAt,
id: p1.id,
text: input,
);
setState(() {});
},
onAttachmentPressed: (!multimodal)
? null
: () {
HapticFeedback.selectionClick();
if (!chatAllowed || model == null) return;
if (Platform.isWindows ||
Platform.isLinux ||
Platform.isMacOS) {
HapticFeedback.selectionClick();
FilePicker.platform
.pickFiles(type: FileType.image)
.then((value) async {
if (value == null) return;
if (!multimodal) return;
var encoded = base64.encode(
await File(value.files.first.path!)
.readAsBytes());
messages.insert(
0,
types.ImageMessage(
author: user,
id: const Uuid().v4(),
name: value.files.first.name,
size: value.files.first.size,
uri:
"data:image/png;base64,$encoded"));
setState(() {});
HapticFeedback.selectionClick();
});
return;
}
showModalBottomSheet(
context: context,
builder: (context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.only(
left: 16, right: 16, top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
HapticFeedback
.selectionClick();
Navigator.of(context)
.pop();
final result =
await ImagePicker()
.pickImage(
source: ImageSource
.camera,
);
if (result == null) {
return;
}
final bytes =
await result
.readAsBytes();
final image =
await decodeImageFromList(
bytes);
final message =
types.ImageMessage(
author: user,
createdAt: DateTime
.now()
.millisecondsSinceEpoch,
height: image.height
.toDouble(),
id: const Uuid().v4(),
name: result.name,
size: bytes.length,
uri: result.path,
width: image.width
.toDouble(),
);
messages.insert(
0, message);
setState(() {});
HapticFeedback
.selectionClick();
},
icon: const Icon(Icons
.photo_camera_rounded),
label: Text(
AppLocalizations.of(
context)!
.takeImage))),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
HapticFeedback
.selectionClick();
Navigator.of(context)
.pop();
final result =
await ImagePicker()
.pickImage(
source: ImageSource
.gallery,
);
if (result == null) {
return;
}
final bytes =
await result
.readAsBytes();
final image =
await decodeImageFromList(
bytes);
final message =
types.ImageMessage(
author: user,
createdAt: DateTime
.now()
.millisecondsSinceEpoch,
height: image.height
.toDouble(),
id: const Uuid().v4(),
name: result.name,
size: bytes.length,
uri: result.path,
width: image.width
.toDouble(),
);
messages.insert(
0, message);
setState(() {});
HapticFeedback
.selectionClick();
},
icon: const Icon(
Icons.image_rounded),
label: Text(
AppLocalizations.of(
context)!
.uploadImage)))
]));
});
},
l10n: ChatL10nEn(
inputPlaceholder: AppLocalizations.of(context)!
.messageInputPlaceholder),
inputOptions: InputOptions(
keyboardType: TextInputType.multiline,
onTextChanged: (p0) {
setState(() {
sendable = p0.trim().isNotEmpty;
});
},
sendButtonVisibilityMode: (sendable)
? SendButtonVisibilityMode.always
: SendButtonVisibilityMode.hidden),
user: user,
hideBackgroundOnEmojiMessages: false,
theme: (Theme.of(context).brightness == Brightness.light)
? DefaultChatTheme(
backgroundColor:
(theme ?? ThemeData()).colorScheme.surface,
primaryColor:
(theme ?? ThemeData()).colorScheme.primary,
attachmentButtonIcon:
const Icon(Icons.add_a_photo_rounded),
sendButtonIcon: const SizedBox(
height: 24,
child: CircleAvatar(
backgroundColor: Colors.black,
radius: 12,
child: Icon(Icons.arrow_upward_rounded)),
),
sendButtonMargin: EdgeInsets.zero,
inputBackgroundColor: (theme ?? ThemeData())
.colorScheme
.onSurface
.withAlpha(10),
inputTextColor:
(theme ?? ThemeData()).colorScheme.onSurface,
inputBorderRadius:
const BorderRadius.all(Radius.circular(64)),
inputPadding: const EdgeInsets.all(16),
inputMargin: EdgeInsets.only(
left: 8,
right: 8,
bottom: (MediaQuery.of(context)
.viewInsets
.bottom ==
0.0 &&
!(Platform.isWindows ||
Platform.isLinux ||
Platform.isMacOS))
? 0
: 8),
messageMaxWidth: (MediaQuery.of(context).size.width >=
1000)
? (MediaQuery.of(context).size.width >= 1600)
? (MediaQuery.of(context).size.width >= 2200)
? 1900
: 1300
: 700
: 440)
: DarkChatTheme(
backgroundColor: (themeDark ?? ThemeData.dark()).colorScheme.surface,
primaryColor: (themeDark ?? ThemeData.dark()).colorScheme.primary.withAlpha(40),
secondaryColor: (themeDark ?? ThemeData.dark()).colorScheme.primary.withAlpha(20),
attachmentButtonIcon: const Icon(Icons.add_a_photo_rounded),
sendButtonIcon: const Icon(Icons.send_rounded),
inputBackgroundColor: (themeDark ?? ThemeData()).colorScheme.onSurface.withAlpha(40),
inputTextColor: (themeDark ?? ThemeData()).colorScheme.onSurface,
inputBorderRadius: const BorderRadius.all(Radius.circular(64)),
inputPadding: const EdgeInsets.all(16),
inputMargin: EdgeInsets.only(left: 8, right: 8, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) ? 0 : 8),
messageMaxWidth: (MediaQuery.of(context).size.width >= 1000)
? (MediaQuery.of(context).size.width >= 1600)
? (MediaQuery.of(context).size.width >= 2200)
? 1900
: 1300
: 700
: 440))),
],
),
drawer: Builder(builder: (context) {
if ((Platform.isWindows || Platform.isLinux || Platform.isMacOS) &&
MediaQuery.of(context).size.width >= 1000) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
}
return NavigationDrawer(
onDestinationSelected: (value) {
if (value == 1) {
} else if (value == 2) {}
},
selectedIndex: 1,
children: sidebar(context, setState));
})),
);
}
}