Updates for web

This commit is contained in:
JHubi1 2024-09-03 21:44:03 +02:00
parent a816cf8f65
commit b1c11ba21f
No known key found for this signature in database
GPG Key ID: 7BF82570CBBBD050
9 changed files with 238 additions and 48 deletions

View File

@ -15,6 +15,11 @@
"description": "Text displayed for settings option",
"context": "Visible in the side bar"
},
"optionInstallPwa": "Install Webapp",
"@optionInstallPwa": {
"description": "Text displayed for install PWA option",
"context": "Visible in the side bar"
},
"optionNoChatFound": "No chats found",
"@optionNoChatFound": {
"description": "Text displayed when no chats are found",
@ -160,6 +165,22 @@
"description": "Text displayed when the model name is invalid",
"context": "Visible in the model dialog"
},
"modelDialogAddAllowanceTitle": "Allow Proxy",
"modelDialogAddAllowanceDescription": "Ollama App must check if the entered model is valid. For that, we normally send a web request to the Ollama model list and check the status code, but because you're using the web client, we can't do that directly. Instead, the app will send the request to a different api, hosted by JHubi1, to check for us.\nThis is a one-time request and will only be sent when you add a new model.\nYour IP address will be sent with the request and might be stored for up to ten minutes to prevent spamming with potential harmful intentions.\nIf you accept, your selection will be remembered in the future; if not, nothing will be sent and the model won't be added.",
"@modelDialogAddAllowanceDescription": {
"description": "Description of the allow proxy dialog",
"context": "Visible in the model dialog"
},
"modelDialogAddAllowanceAllow": "Allow",
"@modelDialogAddAllowanceAllow": {
"description": "Text displayed for allow button, should be capitalized",
"context": "Visible in the model dialog"
},
"modelDialogAddAllowanceDeny": "Deny",
"@modelDialogAddAllowanceDeny": {
"description": "Text displayed for deny button, should be capitalized",
"context": "Visible in the model dialog"
},
"modelDialogAddAssuranceTitle": "Add {model}?",
"@modelDialogAddAssuranceTitle": {
"description": "Title of the add model assurance dialog",
@ -363,14 +384,14 @@
"description": "Text displayed when the host is being checked",
"context": "Visible in the settings view"
},
"settingsHostInvalid": "Issue: {type, select, url{Invalid URL} host{Invalid Host} timeout{Request Failed. Server issues} other{Request Failed}}",
"settingsHostInvalid": "Issue: {type, select, url{Invalid URL} host{Invalid Host} timeout{Request Failed. Server issues} ratelimit{Too many requests} other{Request Failed}}",
"@settingsHostInvalid": {
"description": "Text displayed when the host is invalid",
"context": "Visible in the settings view",
"placeholders": {
"type": {
"type": "String",
"description": "Type of the issue, either 'url' or 'other' (preferably 'host')"
"description": "Type of the issue, either 'url', 'host', 'timeout', 'ratelimit' or 'other' (preferably 'host')"
}
}
},

View File

