Critical chat fix, added voice mode

This commit is contained in:
JHubi1 2024-06-13 19:17:02 +02:00
parent 93818cfbad
commit baf654be40
No known key found for this signature in database
GPG Key ID: 7BF82570CBBBD050
17 changed files with 1454 additions and 303 deletions

View File

@ -50,6 +50,20 @@
<data android:scheme="https"/>
</intent>
<!-- check if url is valid end -->
<!-- voice mode -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<!-- voice mode end-->
</queries>
<uses-permission android:name="android.permission.INTERNET" />
<!-- voice mode -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- voice mode end -->
</manifest>

View File

@ -20,7 +20,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
id "org.jetbrains.kotlin.android" version "2.0.0" apply false
}
include ":app"

View File

@ -145,6 +145,11 @@
"description": "Title of the interface settings section",
"context": "Visible in the settings view"
},
"settingsTitleVoice": "Voice",
"@settingsTitleVoice": {
"description": "Title of the voice settings section. Do not translate if not required!",
"context": "Visible in the settings view"
},
"settingsTitleExport": "Exportieren",
"@settingsTitleExport": {
"description": "Title of the export settings section",
@ -160,6 +165,36 @@
"description": "Text displayed when settings are saved automatically",
"context": "Visible in the settings view"
},
"settingsExperimentalAlpha": "alpha",
"@settingsExperimentalAlpha": {
"description": "Text displayed when a feature is in alpha",
"context": "Visible in the settings view"
},
"settingsExperimentalAlphaDescription": "Diese Funktion befindet sich im Alpha-Status und funktioniert möglicherweise nicht wie beabsichtigt oder erwartet.\nKritische Probleme und/oder dauerhafte kritische Schäden am Gerät und/oder den verwendeten Diensten können nicht ausgeschlossen werden.\nBenutzung auf eigene Gefahr. Keine Haftung seitens des App-Autors.",
"@settingsExperimentalAlphaDescription": {
"description": "Description of the alpha feature",
"context": "Visible in the settings view"
},
"settingsExperimentalAlphaFeature": "Alpha-Funktion, halte, um mehr zu erfahren",
"@settingsExperimentalAlphaFeature": {
"description": "Text displayed when a feature is in alpha",
"context": "Visible in the settings view"
},
"settingsExperimentalBeta": "beta",
"@settingsExperimentalBeta": {
"description": "Text displayed when a feature is in beta",
"context": "Visible in the settings view"
},
"settingsExperimentalBetaDescription": "Diese Funktion befindet sich im Beta-Test und funktioniert möglicherweise nicht wie beabsichtigt oder erwartet.\nWeniger schwerwiegende Probleme können auftreten oder auch nicht. Schäden sollten nicht kritisch sein.\nVerwendung auf eigene Gefahr.",
"@settingsExperimentalBetaDescription": {
"description": "Description of the beta feature",
"context": "Visible in the settings view"
},
"settingsExperimentalBetaFeature": "Beta-Funktion, halte, um mehr zu erfahren",
"@settingsExperimentalBetaFeature": {
"description": "Text displayed when a feature is in beta",
"context": "Visible in the settings view"
},
"settingsHost": "Host",
"@settingsHost": {
"description": "Text displayed as description for host input",
@ -333,6 +368,41 @@
"description": "Text displayed for cancel button, should be capitalized",
"context": "Visible in the settings view"
},
"settingsVoicePermissionLoading": "Lade Sprachberechtigungen ...",
"@settingsVoicePermissionLoading": {
"description": "Text displayed while loading voice permissions",
"context": "Visible in the settings view"
},
"settingsVoicePermissionNot": "Berechtigungen nicht erteilt",
"@settingsVoicePermissionNot": {
"description": "Text displayed when voice permissions are not granted",
"context": "Visible in the settings view"
},
"settingsVoiceNotSupported": "Voice-Modus wird nicht unterstützt",
"@settingsVoiceNotSupported": {
"description": "Text displayed when voice mode is not supported",
"context": "Visible in the settings view"
},
"settingsVoiceEnable": "Voice-Modus aktivieren",
"@settingsVoiceEnable": {
"description": "Text displayed as description for enable voice mode toggle",
"context": "Visible in the settings view"
},
"settingsVoiceNoLanguage": "Keine Sprache ausgewählt",
"@settingsVoiceNoLanguage": {
"description": "Text displayed when no language is selected",
"context": "Visible in the settings view"
},
"settingsVoiceLimitLanguage": "Auf gewählte Sprache beschränken",
"@settingsVoiceLimitLanguage": {
"description": "Text displayed as description for limit language toggle",
"context": "Visible in the settings view"
},
"settingsVoicePunctuation": "KI Satzzeichen aktivieren",
"@settingsVoicePunctuation": {
"description": "Text displayed as description for enable AI punctuation toggle",
"context": "Visible in the settings view"
},
"settingsExportChats": "Chats exportieren",
"@settingsExportChats": {
"description": "Text displayed as description for export chats button",
@ -378,11 +448,6 @@
"description": "Warning displayed for export and import options",
"context": "Visible in the settings view"
},
"settingsCheckForUpdates": "Beim Öffnen nach Updates suchen",
"@settingsCheckForUpdates": {
"description": "Text displayed as description for check for updates toggle",
"context": "Visible in the settings view"
},
"settingsUpdateCheck": "Nach Updates suchen",
"@settingsUpdateCheck": {
"description": "Text displayed as description for check for updates button",
@ -444,6 +509,11 @@
"description": "Text displayed for cancel button, should be capitalized",
"context": "Visible in the settings view"
},
"settingsCheckForUpdates": "Beim Öffnen nach Updates suchen",
"@settingsCheckForUpdates": {
"description": "Text displayed as description for check for updates toggle",
"context": "Visible in the settings view"
},
"settingsGithub": "GitHub",
"@settingsGithub": {
"description": "Text displayed as description for GitHub button",

View File

@ -145,6 +145,11 @@
"description": "Title of the interface settings section",
"context": "Visible in the settings view"
},
"settingsTitleVoice": "Voice",
"@settingsTitleVoice": {
"description": "Title of the voice settings section. Do not translate if not required!",
"context": "Visible in the settings view"
},
"settingsTitleExport": "Export",
"@settingsTitleExport": {
"description": "Title of the export settings section",
@ -160,6 +165,36 @@
"description": "Text displayed when settings are saved automatically",
"context": "Visible in the settings view"
},
"settingsExperimentalAlpha": "alpha",
"@settingsExperimentalAlpha": {
"description": "Text displayed when a feature is in alpha",
"context": "Visible in the settings view"
},
"settingsExperimentalAlphaDescription": "This feature is in alpha and may not work as intended or expected.\nCritical issues and/or permanent critical damage to device and/or used services cannot be ruled out.\nUse at your own risk. No liability on the part of the app author.",
"@settingsExperimentalAlphaDescription": {
"description": "Description of the alpha feature",
"context": "Visible in the settings view"
},
"settingsExperimentalAlphaFeature": "Alpha feature, hold to learn more",
"@settingsExperimentalAlphaFeature": {
"description": "Text displayed when a feature is in alpha",
"context": "Visible in the settings view"
},
"settingsExperimentalBeta": "beta",
"@settingsExperimentalBeta": {
"description": "Text displayed when a feature is in beta",
"context": "Visible in the settings view"
},
"settingsExperimentalBetaDescription": "This feature is in beta and may not work intended or expected.\nLess severe issues may or may not occur. Damage shouldn't be critical.\nUse at your own risk.",
"@settingsExperimentalBetaDescription": {
"description": "Description of the beta feature",
"context": "Visible in the settings view"
},
"settingsExperimentalBetaFeature": "Beta feature, hold to learn more",
"@settingsExperimentalBetaFeature": {
"description": "Text displayed when a feature is in beta",
"context": "Visible in the settings view"
},
"settingsHost": "Host",
"@settingsHost": {
"description": "Text displayed as description for host input",
@ -333,6 +368,41 @@
"description": "Text displayed for cancel button, should be capitalized",
"context": "Visible in the settings view"
},
"settingsVoicePermissionLoading": "Loading voice permissions ...",
"@settingsVoicePermissionLoading": {
"description": "Text displayed while loading voice permissions",
"context": "Visible in the settings view"
},
"settingsVoicePermissionNot": "Permissions not granted",
"@settingsVoicePermissionNot": {
"description": "Text displayed when voice permissions are not granted",
"context": "Visible in the settings view"
},
"settingsVoiceNotSupported": "Voice mode not supported",
"@settingsVoiceNotSupported": {
"description": "Text displayed when voice mode is not supported",
"context": "Visible in the settings view"
},
"settingsVoiceEnable": "Enable voice mode",
"@settingsVoiceEnable": {
"description": "Text displayed as description for enable voice mode toggle",
"context": "Visible in the settings view"
},
"settingsVoiceNoLanguage": "No language selected",
"@settingsVoiceNoLanguage": {
"description": "Text displayed when no language is selected",
"context": "Visible in the settings view"
},
"settingsVoiceLimitLanguage": "Limit to selected language",
"@settingsVoiceLimitLanguage": {
"description": "Text displayed as description for limit language toggle",
"context": "Visible in the settings view"
},
"settingsVoicePunctuation": "Enable AI punctuation",
"@settingsVoicePunctuation": {
"description": "Text displayed as description for enable AI punctuation toggle",
"context": "Visible in the settings view"
},
"settingsExportChats": "Export chats",
"@settingsExportChats": {
"description": "Text displayed as description for export chats button",

View File

@ -10,9 +10,11 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:url_launcher/url_launcher.dart';
import 'screen_settings.dart';
import 'screen_voice.dart';
import 'screen_welcome.dart';
import 'worker/setter.dart';
import 'worker/haptic.dart';
import 'worker/sender.dart';
import 'package:shared_preferences/shared_preferences.dart';
// ignore: depend_on_referenced_packages
@ -22,13 +24,14 @@ 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';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:speech_to_text/speech_to_text.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:permission_handler/permission_handler.dart';
// client configuration
@ -66,6 +69,15 @@ final user = types.User(id: const Uuid().v4());
final assistant = types.User(id: const Uuid().v4());
bool settingsOpen = false;
bool logoVisible = true;
bool menuVisible = false;
bool sendable = false;
SpeechToText speech = SpeechToText();
FlutterTts voice = FlutterTts();
bool voiceSupported = false;
Function? setMainState;
void main() {
runApp(const App());
@ -101,6 +113,12 @@ class _AppState extends State<App> {
try {
await FlutterDisplayMode.setHighRefreshRate();
} catch (_) {}
if ((await Permission.bluetoothConnect.isGranted) &&
(await Permission.microphone.isGranted)) {
voiceSupported = await speech.initialize();
}
SharedPreferences.setPrefix("ollama.");
SharedPreferences tmp = await SharedPreferences.getInstance();
setState(() {
@ -177,11 +195,7 @@ class MainApp extends StatefulWidget {
}
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([
@ -747,18 +761,14 @@ class _MainAppState extends State<MainApp> {
]),
actions: [
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
onPressed: () {selectionHaptic();
Navigator.of(context).pop();
},
child: Text(AppLocalizations.of(
context)!
.deleteDialogCancel)),
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
onPressed: () {selectionHaptic();
Navigator.of(context).pop();
for (var i = 0;
@ -1083,259 +1093,8 @@ class _MainAppState extends State<MainApp> {
child: const ImageIcon(
AssetImage("assets/logo512.png"),
size: 44)))),
onSendPressed: (p0) async {
selectionHaptic();
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: int.parse(
prefs!.getString("keepAlive") ??
"300")),
)
.timeout(const Duration(seconds: 30));
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(() {});
lightHaptic();
}
} else {
llama.GenerateChatCompletionResponse request;
request = await client
.generateChatCompletion(
request: llama.GenerateChatCompletionRequest(
model: model!,
messages: history,
keepAlive: int.parse(
prefs!.getString("keepAlive") ??
"300")),
)
.timeout(const Duration(seconds: 30));
if (chatAllowed) return;
if (request.message!.content.trim() == "") {
throw Exception();
}
messages.insert(
0,
types.TextMessage(
author: assistant,
id: newId,
text: request.message!.content));
setState(() {});
lightHaptic();
}
} 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```",
keepAlive: int.parse(
prefs!.getString("keepAlive") ??
"300")),
)
.timeout(const Duration(seconds: 10));
var title = generated.response!
.replaceAll("\"", "")
.replaceAll("'", "")
.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;
onSendPressed: (p0) {
send(p0.text, context, setState);
},
onMessageDoubleTap: (context, p1) {
selectionHaptic();
@ -1408,7 +1167,20 @@ class _MainAppState extends State<MainApp> {
setState(() {});
},
onAttachmentPressed: (!multimodal)
? null
? (prefs?.getBool("voiceModeEnabled") ?? false)
? (model != null)
? () {
selectionHaptic();
setMainState = setState;
settingsOpen = true;
logoVisible = false;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
const ScreenVoice()));
}
: null
: null
: () {
selectionHaptic();
if (!chatAllowed || model == null) return;
@ -1455,9 +1227,28 @@ class _MainAppState extends State<MainApp> {
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
HapticFeedback
.selectionClick();
onPressed: () async {selectionHaptic();
Navigator.of(context)
.pop();
setMainState = setState;
settingsOpen = true;
logoVisible = false;
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) =>
const ScreenVoice()));
},
icon: const Icon(Icons
.headphones_rounded),
label: Text(AppLocalizations
.of(context)!
.settingsTitleVoice))),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {selectionHaptic();
Navigator.of(context)
.pop();
@ -1497,8 +1288,7 @@ class _MainAppState extends State<MainApp> {
messages.insert(
0, message);
setState(() {});
HapticFeedback
.selectionClick();
selectionHaptic();
},
icon: const Icon(Icons
.photo_camera_rounded),
@ -1510,9 +1300,7 @@ class _MainAppState extends State<MainApp> {
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
HapticFeedback
.selectionClick();
onPressed: () async {selectionHaptic();
Navigator.of(context)
.pop();
@ -1552,8 +1340,7 @@ class _MainAppState extends State<MainApp> {
messages.insert(
0, message);
setState(() {});
HapticFeedback
.selectionClick();
selectionHaptic();
},
icon: const Icon(
Icons.image_rounded),
@ -1589,8 +1376,12 @@ class _MainAppState extends State<MainApp> {
(theme ?? ThemeData()).colorScheme.surface,
primaryColor:
(theme ?? ThemeData()).colorScheme.primary,
attachmentButtonIcon:
const Icon(Icons.add_a_photo_rounded),
attachmentButtonIcon: !multimodal
? (prefs?.getBool("voiceModeEnabled") ??
false)
? const Icon(Icons.headphones_rounded)
: null
: const Icon(Icons.add_a_photo_rounded),
sendButtonIcon: SizedBox(
height: 24,
child: CircleAvatar(
@ -1635,7 +1426,11 @@ class _MainAppState extends State<MainApp> {
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),
attachmentButtonIcon: !multimodal
? (prefs?.getBool("voiceModeEnabled") ?? false)
? const Icon(Icons.headphones_rounded)
: null
: const Icon(Icons.add_a_photo_rounded),
sendButtonIcon: SizedBox(
height: 24,
child: CircleAvatar(

View File

@ -10,6 +10,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'settings/behavior.dart';
import 'settings/interface.dart';
import 'settings/voice.dart';
import 'settings/export.dart';
import 'settings/about.dart';
@ -18,7 +19,11 @@ import 'package:http/http.dart' as http;
import 'package:bitsdojo_window/bitsdojo_window.dart';
Widget toggle(BuildContext context, String text, bool value,
Function(bool value) onChanged) {
Function(bool value) onChanged,
{bool disabled = false,
void Function()? onDisabledTap,
void Function()? onLongTap,
void Function()? onDoubleTap}) {
var space = ""; // Invisible character: U+2063
var spacePlus = " $space";
return InkWell(
@ -26,8 +31,17 @@ Widget toggle(BuildContext context, String text, bool value,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () {
onChanged(!value);
if (disabled) {
selectionHaptic();
if (onDisabledTap != null) {
onDisabledTap();
}
} else {
onChanged(!value);
}
},
onLongPress: onLongTap,
onDoubleTap: onDoubleTap,
child: Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4),
child: Stack(children: [
@ -43,6 +57,7 @@ Widget toggle(BuildContext context, String text, bool value,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: disabled ? Colors.grey : null,
backgroundColor:
(Theme.of(context).brightness == Brightness.light)
? (theme ?? ThemeData()).colorScheme.surface
@ -56,7 +71,22 @@ Widget toggle(BuildContext context, String text, bool value,
: (themeDark ?? ThemeData.dark()).colorScheme.surface,
child: SizedBox(
height: 40,
child: Switch(value: value, onChanged: onChanged)))
child: Switch(
value: value,
onChanged: disabled
? (p0) {
selectionHaptic();
if (onDisabledTap != null) {
onDisabledTap();
}
}
: onChanged,
trackOutlineColor: disabled
? const WidgetStatePropertyAll(Colors.grey)
: null,
thumbColor: disabled
? const WidgetStatePropertyAll(Colors.grey)
: null)))
]),
]),
),
@ -86,17 +116,36 @@ Widget titleDivider({double top = 16, double bottom = 16}) {
));
}
Widget button(String text, IconData icon, void Function()? onPressed,
{Color? color}) {
Widget button(String text, IconData? icon, void Function()? onPressed,
{Color? color,
bool disabled = false,
void Function()? onDisabledTap,
void Function()? onLongTap,
void Function()? onDoubleTap}) {
return InkWell(
onTap: onPressed,
onTap: disabled
? () {
selectionHaptic();
if (onDisabledTap != null) {
onDisabledTap();
}
}
: onPressed,
onLongPress: onLongTap,
onDoubleTap: onDoubleTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(children: [
Icon(icon, color: color),
const SizedBox(width: 16, height: 42),
Expanded(child: Text(text, style: TextStyle(color: color)))
(icon != null)
? Icon(icon, color: disabled ? Colors.grey : color)
: const SizedBox.shrink(),
(icon != null)
? const SizedBox(width: 16, height: 42)
: const SizedBox.shrink(),
Expanded(
child: Text(text,
style: TextStyle(color: disabled ? Colors.grey : color)))
]),
));
}
@ -389,6 +438,21 @@ class _ScreenSettingsState extends State<ScreenSettings> {
builder: (context) =>
const ScreenSettingsInterface()));
}),
(!(Platform.isWindows ||
Platform.isLinux ||
Platform.isMacOS))
? button(
AppLocalizations.of(context)!
.settingsTitleVoice,
Icons.headphones_rounded, () {
selectionHaptic();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ScreenSettingsVoice()));
})
: const SizedBox.shrink(),
button(
AppLocalizations.of(context)!.settingsTitleExport,
Icons.share_rounded, () {

389
lib/screen_voice.dart Normal file
View File

@ -0,0 +1,389 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ollama_app/worker/haptic.dart';
import 'package:ollama_app/worker/setter.dart';
import 'package:speech_to_text/speech_to_text.dart' as stt;
import 'package:ollama_dart/ollama_dart.dart' as llama;
import 'package:datetime_loop/datetime_loop.dart';
import 'main.dart';
import 'worker/sender.dart';
import 'settings/voice.dart';
class ScreenVoice extends StatefulWidget {
const ScreenVoice({super.key});
@override
State<ScreenVoice> createState() => _ScreenVoiceState();
}
class _ScreenVoiceState extends State<ScreenVoice> {
Iterable<String> languageOptionIds = [];
Iterable<String> languageOptions = [];
bool speaking = false;
bool aiThinking = false;
bool sttDone = true;
String text = "";
String aiText = "";
bool intendedStop = false;
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));
};
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));
}
void process() async {
setState(() {
speaking = true;
sttDone = false;
});
var textOldOld = text;
var textOld = "";
text = "";
speech.listen(
localeId: (prefs!.getString("voiceLanguage") ?? ""),
listenOptions:
stt.SpeechListenOptions(listenMode: stt.ListenMode.dictation),
onResult: (result) {
lightHaptic();
if (!speaking) return;
setState(() {
sttDone = result.finalResult;
text = result.recognizedWords;
});
},
pauseFor: const Duration(seconds: 3));
DateTime start = DateTime.now();
bool timeout = false;
await Future.doWhile(() =>
Future.delayed(const Duration(milliseconds: 1)).then((_) {
if (textOld != text) {
start = DateTime.now();
}
timeout =
(DateTime.now().difference(start) >= const Duration(seconds: 3));
textOld = text;
return !sttDone && speaking && !timeout;
}));
if (!sttDone || timeout) {
sttDone = true;
speech.stop();
if (timeout) {
text = textOldOld;
try {
setState(() {});
} catch (_) {}
}
if (!intendedStop) {
speaking = false;
try {
setState(() {});
} catch (_) {}
return;
} else {
intendedStop = false;
try {
setState(() {});
} catch (_) {}
}
}
if (text.isEmpty) {
setState(() {
speaking = false;
});
return;
}
aiText = "";
heavyHaptic();
aiThinking = true;
try {
if (prefs!.getBool("aiPunctuation") ?? true) {
final generated = await llama.OllamaClient(
headers: (jsonDecode(prefs!.getString("hostHeaders") ?? "{}") as Map)
.cast<String, String>(),
baseUrl: "$host/api",
)
.generateCompletion(
request: llama.GenerateCompletionRequest(
model: model!,
prompt:
"Add punctuation and syntax to the following sentence. You must not change order of words or a word in itself! You must not add any word or phrase or remove one! Do not change between formal and personal form, keep the original one!\n\n$text",
keepAlive: int.parse(prefs!.getString("keepAlive") ?? "300")),
)
.timeout(const Duration(seconds: 10));
setState(() {
text = generated.response!;
});
}
} catch (_) {}
// ignore: use_build_context_synchronously
send(text, context, setState, onStream: (currentText, done) async {
setState(() {
aiText = currentText;
lightHaptic();
});
if (done &&
(await voice.getLanguages as List).contains(
(prefs!.getString("voiceLanguage") ?? "en_US")
.replaceAll("_", "-"))) {
aiThinking = false;
heavyHaptic();
voice.setLanguage((prefs!.getString("voiceLanguage") ?? "en_US")
.replaceAll("_", "-"));
voice.setSpeechRate(0.6);
voice.setCompletionHandler(() async {
speaking = false;
try {
setState(() {});
} catch (_) {}
process();
});
var tmp = aiText;
tmp.replaceAll("-", ".");
tmp.replaceAll("*", ".");
voice.speak(tmp);
}
},
addToSystem: (prefs!.getBool("voiceLimitLanguage") ?? true)
? "You must write in the following language: ${prefs!.getString("voiceLanguage") ?? "en_US"}!"
: null);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged =
() {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
systemNavigationBarColor:
(themeDark ?? ThemeData.dark()).colorScheme.surface,
systemNavigationBarIconBrightness: Brightness.dark));
};
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
systemNavigationBarColor:
(themeDark ?? ThemeData.dark()).colorScheme.surface,
systemNavigationBarIconBrightness: Brightness.dark));
setState(() {});
});
void load() async {
var tmp = await speech.locales();
languageOptionIds = tmp.map((e) => e.localeId);
languageOptions = tmp.map((e) => e.name);
setState(() {});
}
load();
void loadProcess() async {
await Future.delayed(const Duration(milliseconds: 500));
process();
}
loadProcess();
}
@override
Widget build(BuildContext context) {
return Theme(
data: themeDark!,
child: PopScope(
canPop: !aiThinking,
onPopInvoked: (didPop) {
speaking = false;
voice.stop();
if (chatUuid != null) {
loadChat(chatUuid!, setMainState!);
}
settingsOpen = false;
logoVisible = true;
setBrightness();
},
child: Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () {
speaking = false;
Navigator.of(context).pop();
},
icon: const Icon(Icons.close_rounded,
color: Colors.grey)),
title: Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Text(model!.split(":")[0],
textAlign: TextAlign.center,
overflow: TextOverflow.fade,
style: const TextStyle(
fontFamily: "monospace", fontSize: 16))),
],
),
actions: [
IconButton(
onPressed: () {
speaking = false;
settingsOpen = false;
logoVisible = true;
setBrightness();
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) =>
const ScreenSettingsVoice()));
},
icon: const Icon(
Icons.settings_rounded,
color: Colors.grey,
))
]),
body: Column(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Column(mainAxisSize: MainAxisSize.max, children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Center(
child: Text(text,
textAlign: TextAlign.center,
overflow: TextOverflow.fade,
style: const TextStyle(
color: Colors.grey,
fontFamily: "monospace"))),
))
]),
),
Expanded(
child: Center(
child: DateTimeLoopBuilder(
timeUnit: TimeUnit.seconds,
builder: (context, dateTime, child) {
return SizedBox(
height: 96,
width: 96,
child: AnimatedScale(
scale: speaking
? aiThinking
? (dateTime.second).isEven
? 2.4
: 2
: 2
: dateTime.second
.toString()
.endsWith("1")
? 1.6
: 1.4,
duration: aiThinking
? const Duration(seconds: 1)
: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: InkWell(
borderRadius:
BorderRadius.circular(48),
onTap: () {
if (speaking && !aiThinking) {
intendedStop = true;
speaking = false;
voice.stop();
return;
}
process();
},
child: CircleAvatar(
backgroundColor: themeDark!
.colorScheme.primary
.withAlpha(
!speaking ? 200 : 255),
child: AnimatedSwitcher(
duration: const Duration(
milliseconds: 200),
child: speaking
? aiThinking
? Icon(Icons.auto_awesome_rounded,
color: themeDark!
.colorScheme
.secondary,
key: const ValueKey(
"aiThinking"))
: sttDone
? Icon(Icons.volume_up_rounded,
color: themeDark!
.colorScheme
.secondary,
key: const ValueKey(
"tts"))
: Icon(Icons.mic_rounded, color: themeDark!.colorScheme.secondary, key: const ValueKey("stt"))
: null)))),
);
}))),
Expanded(
child: Column(mainAxisSize: MainAxisSize.max, children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Center(
child: Text(aiText,
textAlign: TextAlign.center,
overflow: TextOverflow.fade,
style: const TextStyle(
fontFamily: "monospace"))),
))
]),
)
],
))));
}
}

View File

@ -106,13 +106,50 @@ class _ScreenWelcomeState extends State<ScreenWelcome> {
curve: Curves.easeInOut);
} else {
prefs!.setBool("welcomeFinished", true);
WidgetsBinding.instance.platformDispatcher
.onPlatformBrightnessChanged = () {
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));
};
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
systemNavigationBarColor:
(Theme.of(context).brightness == Brightness.light)
? (theme ?? ThemeData()).colorScheme.surface
: (themeDark ?? ThemeData.dark()).colorScheme.surface,
(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:
(Theme.of(context).brightness == Brightness.light)
(((prefs!.getString("brightness") ?? "system") ==
"system" &&
MediaQuery.of(context).platformBrightness ==
Brightness.light) ||
prefs!.getString("brightness") == "light")
? Brightness.dark
: Brightness.light));
Navigator.pushReplacement(context,

View File

@ -340,8 +340,7 @@ class _ScreenSettingsInterfaceState extends State<ScreenSettingsInterface> {
actions: [
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
selectionHaptic();
Navigator.of(context).pop();
},
child: Text(AppLocalizations.of(
@ -349,8 +348,7 @@ class _ScreenSettingsInterfaceState extends State<ScreenSettingsInterface> {
.settingsBrightnessRestartCancel)),
TextButton(
onPressed: () async {
HapticFeedback
.selectionClick();
selectionHaptic();
await prefs!.setString(
"brightness",
p0.elementAt(0));

342
lib/settings/voice.dart Normal file
View File

@ -0,0 +1,342 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:ollama_app/worker/haptic.dart';
import '../main.dart';
// import '../worker/haptic.dart';
import '../screen_settings.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:permission_handler/permission_handler.dart';
class ScreenSettingsVoice extends StatefulWidget {
const ScreenSettingsVoice({super.key});
@override
State<ScreenSettingsVoice> createState() => _ScreenSettingsVoiceState();
}
class _ScreenSettingsVoiceState extends State<ScreenSettingsVoice> {
bool permissionLoading = true;
bool permissionRecord = false;
bool permissionBluetooth = false;
Iterable<String> languageOptionIds = [];
Iterable<String> languageOptions = [];
@override
void initState() {
super.initState();
void load() async {
var tmp = await speech.locales();
languageOptionIds = tmp.map((e) => e.localeId);
languageOptions = tmp.map((e) => e.name);
permissionRecord = await Permission.microphone.isGranted;
permissionBluetooth = await Permission.bluetoothConnect.isGranted;
permissionLoading = false;
setState(() {});
}
load();
}
@override
Widget build(BuildContext context) {
return WindowBorder(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.settingsTitleVoice)),
body: Padding(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Column(children: [
Expanded(
child: ListView(children: [
// const SizedBox(height: 16),
((prefs!.getBool("voiceModeEnabled") ?? false) ||
permissionLoading ||
(permissionBluetooth &&
permissionRecord &&
voiceSupported))
? const SizedBox.shrink()
: button(
permissionLoading
? AppLocalizations.of(context)!
.settingsVoicePermissionLoading
: (permissionBluetooth && permissionRecord)
? AppLocalizations.of(context)!
.settingsVoiceNotSupported
: AppLocalizations.of(context)!
.settingsVoicePermissionNot,
Icons.info_rounded, () {
if (permissionLoading) return;
if (!(permissionBluetooth && permissionRecord)) {
void load() async {
try {
if (await Permission
.bluetooth.isPermanentlyDenied ||
await Permission
.microphone.isPermanentlyDenied) {
await openAppSettings();
}
permissionRecord = await Permission.microphone
.request()
.isGranted;
permissionBluetooth = await Permission
.bluetoothConnect
.request()
.isGranted;
permissionLoading = false;
if (permissionBluetooth && permissionRecord) {
voiceSupported = await speech.initialize();
}
setState(() {});
} catch (_) {
permissionLoading = false;
try {
setState(() {});
} catch (_) {}
}
}
load();
}
}),
toggle(
context,
AppLocalizations.of(context)!.settingsVoiceEnable,
(prefs!.getBool("voiceModeEnabled") ?? false), (value) {
selectionHaptic();
prefs!.setBool("voiceModeEnabled", value);
setState(() {});
}, disabled: !voiceSupported),
button(
((prefs!.getString("voiceLanguage") ?? "") == "" ||
languageOptions.isEmpty)
? AppLocalizations.of(context)!
.settingsVoiceNoLanguage
: () {
for (int i = 0;
i < languageOptionIds.length;
i++) {
if (languageOptionIds.elementAt(i) ==
prefs!.getString("voiceLanguage")) {
return languageOptions.elementAt(i);
}
}
return "";
}(),
Icons.language_rounded, () {
int usedIndex = -1;
Function? setModalState;
void load() async {
var tmp = await speech.locales();
languageOptionIds = tmp.map((e) => e.localeId);
languageOptions = tmp.map((e) => e.name);
if ((prefs!.getString("voiceLanguage") ?? "") != "") {
for (int i = 0; i < languageOptionIds.length; i++) {
if (languageOptionIds.elementAt(i) ==
(prefs!.getString("voiceLanguage") ?? "")) {
usedIndex = i;
setModalState!(() {});
break;
}
}
}
}
selectionHaptic();
load();
showModalBottomSheet(
context: context,
builder:
(context) => StatefulBuilder(
builder: (context, setLocalState) {
setModalState = setLocalState;
return PopScope(
onPopInvoked: (didPop) {
if (usedIndex == -1) return;
prefs!.setString(
"voiceLanguage",
languageOptionIds
.elementAt(usedIndex));
setState(() {});
},
child: Container(
width: ((Platform.isWindows ||
Platform.isLinux ||
Platform.isMacOS) &&
MediaQuery.of(context)
.size
.width >=
1000)
? null
: double.infinity,
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: (Platform.isWindows ||
Platform.isLinux ||
Platform.isMacOS)
? 16
: 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: ((Platform
.isWindows ||
Platform
.isLinux ||
Platform
.isMacOS) &&
MediaQuery.of(
context)
.size
.width >=
1000)
? 300
: double.infinity,
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(
context)
.size
.height *
0.4),
child:
SingleChildScrollView(
scrollDirection:
Axis.vertical,
child: Wrap(
spacing: ((Platform.isWindows ||
Platform
.isLinux ||
Platform
.isMacOS) &&
MediaQuery.of(context).size.width >=
1000)
? 10.0
: 5.0,
runSpacing: (Platform.isWindows ||
Platform
.isLinux ||
Platform
.isMacOS)
? (MediaQuery.of(context).size.width >=
1000)
? 10.0
: 5.0
: 0.0,
alignment:
WrapAlignment
.center,
children: List<
Widget>.generate(
languageOptionIds
.length,
(int index) {
return ChoiceChip(
label: Text(
languageOptions
.elementAt(index)),
selected:
usedIndex ==
index,
avatar: (usedIndex ==
index)
? null
: (languageOptionIds.elementAt(index).startsWith(AppLocalizations.of(context)!.localeName))
? const Icon(Icons.star_rounded)
: null,
checkmarkColor: (usedIndex ==
index)
? ((MediaQuery.of(context).platformBrightness == Brightness.light)
? (theme ?? ThemeData()).colorScheme.secondary
: (themeDark ?? ThemeData.dark()).colorScheme.secondary)
: null,
labelStyle: (usedIndex ==
index)
? TextStyle(
color: (MediaQuery.of(context).platformBrightness == Brightness.light) ? (theme ?? ThemeData()).colorScheme.secondary : (themeDark ?? ThemeData.dark()).colorScheme.secondary)
: null,
selectedColor: (MediaQuery.of(context).platformBrightness ==
Brightness
.light)
? (theme ?? ThemeData())
.colorScheme
.primary
: (themeDark ?? ThemeData.dark())
.colorScheme
.primary,
onSelected:
(bool
selected) {
selectionHaptic();
setLocalState(
() {
usedIndex = selected
? index
: -1;
});
},
);
},
).toList(),
)))
])));
}));
},
disabled: (!voiceSupported ||
!(prefs!.getBool("voiceModeEnabled") ?? false))),
titleDivider(),
toggle(
context,
AppLocalizations.of(context)!
.settingsVoiceLimitLanguage,
(prefs!.getBool("voiceLimitLanguage") ?? true),
(value) {
selectionHaptic();
prefs!.setBool("voiceLimitLanguage", value);
setState(() {});
},
disabled: (!voiceSupported ||
!(prefs!.getBool("voiceModeEnabled") ?? false))),
toggle(
context,
AppLocalizations.of(context)!.settingsVoicePunctuation,
(prefs!.getBool("aiPunctuation") ?? true), (value) {
selectionHaptic();
prefs!.setBool("aiPunctuation", value);
setState(() {});
},
disabled: (!voiceSupported ||
!(prefs!.getBool("voiceModeEnabled") ?? false)))
]),
),
const SizedBox(height: 16),
button(
AppLocalizations.of(context)!
.settingsExperimentalBetaFeature,
Icons.warning_rounded,
null,
color: Colors.orange, onLongTap: () {
selectionHaptic();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!
.settingsExperimentalBetaDescription),
showCloseIcon: true));
})
]))),
);
}
}

View File

@ -18,5 +18,7 @@ void heavyHaptic() {
void selectionHaptic() {
if (!(prefs!.getBool("enableHaptic") ?? true)) return;
HapticFeedback.selectionClick();
// same name but for better experience, change behavior
HapticFeedback.lightImpact();
// HapticFeedback.selectionClick();
}

253
lib/worker/sender.dart Normal file
View File

@ -0,0 +1,253 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'haptic.dart';
import 'setter.dart';
import '../main.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:ollama_dart/ollama_dart.dart' as llama;
import 'package:dartx/dartx.dart';
import 'package:uuid/uuid.dart';
// ignore: depend_on_referenced_packages
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
List<String> images = [];
Future<List<llama.Message>> getHistory([String? addToSystem]) async {
var system = prefs?.getString("system") ?? "You are a helpful assistant";
if (prefs!.getBool("noMarkdown") ?? false) {
system +=
"\nYou must not use markdown or any other formatting language in any way!";
}
if (addToSystem != null) {
system += "\n$addToSystem";
}
List<llama.Message> history = [
llama.Message(role: llama.MessageRole.system, content: system)
];
List<llama.Message> history2 = [];
images = [];
for (var i = 0; i < messages.length; i++) {
if (jsonDecode(jsonEncode(messages[i]))["text"] != null) {
history2.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));
images = [];
} 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.addAll(history2.reversed.toList());
return history;
}
Future<String> send(String value, BuildContext context, Function setState,
{void Function(String currentText, bool done)? onStream,
String? addToSystem}) async {
selectionHaptic();
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 history = await getHistory(addToSystem);
history.add(llama.Message(
role: llama.MessageRole.user,
content: value.trim(),
images: (images.isNotEmpty) ? images : null));
messages.insert(
0,
types.TextMessage(
author: user, id: const Uuid().v4(), text: value.trim()));
saveChat(chatUuid!, setState);
setState(() {});
chatAllowed = false;
String text = "";
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: int.parse(prefs!.getString("keepAlive") ?? "300")),
)
.timeout(const Duration(seconds: 30));
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));
if (onStream != null) {
onStream(text, false);
}
setState(() {});
heavyHaptic();
}
} else {
llama.GenerateChatCompletionResponse request;
request = await client
.generateChatCompletion(
request: llama.GenerateChatCompletionRequest(
model: model!,
messages: history,
keepAlive: int.parse(prefs!.getString("keepAlive") ?? "300")),
)
.timeout(const Duration(seconds: 30));
if (chatAllowed) return "";
if (request.message!.content.trim() == "") {
throw Exception();
}
messages.insert(
0,
types.TextMessage(
author: assistant, id: newId, text: request.message!.content));
text = request.message!.content;
setState(() {});
heavyHaptic();
}
} 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(
content:
// ignore: use_build_context_synchronously
Text(AppLocalizations.of(context)!.settingsHostInvalid("timeout")),
showCloseIcon: true));
return "";
}
if ((prefs!.getString("requestType") ?? "stream") == "stream") {
if (onStream != null) {
onStream(text, true);
}
}
saveChat(chatUuid!, setState);
if (newChat && (prefs!.getBool("generateTitles") ?? true)) {
void setTitle() async {
history = await getHistory();
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```",
keepAlive: int.parse(prefs!.getString("keepAlive") ?? "300")),
)
.timeout(const Duration(seconds: 10));
var title = generated.response!
.replaceAll("\"", "")
.replaceAll("'", "")
.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;
return text;
}

