Added model adding
This commit is contained in:
parent
daff6a722f
commit
1d3a5e91d9
|
@ -135,9 +135,82 @@
|
|||
"description": "Text displayed for add model button",
|
||||
"context": "Visible in the model dialog"
|
||||
},
|
||||
"modelDialogAddSteps": "Adding models is not supported. Go to your host pc and add models there.",
|
||||
"@modelDialogAddSteps": {
|
||||
"description": "Steps to add a new model",
|
||||
"modelDialogAddPromptTitle": "Add new model",
|
||||
"@modelDialogAddPromptTitle": {
|
||||
"description": "Title of the add model dialog",
|
||||
"context": "Visible in the model dialog"
|
||||
},
|
||||
"modelDialogAddPromptDescription": "This can have either be a normal name (e.g. 'llama3') or name and tag (e.g. 'llama3:70b').",
|
||||
"@modelDialogAddPromptDescription": {
|
||||
"description": "Description of the add model dialog",
|
||||
"context": "Visible in the model dialog"
|
||||
},
|
||||
"modelDialogAddPromptAlreadyExists": "Model already exists",
|
||||
"@modelDialogAddPromptAlreadyExists": {
|
||||
"description": "Text displayed when the model already exists",
|
||||
"context": "Visible in the model dialog"
|
||||
},
|
||||
"modelDialogAddPromptInvalid": "Invalid model name",
|
||||
"@modelDialogAddPromptInvalid": {
|
||||
"description": "Text displayed when the model name is invalid",
|
||||
"context": "Visible in the model dialog"
|
||||
},
|
||||
"modelDialogAddAssuranceTitle": "Add {model}?",
|
||||
"@modelDialogAddAssuranceTitle": {
|
||||
"description": "Title of the add model assurance dialog",
|
||||
"context": "Visible in the model dialog",
|
||||
"placeholders": {
|
||||
"model": {
|
||||
"type": "String",
|
||||
"description": "Name of the model to be added"
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelDialogAddAssuranceDescription": "Pressing 'Add' will download the model '{model}' directly from the Ollama server to your host.\nThis can take a while depending on your internet connection. The action cannot be canceled.\nIf the app is closed during the download, it'll resume if you enter the name into the model dialog again.",
|
||||
"@modelDialogAddAssuranceDescription": {
|
||||
"description": "Description of the add model assurance dialog",
|
||||
"context": "Visible in the model dialog",
|
||||
"placeholders": {
|
||||
"model": {
|
||||
"type": "String",
|
||||
"description": "Name of the model to be added"
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelDialogAddAssuranceAdd": "Add",
|
||||
"@modelDialogAddAssuranceAdd": {
|
||||
"description": "Text displayed for add button, should be capitalized",
|
||||
"context": "Visible in the model dialog"
|
||||
},
|
||||
"modelDialogAddAssuranceCancel": "Cancel",
|
||||
"@modelDialogAddAssuranceCancel": {
|
||||
"description": "Text displayed for cancel button, should be capitalized",
|
||||
"context": "Visible in the model dialog"
|
||||
},
|
||||
"modelDialogAddDownloadPercentLoading": "loading progress",
|
||||
"@modelDialogAddDownloadPercentLoading": {
|
||||
"description": "Text displayed while loading the download progress",
|
||||
"context": "Visible in the model dialog; 'loading progress in the moment'"
|
||||
},
|
||||
"modelDialogAddDownloadPercent": "download at {percent}%",
|
||||
"@modelDialogAddDownloadPercent": {
|
||||
"description": "Text displayed while downloading a model",
|
||||
"context": "Visible in the model dialog; download is at x percent",
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "String",
|
||||
"description": "Percentage of the download"
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelDialogAddDownloadFailed": "Disconnected, try again",
|
||||
"@modelDialogAddDownloadFailed": {
|
||||
"description": "Text displayed when the download of a model fails",
|
||||
"context": "Visible in the model dialog"
|
||||
},
|
||||
"modelDialogAddDownloadSuccess": "Download successful",
|
||||
"@modelDialogAddDownloadSuccess": {
|
||||
"description": "Text displayed when the download of a model is successful",
|
||||
"context": "Visible in the model dialog"
|
||||
},
|
||||
"deleteDialogTitle": "Delete Chat",
|
||||
|
|
|
@ -81,6 +81,7 @@ SpeechToText speech = SpeechToText();
|
|||
FlutterTts voice = FlutterTts();
|
||||
bool voiceSupported = false;
|
||||
|
||||
BuildContext? mainContext;
|
||||
void Function(void Function())? setGlobalState;
|
||||
void Function(void Function())? setMainAppState;
|
||||
|
||||
|
@ -673,6 +674,7 @@ class _MainAppState extends State<MainApp> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
mainContext = context;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) async {
|
||||
|
@ -692,6 +694,10 @@ class _MainAppState extends State<MainApp> {
|
|||
}
|
||||
|
||||
if (!(allowSettings || useHost)) {
|
||||
// ignore: use_build_context_synchronously
|
||||
resetSystemNavigation(context,
|
||||
statusBarColor: Colors.black,
|
||||
systemNavigationBarColor: Colors.black);
|
||||
showDialog(
|
||||
// ignore: use_build_context_synchronously
|
||||
context: context,
|
||||
|
|
|
@ -422,6 +422,7 @@ class _ScreenSettingsState extends State<ScreenSettings> {
|
|||
value: (prefs!
|
||||
.getString("hostHeaders") ??
|
||||
""),
|
||||
enableSuggestions: false,
|
||||
valueIfCanceled: "{}",
|
||||
validator: (content) async {
|
||||
try {
|
||||
|
|
|
@ -22,6 +22,7 @@ void setModel(BuildContext context, Function setState) {
|
|||
List<String> modelsReal = [];
|
||||
List<bool> modal = [];
|
||||
int usedIndex = -1;
|
||||
int oldIndex = -1;
|
||||
int addIndex = -1;
|
||||
bool loaded = false;
|
||||
Function? setModalState;
|
||||
|
@ -52,6 +53,18 @@ void setModel(BuildContext context, Function setState) {
|
|||
for (var i = 0; i < modelsReal.length; i++) {
|
||||
if (modelsReal[i] == model) {
|
||||
usedIndex = i;
|
||||
oldIndex = usedIndex;
|
||||
}
|
||||
}
|
||||
if (prefs!.getBool("modelTags") == null) {
|
||||
List duplicateFinder = [];
|
||||
for (var model in models) {
|
||||
if (duplicateFinder.contains(model)) {
|
||||
prefs!.setBool("modelTags", true);
|
||||
break;
|
||||
} else {
|
||||
duplicateFinder.add(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
loaded = true;
|
||||
|
@ -64,7 +77,6 @@ void setModel(BuildContext context, Function setState) {
|
|||
Navigator.of(context).pop();
|
||||
// ignore: use_build_context_synchronously
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
// ignore: use_build_context_synchronously
|
||||
content: Text(
|
||||
// ignore: use_build_context_synchronously
|
||||
AppLocalizations.of(context)!.settingsHostInvalid("timeout")),
|
||||
|
@ -84,18 +96,14 @@ void setModel(BuildContext context, Function setState) {
|
|||
onPopInvoked: (didPop) async {
|
||||
if (!loaded) return;
|
||||
loaded = false;
|
||||
bool preload = false;
|
||||
if (usedIndex >= 0 && modelsReal[usedIndex] != model) {
|
||||
preload = true;
|
||||
if (prefs!.getBool("resetOnModelSelect") ??
|
||||
true && allowMultipleChats) {
|
||||
messages = [];
|
||||
chatUuid = null;
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
desktopTitleVisible = true;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
model = (usedIndex >= 0) ? modelsReal[usedIndex] : null;
|
||||
chatAllowed = !(model == null);
|
||||
|
@ -108,6 +116,7 @@ void setModel(BuildContext context, Function setState) {
|
|||
prefs?.setBool("multimodal", multimodal);
|
||||
|
||||
if (model != null &&
|
||||
preload &&
|
||||
int.parse(prefs!.getString("keepAlive") ?? "300") != 0 &&
|
||||
(prefs!.getBool("preloadModel") ?? true)) {
|
||||
setLocalState(() {});
|
||||
|
@ -152,7 +161,9 @@ void setModel(BuildContext context, Function setState) {
|
|||
setState(() {
|
||||
desktopTitleVisible = true;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
try {
|
||||
Navigator.of(context).pop();
|
||||
} catch (_) {}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
|
@ -251,14 +262,16 @@ void setModel(BuildContext context, Function setState) {
|
|||
onSelected: (bool selected) {
|
||||
selectionHaptic();
|
||||
if (addIndex == index) {
|
||||
usedIndex = oldIndex;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(
|
||||
context)!
|
||||
.modelDialogAddSteps),
|
||||
showCloseIcon: true));
|
||||
// ScaffoldMessenger.of(context)
|
||||
// .showSnackBar(SnackBar(
|
||||
// content: Text(
|
||||
// AppLocalizations.of(
|
||||
// context)!
|
||||
// .modelDialogAddSteps),
|
||||
// showCloseIcon: true));
|
||||
addModel(context, setState);
|
||||
}
|
||||
if (!chatAllowed && model != null) {
|
||||
return;
|
||||
|
@ -299,6 +312,166 @@ void setModel(BuildContext context, Function setState) {
|
|||
}
|
||||
}
|
||||
|
||||
void addModel(BuildContext context, Function setState) async {
|
||||
var client = llama.OllamaClient(
|
||||
headers: (jsonDecode(prefs!.getString("hostHeaders") ?? "{}") as Map)
|
||||
.cast<String, String>(),
|
||||
baseUrl: "$host/api");
|
||||
bool canceled = false;
|
||||
bool networkError = false;
|
||||
bool alreadyExists = false;
|
||||
final String invalidText =
|
||||
AppLocalizations.of(context)!.modelDialogAddPromptInvalid;
|
||||
final networkErrorText =
|
||||
AppLocalizations.of(context)!.settingsHostInvalid("other");
|
||||
final alreadyExistsText =
|
||||
AppLocalizations.of(context)!.modelDialogAddPromptAlreadyExists;
|
||||
final downloadSuccessText =
|
||||
AppLocalizations.of(context)!.modelDialogAddDownloadSuccess;
|
||||
final downloadFailedText =
|
||||
AppLocalizations.of(context)!.modelDialogAddDownloadFailed;
|
||||
var requestedModel = await prompt(
|
||||
context,
|
||||
title: AppLocalizations.of(context)!.modelDialogAddPromptTitle,
|
||||
description: AppLocalizations.of(context)!.modelDialogAddPromptDescription,
|
||||
placeholder: "llama3:latest",
|
||||
enableSuggestions: false,
|
||||
validator: (content) async {
|
||||
var model = content;
|
||||
model = model.removeSuffix(":latest");
|
||||
if (model == "") return false;
|
||||
canceled = false;
|
||||
networkError = false;
|
||||
alreadyExists = false;
|
||||
try {
|
||||
var request = await client.listModels().timeout(Duration(
|
||||
seconds: (10.0 * (prefs!.getDouble("timeoutMultiplier") ?? 1.0))
|
||||
.round()));
|
||||
for (var element in request.models!) {
|
||||
var localModel = element.model!.removeSuffix(":latest");
|
||||
if (localModel == model) {
|
||||
alreadyExists = true;
|
||||
}
|
||||
}
|
||||
if (alreadyExists) return false;
|
||||
} catch (_) {
|
||||
networkError = true;
|
||||
return false;
|
||||
}
|
||||
http.Response response;
|
||||
try {
|
||||
response = await http
|
||||
.get(Uri.parse("https://ollama.com/library/$model"))
|
||||
.timeout(Duration(
|
||||
seconds: (10.0 * (prefs!.getDouble("timeoutMultiplier") ?? 1.0))
|
||||
.round()));
|
||||
} catch (_) {
|
||||
networkError = true;
|
||||
return false;
|
||||
}
|
||||
if (response.statusCode == 200) {
|
||||
bool returnValue = false;
|
||||
await showDialog(
|
||||
context: mainContext!,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!
|
||||
.modelDialogAddAssuranceTitle(model)),
|
||||
content: Text(AppLocalizations.of(context)!
|
||||
.modelDialogAddAssuranceDescription(model)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
canceled = true;
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!
|
||||
.modelDialogAddAssuranceCancel)),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
returnValue = true;
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!
|
||||
.modelDialogAddAssuranceAdd))
|
||||
]);
|
||||
});
|
||||
return returnValue;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
validatorErrorCallback: (content) {
|
||||
if (networkError) return networkErrorText;
|
||||
if (alreadyExists) return alreadyExistsText;
|
||||
if (canceled) return null;
|
||||
return invalidText;
|
||||
},
|
||||
);
|
||||
if (requestedModel == "") return;
|
||||
requestedModel = requestedModel.removeSuffix(":latest");
|
||||
double? percent;
|
||||
Function? setDialogState;
|
||||
showModalBottomSheet(
|
||||
context: mainContext!,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(builder: (context, setLocalState) {
|
||||
setDialogState = setLocalState;
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: desktopLayout(context) ? 16 : 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
percent == null
|
||||
? AppLocalizations.of(context)!
|
||||
.modelDialogAddDownloadPercentLoading
|
||||
: AppLocalizations.of(context)!
|
||||
.modelDialogAddDownloadPercent(
|
||||
(percent * 100).round().toString()),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.only(top: 4)),
|
||||
LinearProgressIndicator(value: percent),
|
||||
],
|
||||
)));
|
||||
});
|
||||
});
|
||||
try {
|
||||
final stream = client
|
||||
.pullModelStream(request: llama.PullModelRequest(model: requestedModel))
|
||||
.timeout(Duration(
|
||||
seconds: (10.0 * (prefs!.getDouble("timeoutMultiplier") ?? 1.0))
|
||||
.round()));
|
||||
await for (final res in stream) {
|
||||
percent = ((res.completed ?? 0).toInt() / (res.total ?? 100).toInt());
|
||||
if ((percent * 100).round() == 0) {
|
||||
percent = null;
|
||||
}
|
||||
setDialogState!(() {});
|
||||
}
|
||||
Navigator.of(mainContext!).pop();
|
||||
setState(() {
|
||||
model = requestedModel;
|
||||
if (model!.split(":").length == 1) {
|
||||
model = "$model:latest";
|
||||
}
|
||||
});
|
||||
ScaffoldMessenger.of(mainContext!).showSnackBar(
|
||||
SnackBar(content: Text(downloadSuccessText), showCloseIcon: true));
|
||||
} catch (_) {
|
||||
Navigator.of(mainContext!).pop();
|
||||
ScaffoldMessenger.of(mainContext!).showSnackBar(
|
||||
SnackBar(content: Text(downloadFailedText), showCloseIcon: true));
|
||||
}
|
||||
}
|
||||
|
||||
void saveChat(String uuid, Function setState) async {
|
||||
int index = -1;
|
||||
for (var i = 0; i < (prefs!.getStringList("chats") ?? []).length; i++) {
|
||||
|
@ -493,11 +666,15 @@ Future<String> prompt(BuildContext context,
|
|||
String title = "",
|
||||
String? valueIfCanceled,
|
||||
TextInputType keyboard = TextInputType.text,
|
||||
bool autocorrect = true,
|
||||
Iterable<String> autofillHints = const [],
|
||||
bool enableSuggestions = true,
|
||||
Icon? prefixIcon,
|
||||
int maxLines = 1,
|
||||
String? uuid,
|
||||
Future<bool> Function(String content)? validator,
|
||||
String? validatorError,
|
||||
String? Function(String content)? validatorErrorCallback,
|
||||
String? placeholder,
|
||||
bool prefill = true}) async {
|
||||
var returnText = (valueIfCanceled != null) ? valueIfCanceled : value;
|
||||
|
@ -510,6 +687,35 @@ Future<String> prompt(BuildContext context,
|
|||
isScrollControlled: true,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(builder: (context, setLocalState) {
|
||||
void submit() async {
|
||||
selectionHaptic();
|
||||
if (validator != null) {
|
||||
setLocalState(() {
|
||||
error = null;
|
||||
loading = true;
|
||||
});
|
||||
bool valid = await validator(controller.text);
|
||||
setLocalState(() {
|
||||
loading = false;
|
||||
});
|
||||
if (!valid) {
|
||||
setLocalState(() {
|
||||
if (validatorError != null) {
|
||||
error = validatorError;
|
||||
} else if (validatorErrorCallback != null) {
|
||||
error = validatorErrorCallback(controller.text);
|
||||
} else {
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
returnText = controller.text;
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
|
@ -540,25 +746,11 @@ Future<String> prompt(BuildContext context,
|
|||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: keyboard,
|
||||
autocorrect: autocorrect,
|
||||
autofillHints: autofillHints,
|
||||
enableSuggestions: enableSuggestions,
|
||||
maxLines: maxLines,
|
||||
onSubmitted: (value) async {
|
||||
if (validator != null) {
|
||||
selectionHaptic();
|
||||
setLocalState(() {
|
||||
error = null;
|
||||
});
|
||||
bool valid = await validator(controller.text);
|
||||
if (!valid) {
|
||||
setLocalState(() {
|
||||
error = validatorError;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
returnText = controller.text;
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onSubmitted: (_) => submit(),
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: placeholder,
|
||||
|
@ -566,25 +758,7 @@ Future<String> prompt(BuildContext context,
|
|||
suffixIcon: IconButton(
|
||||
tooltip: AppLocalizations.of(context)!
|
||||
.tooltipSave,
|
||||
onPressed: () async {
|
||||
if (validator != null) {
|
||||
selectionHaptic();
|
||||
setLocalState(() {
|
||||
error = null;
|
||||
});
|
||||
bool valid =
|
||||
await validator(controller.text);
|
||||
if (!valid) {
|
||||
setLocalState(() {
|
||||
error = validatorError;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
returnText = controller.text;
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onPressed: submit,
|
||||
icon: const Icon(Icons.save_rounded)),
|
||||
prefixIcon: (title ==
|
||||
AppLocalizations.of(context)!
|
||||
|
|
|
@ -2,6 +2,18 @@
|
|||
"de": [
|
||||
"deleteChat",
|
||||
"renameChat",
|
||||
"modelDialogAddPromptTitle",
|
||||
"modelDialogAddPromptDescription",
|
||||
"modelDialogAddPromptAlreadyExists",
|
||||
"modelDialogAddPromptInvalid",
|
||||
"modelDialogAddAssuranceTitle",
|
||||
"modelDialogAddAssuranceDescription",
|
||||
"modelDialogAddAssuranceAdd",
|
||||
"modelDialogAddAssuranceCancel",
|
||||
"modelDialogAddDownloadPercentLoading",
|
||||
"modelDialogAddDownloadPercent",
|
||||
"modelDialogAddDownloadFailed",
|
||||
"modelDialogAddDownloadSuccess",
|
||||
"settingsDescriptionBehavior",
|
||||
"settingsDescriptionInterface",
|
||||
"settingsDescriptionVoice",
|
||||
|
@ -27,6 +39,18 @@
|
|||
"it": [
|
||||
"deleteChat",
|
||||
"renameChat",
|
||||
"modelDialogAddPromptTitle",
|
||||
"modelDialogAddPromptDescription",
|
||||
"modelDialogAddPromptAlreadyExists",
|
||||
"modelDialogAddPromptInvalid",
|
||||
"modelDialogAddAssuranceTitle",
|
||||
"modelDialogAddAssuranceDescription",
|
||||
"modelDialogAddAssuranceAdd",
|
||||
"modelDialogAddAssuranceCancel",
|
||||
"modelDialogAddDownloadPercentLoading",
|
||||
"modelDialogAddDownloadPercent",
|
||||
"modelDialogAddDownloadFailed",
|
||||
"modelDialogAddDownloadSuccess",
|
||||
"settingsDescriptionBehavior",
|
||||
"settingsDescriptionInterface",
|
||||
"settingsDescriptionVoice",
|
||||
|
@ -52,6 +76,18 @@
|
|||
"tr": [
|
||||
"deleteChat",
|
||||
"renameChat",
|
||||
"modelDialogAddPromptTitle",
|
||||
"modelDialogAddPromptDescription",
|
||||
"modelDialogAddPromptAlreadyExists",
|
||||
"modelDialogAddPromptInvalid",
|
||||
"modelDialogAddAssuranceTitle",
|
||||
"modelDialogAddAssuranceDescription",
|
||||
"modelDialogAddAssuranceAdd",
|
||||
"modelDialogAddAssuranceCancel",
|
||||
"modelDialogAddDownloadPercentLoading",
|
||||
"modelDialogAddDownloadPercent",
|
||||
"modelDialogAddDownloadFailed",
|
||||
"modelDialogAddDownloadSuccess",
|
||||
"settingsDescriptionBehavior",
|
||||
"settingsDescriptionInterface",
|
||||
"settingsDescriptionVoice",
|
||||
|
@ -77,6 +113,18 @@
|
|||
"zh": [
|
||||
"deleteChat",
|
||||
"renameChat",
|
||||
"modelDialogAddPromptTitle",
|
||||
"modelDialogAddPromptDescription",
|
||||
"modelDialogAddPromptAlreadyExists",
|
||||
"modelDialogAddPromptInvalid",
|
||||
"modelDialogAddAssuranceTitle",
|
||||
"modelDialogAddAssuranceDescription",
|
||||
"modelDialogAddAssuranceAdd",
|
||||
"modelDialogAddAssuranceCancel",
|
||||
"modelDialogAddDownloadPercentLoading",
|
||||
"modelDialogAddDownloadPercent",
|
||||
"modelDialogAddDownloadFailed",
|
||||
"modelDialogAddDownloadSuccess",
|
||||
"settingsDescriptionBehavior",
|
||||
"settingsDescriptionInterface",
|
||||
"settingsDescriptionVoice",
|
||||
|
|
Loading…
Reference in New Issue