@ -4,6 +4,7 @@ import 'dart:math';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
@ -35,6 +36,8 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:version/version.dart';
import 'package:pwa_install/pwa_install.dart' as pwa;
import 'package:universal_html/html.dart' as html;
// client configuration
@ -89,6 +92,10 @@ void Function(void Function())? setGlobalState;
void Function(void Function())? setMainAppState;
void main() {
pwa.PWAInstall().setup(installCallback: () {
debugPrint('APP INSTALLED!');
});
runApp(const App());
if (desktopFeature()) {
@ -195,7 +202,9 @@ class _MainAppState extends State<MainApp> {
left: desktopLayoutRequired(context) ? 17 : 12,
right: desktopLayoutRequired(context) ? 17 : 12);
return List.from([
desktopFeature() ? const SizedBox(height: 8) : const SizedBox.shrink(),
(desktopLayoutNotRequired(context) || kIsWeb)
? const SizedBox(height: 8)
: const SizedBox.shrink(),
desktopLayoutNotRequired(context)
? const SizedBox.shrink()
: (Padding(
@ -324,6 +333,47 @@ class _MainAppState extends State<MainApp> {
const SizedBox(width: 16),
])))))
: const SizedBox.shrink(),
(pwa.PWAInstall().installPromptEnabled &&
pwa.PWAInstall().launchMode == pwa.LaunchMode.browser)
? (Padding(
padding: padding,
child: InkWell(
enableFeedback: false,
customBorder: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(50))),
onTap: () {
selectionHaptic();
if (!desktopLayout(context)) {
Navigator.of(context).pop();
}
pwa.PWAInstall().onAppInstalled = () {
WidgetsBinding.instance.addPostFrameCallback((_) {
pwa.setLaunchModePWA();
setMainAppState!(() {});
});
};
pwa.PWAInstall().promptInstall_();
setState(() {});
},
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 16),
child: Row(children: [
Padding(
padding: const EdgeInsets.only(left: 16, right: 12),
child: desktopLayoutNotRequired(context)
? const Icon(Icons.install_desktop_rounded)
: const Icon(Icons.install_mobile_rounded)),
Expanded(
child: Text(
AppLocalizations.of(context)!.optionInstallPwa,
softWrap: false,
overflow: TextOverflow.fade,
style:
const TextStyle(fontWeight: FontWeight.w500)),
),
const SizedBox(width: 16),
])))))
: const SizedBox.shrink(),
(desktopLayoutNotRequired(context) &&
(!allowMultipleChats && !allowSettings))
? const SizedBox.shrink()
@ -437,7 +487,8 @@ class _MainAppState extends State<MainApp> {
}
});
},
onLongPress: desktopFeature()
onLongPress: (desktopFeature() ||
(kIsWeb && desktopLayoutNotRequired(context)))
? null
: () async {
selectionHaptic();
@ -486,7 +537,10 @@ class _MainAppState extends State<MainApp> {
AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
child:
((desktopFeature() &&
(((desktopFeature() ||
(kIsWeb &&
desktopLayoutNotRequired(
context))) &&
(hoveredChat ==
jsonDecode(item)["uuid"])) ||
!allowMultipleChats)
@ -643,7 +697,9 @@ class _MainAppState extends State<MainApp> {
))
: const SizedBox(width: 16)),
]))));
return desktopFeature() || !allowMultipleChats
return (desktopFeature() ||
(kIsWeb && desktopLayoutNotRequired(context))) ||
!allowMultipleChats
? child
: Dismissible(
key: Key(jsonDecode(item)["uuid"]),
@ -689,6 +745,10 @@ class _MainAppState extends State<MainApp> {
super.initState();
mainContext = context;
if (kIsWeb) {
html.querySelector(".loader")?.remove();
}
WidgetsBinding.instance.addPostFrameCallback(
(_) async {
if (prefs == null) {
@ -1529,7 +1589,7 @@ class _MainAppState extends State<MainApp> {
inputTextColor: themeLight().colorScheme.onSurface,
inputBorderRadius: BorderRadius.circular(32),
inputPadding: const EdgeInsets.all(16),
inputMargin: EdgeInsets.only(left: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature()) ? 8 : 6, right: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature()) ? 8 : 6, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature()) ? 0 : 8),
inputMargin: EdgeInsets.only(left: !desktopFeature(web: true) ? 8 : 6, right: !desktopFeature(web: true) ? 8 : 6, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature(web: true)) ? 0 : 8),
messageMaxWidth: (MediaQuery.of(context).size.width >= 1000)
? (MediaQuery.of(context).size.width >= 1600)
? (MediaQuery.of(context).size.width >= 2200)
@ -1567,9 +1627,9 @@ class _MainAppState extends State<MainApp> {
attachmentButtonMargin: EdgeInsets.zero,
inputBackgroundColor: themeDark().colorScheme.onSurface.withAlpha(40),
inputTextColor: themeDark().colorScheme.onSurface,
inputBorderRadius: const BorderRadius.all(Radius.circular(64)),
inputBorderRadius: BorderRadius.circular(32),
inputPadding: const EdgeInsets.all(16),
inputMargin: EdgeInsets.only(left: 8, right: 8, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature()) ? 0 : 8),
inputMargin: EdgeInsets.only(left: !desktopFeature(web: true) ? 8 : 6, right: !desktopFeature(web: true) ? 8 : 6, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature(web: true)) ? 0 : 8),
messageMaxWidth: (MediaQuery.of(context).size.width >= 1000)
? (MediaQuery.of(context).size.width >= 1600)
? (MediaQuery.of(context).size.width >= 2200)

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../main.dart';
import '../worker/haptic.dart';
@ -313,8 +312,19 @@ class _ScreenSettingsInterfaceState extends State<ScreenSettingsInterface> {
Icons.calculate_rounded,
null,
onlyDesktopDescription: false,
description:
"\n${((prefs!.getDouble("timeoutMultiplier") ?? 1) == 10) ? "${(prefs!.getDouble("timeoutMultiplier") ?? 1).round()}." : (prefs!.getDouble("timeoutMultiplier") ?? 1)} x 30s = ${((prefs!.getDouble("timeoutMultiplier") ?? 1) * 30).round()}s ${secondsBeautify((prefs!.getDouble("timeoutMultiplier") ?? 1) * 30)}"),
// making it complicated because web is weird and doesn't like to round numbers
description: "\n${() {
var value =
(prefs!.getDouble("timeoutMultiplier") ?? 1);
if (value == 10) {
return "${value.round()}.";
} else {
if (!value.toString().contains(".")) {
return "${value.toString()}.0";
}
return value;
}
}.call()} x 30s = ${((prefs!.getDouble("timeoutMultiplier") ?? 1) * 30).round()}s ${secondsBeautify((prefs!.getDouble("timeoutMultiplier") ?? 1) * 30)}"),
titleDivider(context: context),
toggle(
context,
@ -368,7 +378,7 @@ class _ScreenSettingsInterfaceState extends State<ScreenSettingsInterface> {
setState(() {});
}),
const SizedBox(height: 8),
!kIsWeb
(colorSchemeLight != null && colorSchemeDark != null)
? SegmentedButton(
segments: [
ButtonSegment(

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'desktop.dart';
@ -187,11 +188,8 @@ void setModel(BuildContext context, Function setState) {
scrollDirection: Axis.vertical,
child: Wrap(
spacing: desktopLayout(context) ? 10.0 : 5.0,
runSpacing: desktopFeature()
? desktopFeature()
? 10.0
: 5.0
: 0.0,
runSpacing:
desktopFeature(web: true) ? 10.0 : 0.0,
alignment: WrapAlignment.center,
children: List<Widget>.generate(
models.length,
@ -280,7 +278,7 @@ void setModel(BuildContext context, Function setState) {
])));
});
if (desktopFeature()) {
if (desktopLayoutNotRequired(context)) {
showDialog(
context: context,
builder: (context) {
@ -312,6 +310,7 @@ void addModel(BuildContext context, Function setState) async {
baseUrl: "$host/api");
bool canceled = false;
bool networkError = false;
bool ratelimitError = false;
bool alreadyExists = false;
final String invalidText =
AppLocalizations.of(context)!.modelDialogAddPromptInvalid;
@ -319,6 +318,8 @@ void addModel(BuildContext context, Function setState) async {
AppLocalizations.of(context)!.settingsHostInvalid("other");
final timeoutErrorText =
AppLocalizations.of(context)!.settingsHostInvalid("timeout");
final ratelimitErrorText =
AppLocalizations.of(context)!.settingsHostInvalid("ratelimit");
final alreadyExistsText =
AppLocalizations.of(context)!.modelDialogAddPromptAlreadyExists;
final downloadSuccessText =
@ -337,6 +338,7 @@ void addModel(BuildContext context, Function setState) async {
if (model == "") return false;
canceled = false;
networkError = false;
ratelimitError = false;
alreadyExists = false;
try {
var request = await client.listModels().timeout(Duration(
@ -353,11 +355,48 @@ void addModel(BuildContext context, Function setState) async {
networkError = true;
return false;
}
var endpoint = "https://ollama.com/library/";
if (kIsWeb) {
if (!(prefs!.getBool("allowWebProxy") ?? false)) {
bool returnValue = false;
await showDialog(
context: mainContext!,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!
.modelDialogAddAllowanceTitle),
content: SizedBox(
width: 640,
child: Text(AppLocalizations.of(context)!
.modelDialogAddAllowanceDescription),
),
actions: [
TextButton(
onPressed: () {
canceled = true;
Navigator.of(context).pop();
},
child: Text(AppLocalizations.of(context)!
.modelDialogAddAllowanceDeny)),
TextButton(
onPressed: () {
returnValue = true;
Navigator.of(context).pop();
},
child: Text(AppLocalizations.of(context)!
.modelDialogAddAllowanceAllow))
]);
});
if (!returnValue) return false;
prefs!.setBool("allowWebProxy", true);
}
endpoint = "https://end.jhubi1.com/ollama-proxy/";
}
http.Response response;
try {
response = await http
.get(Uri.parse("https://ollama.com/library/$model"))
.timeout(Duration(
response = await http.get(Uri.parse("$endpoint$model")).timeout(
Duration(
seconds: (10.0 * (prefs!.getDouble("timeoutMultiplier") ?? 1.0))
.round()));
} catch (_) {
@ -376,8 +415,11 @@ void addModel(BuildContext context, Function setState) async {
return AlertDialog(
title: Text(AppLocalizations.of(context)!
.modelDialogAddAssuranceTitle(model)),
content: Text(AppLocalizations.of(context)!
.modelDialogAddAssuranceDescription(model)),
content: SizedBox(
width: 640,
child: Text(AppLocalizations.of(context)!
.modelDialogAddAssuranceDescription(model)),
),
actions: [
TextButton(
onPressed: () {
@ -398,10 +440,14 @@ void addModel(BuildContext context, Function setState) async {
resetSystemNavigation(mainContext!);
return returnValue;
}
if (response.statusCode == 429) {
ratelimitError = true;
}
return false;
},
validatorErrorCallback: (content) {
if (networkError) return networkErrorText;
if (ratelimitError) return ratelimitErrorText;
if (alreadyExists) return alreadyExistsText;
if (canceled) return null;
return invalidText;
@ -774,8 +820,8 @@ Future<String> prompt(BuildContext context,
left: 16,
right: 16,
top: 16,
bottom: desktopFeature()
? 16
bottom: desktopFeature(web: true)
? 12
: MediaQuery.of(context).viewInsets.bottom),
width: double.infinity,
child: Column(

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../main.dart';
@ -11,29 +12,12 @@ void resetSystemNavigation(BuildContext context,
Color? statusBarColor,
Color? systemNavigationBarColor,
Duration? delay}) {
ColorScheme getColorScheme() {
final ColorScheme schemeLight = themeLight().colorScheme;
final ColorScheme schemeDark = themeDark().colorScheme;
if (themeMode() == ThemeMode.system) {
if (MediaQuery.of(context).platformBrightness == Brightness.light) {
return schemeLight;
} else {
return schemeDark;
}
} else {
if (themeMode() == ThemeMode.light) {
return schemeLight;
} else {
return schemeDark;
}
}
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (delay != null) {
await Future.delayed(delay);
}
color ??= getColorScheme().surface;
// ignore: use_build_context_synchronously
color ??= themeCurrent(context).colorScheme.surface;
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarIconBrightness:
(((statusBarColor != null) ? statusBarColor : color)!
@ -42,8 +26,10 @@ void resetSystemNavigation(BuildContext context,
? Brightness.dark
: Brightness.light,
statusBarColor:
(((statusBarColor != null) ? statusBarColor : color)!.value !=
getColorScheme().surface.value)
((((statusBarColor != null) ? statusBarColor : color)!.value !=
// ignore: use_build_context_synchronously
themeCurrent(context).colorScheme.surface.value) ||
kIsWeb)
? (statusBarColor != null)
? statusBarColor
: color
@ -56,7 +42,7 @@ void resetSystemNavigation(BuildContext context,
ThemeData themeModifier(ThemeData theme) {
return theme.copyWith(
// https://docs.flutter.dev/platform-integration/android/predictive-back#set-up-your-app
// https://docs.flutter.dev/platform-integration/android/predictive-back#set-up-your-app
pageTransitionsTheme: const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
@ -64,6 +50,22 @@ ThemeData themeModifier(ThemeData theme) {
));
}
ThemeData themeCurrent(BuildContext context) {
if (themeMode() == ThemeMode.system) {
if (MediaQuery.of(context).platformBrightness == Brightness.light) {
return themeLight();
} else {
return themeDark();
}
} else {
if (themeMode() == ThemeMode.light) {
return themeLight();
} else {
return themeDark();
}
}
}
ThemeData themeLight() {
if (!(prefs?.getBool("useDeviceTheme") ?? false) ||
colorSchemeLight == null) {

View File

@ -509,6 +509,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.19.0"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
description:
@ -733,6 +741,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pwa_install:
dependency: "direct main"
description:
name: pwa_install
sha256: "79a6c38e67db12da98489258ead7e025ce4860c9010cde226d0b53ad6d403fd9"
url: "https://pub.dev"
source: hosted
version: "0.0.5"
scroll_to_index:
dependency: "direct main"
description:

View File

@ -42,6 +42,7 @@ dependencies:
dynamic_color: ^1.7.0
volume_controller: ^2.0.7
universal_html: ^2.2.4
pwa_install: ^0.0.5
dev_dependencies:
flutter_test:

View File

@ -1,5 +1,6 @@
{
"de": [
"optionInstallPwa",
"deleteChat",
"renameChat",
"tooltipReset",
@ -7,6 +8,10 @@
"modelDialogAddPromptDescription",
"modelDialogAddPromptAlreadyExists",
"modelDialogAddPromptInvalid",
"modelDialogAddAllowanceTitle",
"modelDialogAddAllowanceDescription",
"modelDialogAddAllowanceAllow",
"modelDialogAddAllowanceDeny",
"modelDialogAddAssuranceTitle",
"modelDialogAddAssuranceDescription",
"modelDialogAddAssuranceAdd",
@ -41,6 +46,7 @@
],
"it": [
"optionInstallPwa",
"deleteChat",
"renameChat",
"tooltipReset",
@ -48,6 +54,10 @@
"modelDialogAddPromptDescription",
"modelDialogAddPromptAlreadyExists",
"modelDialogAddPromptInvalid",
"modelDialogAddAllowanceTitle",
"modelDialogAddAllowanceDescription",
"modelDialogAddAllowanceAllow",
"modelDialogAddAllowanceDeny",
"modelDialogAddAssuranceTitle",
"modelDialogAddAssuranceDescription",
"modelDialogAddAssuranceAdd",
@ -82,6 +92,7 @@
],
"tr": [
"optionInstallPwa",
"deleteChat",
"renameChat",
"tooltipReset",
@ -89,6 +100,10 @@
"modelDialogAddPromptDescription",
"modelDialogAddPromptAlreadyExists",
"modelDialogAddPromptInvalid",
"modelDialogAddAllowanceTitle",
"modelDialogAddAllowanceDescription",
"modelDialogAddAllowanceAllow",
"modelDialogAddAllowanceDeny",
"modelDialogAddAssuranceTitle",
"modelDialogAddAssuranceDescription",
"modelDialogAddAssuranceAdd",
@ -123,6 +138,7 @@
],
"zh": [
"optionInstallPwa",
"deleteChat",
"renameChat",
"tooltipReset",
@ -130,6 +146,10 @@
"modelDialogAddPromptDescription",
"modelDialogAddPromptAlreadyExists",
"modelDialogAddPromptInvalid",
"modelDialogAddAllowanceTitle",
"modelDialogAddAllowanceDescription",
"modelDialogAddAllowanceAllow",
"modelDialogAddAllowanceDeny",
"modelDialogAddAssuranceTitle",
"modelDialogAddAssuranceDescription",
"modelDialogAddAssuranceAdd",

View File

@ -7,6 +7,7 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A modern and easy-to-use client for Ollama">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
@ -91,6 +92,19 @@
}
}
</style>
<script>
let deferredPrompt;
window.addEventListener("beforeinstallprompt", (e) => { deferredPrompt = e; });
function promptInstall() { deferredPrompt.prompt(); }
window.addEventListener("appinstalled", () => { deferredPrompt = null; appInstalled(); });
function getLaunchMode() {
const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
if (deferredPrompt) hasPrompt();
if (document.referrer.startsWith("android-app://")) { appLaunchedAsTWA(); }
else if (navigator.standalone || isStandalone) { appLaunchedAsPWA(); }
else { window.appLaunchedInBrowser(); }
}
</script>
</head>
<body>