View File

@ -198,6 +198,7 @@ void setModel(BuildContext context, Function setState) {
.colorScheme
.primary,
onSelected: (bool selected) {
selectionHaptic();
if (addIndex == index) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context)

View File

@ -129,6 +129,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
datetime_loop:
dependency: "direct main"
description:
name: datetime_loop
sha256: "5b7b694879e505368e6d0e04ac8bb55a1e7d2e493dd0b851e08db08e1ba784e2"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
diffutil_dart:
dependency: transitive
description:
@ -328,6 +336,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_tts:
dependency: "direct main"
description:
name: flutter_tts
sha256: aed2a00c48c43af043ed81145fd8503ddd793dafa7088ab137dbef81a703e53d
url: "https://pub.dev"
source: hosted
version: "4.0.2"
flutter_web_plugins:
dependency: transitive
description: flutter
@ -445,6 +461,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.19.0"
js:
dependency: transitive
description:
name: js
sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
url: "https://pub.dev"
source: hosted
version: "0.7.1"
json_annotation:
dependency: transitive
description:
@ -590,6 +614,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.1"
pedantic:
dependency: transitive
description:
name: pedantic
sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb"
url: "https://pub.dev"
source: hosted
version: "11.3.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "8bb852cd759488893805c3161d0b2b5db55db52f773dbb014420b304055ba2c5"
url: "https://pub.dev"
source: hosted
version: "12.0.6"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0
url: "https://pub.dev"
source: hosted
version: "9.4.5"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d"
url: "https://pub.dev"
source: hosted
version: "0.1.1"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20"
url: "https://pub.dev"
source: hosted
version: "4.2.1"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
photo_view:
dependency: transitive
description:
@ -715,6 +795,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.0"
speech_to_text:
dependency: "direct main"
description:
name: speech_to_text
sha256: "97425fd8cc60424061a0584b6c418c0eedab5201cc5e96ef15a946d7fab7b9b7"
url: "https://pub.dev"
source: hosted
version: "6.6.2"
speech_to_text_macos:
dependency: transitive
description:
name: speech_to_text_macos
sha256: e685750f7542fcaa087a5396ee471e727ec648bf681f4da83c84d086322173f6
url: "https://pub.dev"
source: hosted
version: "1.1.0"
speech_to_text_platform_interface:
dependency: transitive
description:
name: speech_to_text_platform_interface
sha256: a0df1a907091ea09880077dc25aae02af9f79811264e6e97ddb08639b7f771c2
url: "https://pub.dev"
source: hosted
version: "2.2.0"
sprintf:
dependency: transitive
description:

View File

@ -38,6 +38,10 @@ dependencies:
version: ^3.0.2
flutter_displaymode: ^0.6.0
duration_picker: ^1.2.0
speech_to_text: ^6.6.2
flutter_tts: ^4.0.2
permission_handler: ^11.3.1
datetime_loop: ^1.2.0
dev_dependencies:
flutter_test:

View File

@ -8,6 +8,8 @@
#include <bitsdojo_window_windows/bitsdojo_window_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_tts/flutter_tts_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@ -15,6 +17,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("BitsdojoWindowPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterTtsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@ -5,6 +5,8 @@
list(APPEND FLUTTER_PLUGIN_LIST
bitsdojo_window_windows
file_selector_windows
flutter_tts
permission_handler_windows
url_launcher_windows
)