diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index 1ca8bf8..4099065 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -9,6 +9,9 @@ on: release_name: description: 'Release name' required: true + release_notes: + description: 'Release Notes' + required: true jobs: build-and-release: @@ -21,7 +24,10 @@ jobs: env: RELEASE_NOTES: | **Notice:** By downloading and using the pre-built binaries, you agree to the app's [Terms and Conditions](https://github.com/1runeberg/confichat/blob/main/confichat/assets/TERMS_AND_CONDITIONS.md). Acceptance of these terms is implied upon download. The full Terms and Conditions are also available within the app under (Hamburger menu) > "Legal" > "Terms and Conditions". - + + + ${{ github.event.inputs.release_notes }} + steps: - uses: actions/checkout@v4.1.7 diff --git a/README.md b/README.md index f4d1b20..fcc706d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ In a nutshell, ConfiChat caters to users who value transparent control over thei ### 🛠️ 5. Compiling your own build -For those who prefer to compile ConfiChat themselves, or for macOS and iOS users, we provide detailed instructions in the [Compiling on your Own](docs/compiling.md) section. +For those who prefer to compile ConfiChat themselves, or for macOS and iOS users, we provide detailed instructions in the [Compiling on your own](docs/compiling.md) section. ### 🤝 6. Contributing diff --git a/confichat/.idea/workspace.xml b/confichat/.idea/workspace.xml index ea5d72d..638e1b6 100644 --- a/confichat/.idea/workspace.xml +++ b/confichat/.idea/workspace.xml @@ -10,10 +10,7 @@ - - - - + - { + "keyToString": { + "Flutter.main.dart.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.cidr.known.project.marker": "true", + "RunOnceActivity.readMode.enableVisualFormatting": "true", + "cf.first.check.clang-format": "false", + "cidr.known.project.marker": "true", + "dart.analysis.tool.window.visible": "false", + "io.flutter.reload.alreadyRun": "true", + "kotlin-language-version-configured": "true", + "last_opened_file_path": "E:/GitHub/confichat/confichat", + "show.migrate.to.gradle.popup": "false" } -}]]> +} diff --git a/confichat/lib/api_llamacpp.dart b/confichat/lib/api_llamacpp.dart new file mode 100644 index 0000000..78f4759 --- /dev/null +++ b/confichat/lib/api_llamacpp.dart @@ -0,0 +1,368 @@ +/* + * Copyright 2024 Rune Berg (http://runeberg.io | https://github.com/1runeberg) + * Licensed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) + * SPDX-License-Identifier: Apache-2.0 + */ + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'interfaces.dart'; +import 'package:intl/intl.dart'; + +import 'package:confichat/app_data.dart'; + + +class ApiLlamaCpp extends LlmApi{ + + static final ApiLlamaCpp _instance = ApiLlamaCpp._internal(); + static ApiLlamaCpp get instance => _instance; + + factory ApiLlamaCpp() { + return _instance; + } + + ApiLlamaCpp._internal() : super(AiProvider.llamacpp) { + + scheme = 'http'; + host = 'localhost'; + port = 8080; + path = '/v1'; + apiKey = ''; + + defaultTemperature = 1.0; + defaultProbability = 1.0; + defaultMaxTokens = 4096; + defaultStopSequences = []; + + temperature = 1.0; + probability = 1.0; + maxTokens = 4096; + stopSequences = []; + } + + // Implementations + @override + Future loadSettings() async { + final directory = AppData.instance.rootPath.isEmpty ? await getApplicationDocumentsDirectory() : Directory(AppData.instance.rootPath); + final filePath ='${directory.path}/${AppData.appStoragePath}/${AppData.appSettingsFile}'; + + if (await File(filePath).exists()) { + final fileContent = await File(filePath).readAsString(); + final Map settings = json.decode(fileContent); + + if (settings.containsKey(AiProvider.llamacpp.name)) { + + // Override values in memory from disk + scheme = settings[AiProvider.llamacpp.name]['scheme'] ?? 'http'; + host = settings[AiProvider.llamacpp.name]['host'] ?? 'localhost'; + port = settings[AiProvider.llamacpp.name]['port'] ?? 8080; + path = settings[AiProvider.llamacpp.name]['path'] ?? '/v1'; + apiKey = settings[AiProvider.llamacpp.name]['apikey'] ?? ''; + } + } + } + + @override + Future getModels(List outModels) async { + + try { + + // Add authorization header + final Map headers = {'Authorization': 'Bearer $apiKey'}; + if(apiKey.trim().isEmpty){ + headers['Authorization'] = 'Bearer no-key'; + } + + // Retrieve active models for provider + await getData(url: getUri('/models'), requestHeaders: headers); + + // Decode response + final Map jsonData = jsonDecode(responseData); + final List modelsJson = jsonData['data']; + + // Parse to ModelItem + for (var json in modelsJson) { + final String id = json['id']; + outModels.add(ModelItem(id, id)); + } + + } catch (e) { + // Catch and handle the FormatException + if (kDebugMode) { print('Unable to retrieve models ($host): $e\n $responseData'); } + } + + } + + @override + Future getCachedMessagesInModel(List outCachedMessages, String modelId) async { + } + + @override + Future loadModelToMemory(String modelId) async { + return; // model is loaded via llama-server -m (default is: models/7B/ggml-model-f16.gguf) + } + + @override + Future getModelInfo(ModelInfo outModelInfo, String modelId) async { + + // As of this writing, there doesn't appear to be an endpoint to probe model info, + // so we'll use general settings from models query + try { + + // Add authorization header + final Map headers = {'Authorization': 'Bearer $apiKey'}; + if(apiKey.trim().isEmpty){ + headers['Authorization'] = 'Bearer no-key'; + } + + // Send api request + await getData( + url: getUri('/models'), + requestHeaders: headers + ); + + // Decode response + final Map jsonData = jsonDecode(responseData); + final List modelsJson = jsonData['data']; + int? unixTimestamp; + + if(modelsJson.isNotEmpty) + { + outModelInfo.parentModel = modelsJson.first['id'] ?? ''; + unixTimestamp = modelsJson.first['created']; + outModelInfo.rootModel = ''; + } + + // Parse unix timestamp + if(unixTimestamp != null){ + final DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(unixTimestamp * 1000, isUtc: true); + final String formattedDate = DateFormat('yyyy-MMM-dd HH:mm:ss').format(dateTime); + outModelInfo.createdOn = '$formattedDate (UTC)'; + } else { + outModelInfo.createdOn = ''; + } + + } catch (e) { + // Catch and handle the FormatException + if (kDebugMode) { + print('Unable to retrieve models: $e\n ${AppData.instance.api.responseData}'); + } + } + + } + + @override + Future deleteModel(String modelId) async { + // todo: allow deletion of tuned models + } + + @override + Future sendPrompt({ + required String modelId, + required List> messages, + bool? getSummary, + Map? documents, + Map? codeFiles, + CallbackPassVoidReturnInt? onStreamRequestSuccess, + CallbackPassIntReturnBool? onStreamCancel, + CallbackPassIntChunkReturnVoid? onStreamChunkReceived, + CallbackPassIntReturnVoid? onStreamComplete, + CallbackPassDynReturnVoid? onStreamRequestError, + CallbackPassIntDynReturnVoid? onStreamingError + }) async { + try { + + // Set if this is a summary request + getSummary = getSummary ?? false; + + // Add documents if present + applyDocumentContext(messages: messages, documents: documents, codeFiles: codeFiles ); + + // Filter out empty stop sequences + List filteredStopSequences = stopSequences.where((s) => s.trim().isNotEmpty).toList(); + // Add authorization header + final Map headers = {'Authorization': 'Bearer $apiKey'}; + if(apiKey.trim().isEmpty){ + headers['Authorization'] = 'Bearer no-key'; + } + headers.addAll(AppData.headerJson); + + // Parse message for sending to chatgpt + List> apiMessages = []; + + for (var message in messages) { + List> contentList = []; + + // Add the text content + if (message['content'] != null && message['content'].isNotEmpty) { + contentList.add({ + "type": "text", + "text": message['content'], + }); + } + + // Add the images if any + if (message['images'] != null) { + for (var imageUrl in message['images']) { + contentList.add({ + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,$imageUrl"}, + }); + } + } + + apiMessages.add({ + "role": message['role'], + "content": contentList, + }); + } + + // Add summary prompt + if( getSummary ) { + apiMessages.add({ + "role": 'user', + "content": summaryPrompt, + }); + } + + // Assemble request + final request = http.Request('POST', getUri('/chat/completions')) + ..headers.addAll(headers); + + request.body = jsonEncode({ + 'model': modelId, + 'messages': apiMessages, + 'temperature': temperature, + 'top_p': probability, + 'max_tokens': maxTokens, + if (filteredStopSequences.isNotEmpty) 'stop': filteredStopSequences, + 'stream': true + }); + + // Send request and await streamed response + final response = await request.send(); + + // Check the status of the response + String currentDelimiter = ''; + if (response.statusCode == 200) { + + // Handle callback if any + int indexPayload = 0; + if(onStreamRequestSuccess != null) { indexPayload = onStreamRequestSuccess(); } + + // Listen for json object stream from api + StreamSubscription? streamSub; + streamSub = response.stream.transform(utf8.decoder).listen((chunk) { + + // Check if user requested a cancel + bool cancelRequested = onStreamCancel != null; + if(cancelRequested){ cancelRequested = onStreamCancel(indexPayload); } + if(cancelRequested){ + if(onStreamComplete != null) { onStreamComplete(indexPayload); } + currentDelimiter = ''; + streamSub?.cancel(); + return; + } + + // Check for end of stream + if (currentDelimiter.isNotEmpty && currentDelimiter.contains('<|im_end|>')) { + if(onStreamComplete != null) { onStreamComplete(indexPayload); } + currentDelimiter = ''; + streamSub?.cancel(); + return; + } + + // Handle callback (if any) + if(chunk.isNotEmpty) + { + // Uncomment for testing + //print(chunk); + + // Parse the JSON string + Map jsonMap = jsonDecode(chunk.substring(6)); + + // Extract the first choice + if (jsonMap.containsKey('choices') && jsonMap['choices'].isNotEmpty) { + var firstChoice = jsonMap['choices'][0]; + var delta = firstChoice['delta']; + + // Extract the content + if (delta.containsKey('content')) { + String content = delta['content']; + if (content.isNotEmpty && onStreamChunkReceived != null) { + + // Check for delimeters + if(currentDelimiter.isEmpty && content == '<') { + // Start delimeter + currentDelimiter = content; + } else if (currentDelimiter.isNotEmpty && content == '>'){ + // End delimeter + currentDelimiter += content; + + if(currentDelimiter == '<|im_start|>' || currentDelimiter.contains('<|im_start|>')){ + // content start + } else if(currentDelimiter == '<|im_end|>' || currentDelimiter.contains('<|im_end|>')){ + // content end + if(onStreamComplete != null) { onStreamComplete(indexPayload); } + currentDelimiter = ''; + streamSub?.cancel(); + return; + } else { + // not actually a delimeter + onStreamChunkReceived(indexPayload, StreamChunk(currentDelimiter)); + } + + currentDelimiter = ''; + + } else if (currentDelimiter.isNotEmpty && currentDelimiter.contains('<|im_start|>') ) { + currentDelimiter = ''; + + } else if (currentDelimiter.isNotEmpty && currentDelimiter.contains('<|im_end|>') ) { + if(onStreamComplete != null) { onStreamComplete(indexPayload); } + currentDelimiter = ''; + streamSub?.cancel(); + return; + + } else if (currentDelimiter.isNotEmpty + && !currentDelimiter.contains('<|im_start|>') + && !currentDelimiter.contains('<|im_end|>')) { + currentDelimiter += content; + + } else { + onStreamChunkReceived(indexPayload, StreamChunk(content)); + currentDelimiter = ''; + } + } + } + } + + } + + }, onDone: () { + + if(onStreamComplete != null) { onStreamComplete(indexPayload); } + + }, onError: (error) { + + if (kDebugMode) {print('Streamed data request failed with error: $error');} + if(onStreamingError != null) { onStreamingError(indexPayload, error); } + }); + + } else { + if (kDebugMode) {print('Streamed data request failed with status: ${response.statusCode}\n');} + if(onStreamRequestError != null) { onStreamRequestError(response.statusCode); } + } + } catch (e) { + if (kDebugMode) { + print('Unable to get chat response: $e\n $responseData'); + } + } + + } + +} // ApiLlamaCpp diff --git a/confichat/lib/api_ollama.dart b/confichat/lib/api_ollama.dart index f8a50c1..f0a2e03 100644 --- a/confichat/lib/api_ollama.dart +++ b/confichat/lib/api_ollama.dart @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -150,7 +151,7 @@ class ApiOllama extends LlmApi{ ); // Decode response - Map jsonData = jsonDecode(AppData.instance.api.responseData); + Map jsonData = jsonDecode(responseData); if (jsonData.containsKey('details')) { Map details = jsonData['details']; @@ -215,10 +216,11 @@ class ApiOllama extends LlmApi{ Map? documents, Map? codeFiles, CallbackPassVoidReturnInt? onStreamRequestSuccess, + CallbackPassIntReturnBool? onStreamCancel, CallbackPassIntChunkReturnVoid? onStreamChunkReceived, CallbackPassIntReturnVoid? onStreamComplete, CallbackPassDynReturnVoid? onStreamRequestError, - CallbackPassDynReturnVoid? onStreamingError + CallbackPassIntDynReturnVoid? onStreamingError }) async { try { @@ -270,7 +272,17 @@ class ApiOllama extends LlmApi{ if(onStreamRequestSuccess != null) { indexPayload = onStreamRequestSuccess(); } // Listen for json object stream from api - response.stream.transform(utf8.decoder).listen((chunk) { + StreamSubscription? streamSub; + streamSub = response.stream.transform(utf8.decoder).listen((chunk) { + + // Check if user requested a cancel + bool cancelRequested = onStreamCancel != null; + if(cancelRequested){ cancelRequested = onStreamCancel(indexPayload); } + if(cancelRequested){ + if(onStreamComplete != null) { onStreamComplete(indexPayload); } + streamSub?.cancel(); + return; + } // Handle callback (if any) if(onStreamChunkReceived != null) @@ -298,7 +310,7 @@ class ApiOllama extends LlmApi{ }, onError: (error) { if (kDebugMode) {print('Streamed data request failed with error: $error');} - if(onStreamingError != null) { onStreamingError(error); } + if(onStreamingError != null) { onStreamingError(indexPayload, error); } }); } else { diff --git a/confichat/lib/api_openai.dart b/confichat/lib/api_openai.dart index 8abcf9a..cf1511b 100644 --- a/confichat/lib/api_openai.dart +++ b/confichat/lib/api_openai.dart @@ -114,7 +114,7 @@ class ApiChatGPT extends LlmApi{ ); // Decode response - Map jsonData = jsonDecode(AppData.instance.api.responseData); + Map jsonData = jsonDecode(responseData); outModelInfo.parameterSize = ''; outModelInfo.parentModel = jsonData['id']; outModelInfo.quantizationLevel = ''; @@ -155,10 +155,11 @@ class ApiChatGPT extends LlmApi{ Map? documents, Map? codeFiles, CallbackPassVoidReturnInt? onStreamRequestSuccess, + CallbackPassIntReturnBool? onStreamCancel, CallbackPassIntChunkReturnVoid? onStreamChunkReceived, CallbackPassIntReturnVoid? onStreamComplete, CallbackPassDynReturnVoid? onStreamRequestError, - CallbackPassDynReturnVoid? onStreamingError + CallbackPassIntDynReturnVoid? onStreamingError }) async { try { @@ -238,12 +239,22 @@ class ApiChatGPT extends LlmApi{ if(onStreamRequestSuccess != null) { indexPayload = onStreamRequestSuccess(); } // Listen for json object stream from api - response.stream + StreamSubscription? streamSub; + streamSub = response.stream .transform(utf8.decoder) .transform(const LineSplitter()) // Split by lines .transform(SseTransformer()) // Transform into SSE events .listen((chunk) { + // Check if user requested a cancel + bool cancelRequested = onStreamCancel != null; + if(cancelRequested){ cancelRequested = onStreamCancel(indexPayload); } + if(cancelRequested){ + if(onStreamComplete != null) { onStreamComplete(indexPayload); } + streamSub?.cancel(); + return; + } + // Handle callback (if any) if(chunk.isNotEmpty) { @@ -276,7 +287,7 @@ class ApiChatGPT extends LlmApi{ }, onError: (error) { if (kDebugMode) {print('Streamed data request failed with error: $error');} - if(onStreamingError != null) { onStreamingError(error); } + if(onStreamingError != null) { onStreamingError(indexPayload, error); } }); } else { diff --git a/confichat/lib/app_data.dart b/confichat/lib/app_data.dart index f7fe312..0beb4b2 100644 --- a/confichat/lib/app_data.dart +++ b/confichat/lib/app_data.dart @@ -21,8 +21,10 @@ class AppData { static AppData get instance => _instance; late CallbackSwitchProvider callbackSwitchProvider; + late final GlobalKey navigatorKey; AppData._internal() : super() { - callbackSwitchProvider = defaultCallback; + callbackSwitchProvider = defaultCallback; + navigatorKey = GlobalKey(); } // Class vars @@ -57,6 +59,9 @@ class AppData { case AiProvider.openai: api = LlmApiFactory.create(AiProvider.openai.name); break; + case AiProvider.llamacpp: + api = LlmApiFactory.create(AiProvider.llamacpp.name); + break; default: if (kDebugMode) { print('Unknown AI provider.'); } } @@ -82,7 +87,8 @@ class AppData { enum AiProvider { ollama('Ollama', 0), - openai('OpenAI', 1); + openai('OpenAI', 1), + llamacpp('LlamaCpp', 2); final String name; final int id; diff --git a/confichat/lib/factories.dart b/confichat/lib/factories.dart index dc8a538..d03220d 100644 --- a/confichat/lib/factories.dart +++ b/confichat/lib/factories.dart @@ -5,6 +5,7 @@ */ +import 'package:confichat/api_llamacpp.dart'; import 'package:confichat/api_openai.dart'; import 'package:confichat/api_ollama.dart'; import 'package:confichat/interfaces.dart'; @@ -16,6 +17,8 @@ class LlmApiFactory { return ApiOllama(); case 'openai': return ApiChatGPT(); + case 'llamacpp': + return ApiLlamaCpp(); default: throw Exception('Unsupported API provider: $apiProvider'); } diff --git a/confichat/lib/interfaces.dart b/confichat/lib/interfaces.dart index d03e8f9..aac9461 100644 --- a/confichat/lib/interfaces.dart +++ b/confichat/lib/interfaces.dart @@ -14,9 +14,11 @@ import 'package:confichat/app_data.dart'; typedef CallbackPassVoidReturnInt = int Function(); typedef CallbackPassIntReturnVoid = Function(int); +typedef CallbackPassIntReturnBool = bool Function(int); typedef CallbackPassIntStringReturnVoid = Function(int, String); typedef CallbackPassIntChunkReturnVoid = Function(int, StreamChunk); typedef CallbackPassDynReturnVoid = Function(dynamic); +typedef CallbackPassIntDynReturnVoid = Function(int, dynamic); class StreamChunk{ @@ -67,10 +69,11 @@ abstract class LlmApi { Map? documents, Map? codeFiles, CallbackPassVoidReturnInt? onStreamRequestSuccess, + CallbackPassIntReturnBool? onStreamCancel, CallbackPassIntChunkReturnVoid? onStreamChunkReceived, CallbackPassIntReturnVoid? onStreamComplete, CallbackPassDynReturnVoid? onStreamRequestError, - CallbackPassDynReturnVoid? onStreamingError + CallbackPassIntDynReturnVoid? onStreamingError }); // Concrete functions diff --git a/confichat/lib/main.dart b/confichat/lib/main.dart index f9122ad..b8b67aa 100644 --- a/confichat/lib/main.dart +++ b/confichat/lib/main.dart @@ -76,6 +76,7 @@ class ConfiChat extends StatelessWidget { return Consumer( builder: (context, themeProvider, child) { return MaterialApp( + navigatorKey: AppData.instance.navigatorKey, title: AppData.appTitle, theme: themeProvider.currentTheme, home: HomePage(appData: AppData.instance), @@ -117,9 +118,7 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - final ChatSessionSelectedNotifier chatSessionSelectedNotifier = ChatSessionSelectedNotifier(); - TextEditingController providerController = TextEditingController(); AiProvider? selectedProvider; diff --git a/confichat/lib/ui_canvass.dart b/confichat/lib/ui_canvass.dart index 3cf5e24..abbfe77 100644 --- a/confichat/lib/ui_canvass.dart +++ b/confichat/lib/ui_canvass.dart @@ -55,6 +55,7 @@ class CanvassState extends State { List base64Images = []; Map documents = {}; Map codeFiles = {}; + Map processingData = {}; @override @@ -158,13 +159,22 @@ class CanvassState extends State { itemBuilder: (context, index) { int currentIndex = (chatData.length - 1) - index; - return ChatBubble( - isUser: chatData[currentIndex]['role'] == 'user', - textData: chatData[currentIndex]['role'] == 'system' ? "!system_prompt_ignore" : chatData[currentIndex]['content'], - images: chatData[currentIndex]['images'], - documents: chatDocuments.containsKey(currentIndex) ? chatDocuments[currentIndex] : null, - codeFiles: chatCodeFiles.containsKey(currentIndex) ? chatCodeFiles[currentIndex] : null, - ); + bool isProcessing = processingData.containsKey(currentIndex) && processingData.containsKey(currentIndex); + + return ChatBubble( + isUser: chatData[currentIndex]['role'] == 'user', + animateIcon: isProcessing, + fnCancelProcessing: isProcessing ? _cancelProcessing : null, + indexProcessing: (isProcessing && (processingData[currentIndex] != null && processingData[currentIndex]!)) ? currentIndex : null, + textData: chatData[currentIndex]['role'] == 'system' ? "!system_prompt_ignore" : chatData[currentIndex]['content'], + images:chatData[currentIndex]['images'] != null + ? (chatData[currentIndex]['images'] as List) + .map((item) => item as String) + .toList() + : null, + documents: chatDocuments.containsKey(currentIndex) ? chatDocuments[currentIndex] : null, + codeFiles: chatCodeFiles.containsKey(currentIndex) ? chatCodeFiles[currentIndex] : null, + ); } ) @@ -257,7 +267,7 @@ class CanvassState extends State { controller: _promptController, focusNode: _focusNodePrompt, decoration: const InputDecoration( - labelText: 'Prompt (you can drag-and-drop files below)', + labelText: 'Prompt', alignLabelWithHint: true, ), minLines: 1, @@ -527,14 +537,16 @@ class CanvassState extends State { // Set chat history (encrypted) final messageHistory = jsonData['messages']; - await DecryptDialog.showDecryptDialog( - // ignore: use_build_context_synchronously - context: context, - systemPrompt: options['systemPrompt'] ?? '', - base64IV: iv, - chatData: chatData, - jsonData: messageHistory, - decryptContent: _decryptData ); + + if(mounted){ + await DecryptDialog.showDecryptDialog( + // ignore: use_build_context_synchronously + systemPrompt: options['systemPrompt'] ?? '', + base64IV: iv, + chatData: chatData, + jsonData: messageHistory, + decryptContent: _decryptData ); + } } else { @@ -628,16 +640,31 @@ class CanvassState extends State { final selectedModelProvider = Provider.of(context, listen: false); final selectedModelName = selectedModelProvider.selectedModel?.name ?? 'Unknown Model'; - await widget.appData.api.sendPrompt( - modelId: selectedModelName, - messages: chatData, - documents: documents, - codeFiles: codeFiles, - onStreamRequestSuccess: _onChatRequestSuccess, - onStreamChunkReceived: _onChatChunkReceived, - onStreamComplete: _onChatStreamComplete - ); + widget.appData.api.sendPrompt( + modelId: selectedModelName, + messages: chatData, + documents: documents, + codeFiles: codeFiles, + onStreamRequestSuccess: _onChatRequestSuccess, + onStreamCancel: _onChatStreamCancel, + onStreamChunkReceived: _onChatChunkReceived, + onStreamComplete: _onChatStreamComplete, + onStreamRequestError: _onChatRequestError, + onStreamingError: _onChatStreamError + ); + + setState(() { + // Clear files + base64Images.clear(); + documents.clear(); + codeFiles.clear(); + // Add placeholder + chatData.add({'role': 'assistant', 'content': ''}); + int index = chatData.length - 1; + processingData[index] = false; // disable cancel button until our request goes through + _scrollToBottom(); + }); } Future _loadModelToMemory() async { @@ -650,17 +677,58 @@ class CanvassState extends State { } int _onChatRequestSuccess(){ + int chatDataIndex = chatData.length - 1; + + if(processingData.containsKey(chatDataIndex)) { + processingData[chatDataIndex] = true; + } + return chatDataIndex; + } + + void _onChatRequestError( dynamic error ){ + + // Check and remove placeholder + int index = chatData.length - 1; setState(() { - // Clear files - base64Images.clear(); - documents.clear(); - codeFiles.clear(); + if(chatData[index]['role'] == 'assistant' && chatData[index]['content'].isEmpty ){ + chatData.removeAt(index); - chatData.add({'role': 'assistant', 'content': ''}); - _scrollToBottom(); + if(processingData.containsKey(index)){ processingData.remove(index); } + _scrollToBottom(); + } }); - return chatData.length - 1; + final String errorMessage = error.toString(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(backgroundColor: Theme.of(context).colorScheme.primaryContainer, content: Text('Error requesting chat completion: $errorMessage')), + ); + } + + dynamic _onChatStreamError( int index, dynamic error ){ + _onChatStreamComplete(index); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(backgroundColor: Theme.of(context).colorScheme.primaryContainer, content: Text('Error encountered in response stream: $error')), + ); + } + + void _cancelProcessing(int index){ + if(processingData.containsKey(index)) { + processingData[index] = false; + } + } + + bool _onChatStreamCancel(int index){ + // No cancel request received for this response + if(!processingData.containsKey(index) || processingData[index] == true){ + return false; + } + + // Cancel requested - api should call chatStreamComplete callback + if(processingData.containsKey(index) && processingData[index] == false){ + return true; + } + + return false; } void _onChatChunkReceived(int index, StreamChunk chunk){ @@ -674,7 +742,18 @@ class CanvassState extends State { } void _onChatStreamComplete(int index){ - //FocusScope.of(context).requestFocus(_focusNode_Prompt); + setState(() { + // Remove processing indicator + processingData.remove(index); + + // Handle empty response + if( chatData[index]['content'].isEmpty ) { + chatData[index]['content'] = '*...*'; + } + + // Force refresh list + _scrollToBottom(); + }); } Widget _buildFileChip(String fileName, String fileType) { @@ -718,7 +797,9 @@ class CanvassState extends State { if (result != null) { // ignore: use_build_context_synchronously - await FileParser.processPlatformFiles(files: result.files, context: context, outImages: base64Images, outDocuments: documents, outCodeFiles: codeFiles ); + setState(() { + FileParser.processPlatformFiles(files: result.files, context: context, outImages: base64Images, outDocuments: documents, outCodeFiles: codeFiles ); + }); } } catch (e) { if (kDebugMode) {print("Error picking files: $e");} @@ -852,7 +933,6 @@ class CanvassState extends State { class DecryptDialog { static Future showDecryptDialog({ - required BuildContext context, required String systemPrompt, required String base64IV, required dynamic jsonData, @@ -869,7 +949,7 @@ class DecryptDialog { final TextEditingController keyController = TextEditingController(); return showDialog( - context: context, + context: AppData.instance.navigatorKey.currentContext!, barrierDismissible: false, builder: (BuildContext context) { diff --git a/confichat/lib/ui_llamacpp_options.dart b/confichat/lib/ui_llamacpp_options.dart new file mode 100644 index 0000000..f658fc9 --- /dev/null +++ b/confichat/lib/ui_llamacpp_options.dart @@ -0,0 +1,262 @@ +/* + * Copyright 2024 Rune Berg (http://runeberg.io | https://github.com/1runeberg) + * Licensed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) + * SPDX-License-Identifier: Apache-2.0 + */ + +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:confichat/ui_widgets.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:confichat/app_data.dart'; + + +class LlamaCppOptions extends StatefulWidget { + final AppData appData; + + const LlamaCppOptions({super.key, required this.appData}); + + @override + LlamaCppOptionsState createState() => LlamaCppOptionsState(); +} + +class LlamaCppOptionsState extends State { + final TextEditingController _schemeController = TextEditingController(); + final TextEditingController _hostController = TextEditingController(); + final TextEditingController _portController = TextEditingController(); + final TextEditingController _pathController = TextEditingController(); + final TextEditingController _apiKeyController = TextEditingController(); + + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _loadSettings(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _schemeController.dispose(); + _hostController.dispose(); + _portController.dispose(); + _pathController.dispose(); + _apiKeyController.dispose(); + + _focusNode.dispose(); + super.dispose(); + } + + Future _loadSettings() async { + final directory = AppData.instance.rootPath.isEmpty ? await getApplicationDocumentsDirectory() : Directory(AppData.instance.rootPath); + final filePath ='${directory.path}/${AppData.appStoragePath}/${AppData.appSettingsFile}'; + + if (await File(filePath).exists()) { + final fileContent = await File(filePath).readAsString(); + final Map settings = json.decode(fileContent); + + if (settings.containsKey(AiProvider.llamacpp.name) && AppData.instance.api.aiProvider.name == AiProvider.llamacpp.name) { + + // Set the form text + _schemeController.text = settings[AiProvider.llamacpp.name]['scheme'] ?? 'http'; + _hostController.text = settings[AiProvider.llamacpp.name]['host'] ?? 'localhost'; + _portController.text = settings[AiProvider.llamacpp.name]['port']?.toString() ?? '8080'; + _pathController.text = settings[AiProvider.llamacpp.name]['path'] ?? '/v1'; + _apiKeyController.text = settings[AiProvider.llamacpp.name]['apikey'] ?? ''; + _applySettings(); + + } else { + _useDefaultSettings(); + } + } else { + _useDefaultSettings(); + } + } + + void _useDefaultSettings() { + _schemeController.text = 'http'; + _hostController.text = 'localhost'; + _portController.text = '8080'; + _pathController.text = '/v1'; + + _applySettings(); + } + + void _applySettings() { + if(widget.appData.api.aiProvider.name == AiProvider.llamacpp.name) { + AppData.instance.api.scheme = _schemeController.text; + AppData.instance.api.host = _hostController.text; + AppData.instance.api.port = int.tryParse(_portController.text) ?? 8080; + AppData.instance.api.path = _pathController.text; + AppData.instance.api.apiKey = _apiKeyController.text; + } + } + + Future _saveSettings() async { + final directory = AppData.instance.rootPath.isEmpty ? await getApplicationDocumentsDirectory() : Directory(AppData.instance.rootPath); + final filePath = '${directory.path}/${AppData.appStoragePath}/${AppData.appSettingsFile}'; + + final newSetting = { + 'scheme': _schemeController.text, + 'host': _hostController.text, + 'port': int.tryParse(_portController.text) ?? 11434, + 'path': _pathController.text, + 'apikey': _apiKeyController.text, + }; + + Map settings; + final file = File(filePath); + + if (await file.exists()) { + final content = await file.readAsString(); + settings = json.decode(content) as Map; + + if (settings.containsKey(AiProvider.llamacpp.name)) { + settings[AiProvider.llamacpp.name] = newSetting; + } else { + settings[AiProvider.llamacpp.name] = newSetting; + } + } else { + settings = { AiProvider.llamacpp.name: newSetting }; + } + + // Set in-memory values + _applySettings(); + + // Save the updated settings to disk + await file.create(recursive: true); + await file.writeAsString(const JsonEncoder.withIndent(' ').convert(settings)); + + // Reset model values + if(widget.appData.api.aiProvider.name == AiProvider.llamacpp.name) { + AppData.instance.callbackSwitchProvider(AiProvider.llamacpp); + } + + // Close window + if (mounted) { + Navigator.of(context).pop(); + } + } + + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + // Window title + DialogTitle(title: '${AiProvider.llamacpp.name} Options'), + const SizedBox(height: 24), + + + ConstrainedBox( constraints: + BoxConstraints( + minWidth: 300, + maxHeight: widget.appData.getUserDeviceType(context) != UserDeviceType.phone ? 400 : 250, + ), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, child: Column ( children: [ + + // Scheme + const SizedBox(height: 16), + TextField( + controller: _schemeController, + decoration: InputDecoration( + labelText: 'Scheme', + labelStyle: Theme.of(context).textTheme.labelSmall, + border: const UnderlineInputBorder(), + ), + ), + + // Host + const SizedBox(height: 16), + TextField( + controller: _hostController, + decoration: InputDecoration( + labelText: 'Host', + labelStyle: Theme.of(context).textTheme.labelSmall, + border: const UnderlineInputBorder(), + ), + ), + + // Port + const SizedBox(height: 16), + TextField( + controller: _portController, + decoration: InputDecoration( + labelText: 'Port', + labelStyle: Theme.of(context).textTheme.labelSmall, + border: const UnderlineInputBorder(), + ), + ), + + // Path + const SizedBox(height: 16), + TextField( + controller: _pathController, + decoration: InputDecoration( + labelText: 'Path', + labelStyle: Theme.of(context).textTheme.labelSmall, + border: const UnderlineInputBorder(), + ), + ), + + // API Key + const SizedBox(height: 16), + TextField( + controller: _apiKeyController, + decoration: InputDecoration( + labelText: 'API Key', + labelStyle: Theme.of(context).textTheme.labelSmall, + border: const UnderlineInputBorder(), + ), + ), + + ]))), + + // Buttons + const SizedBox(height: 16), + Align( + alignment: Alignment.bottomRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + + ElevatedButton( + onPressed: () async { + await _saveSettings(); + }, + child: const Text('Save'), + ), + + const SizedBox(width: 8), + ElevatedButton( + focusNode: _focusNode, + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + +} diff --git a/confichat/lib/ui_ollama_options.dart b/confichat/lib/ui_ollama_options.dart index 4b44a18..bd8edff 100644 --- a/confichat/lib/ui_ollama_options.dart +++ b/confichat/lib/ui_ollama_options.dart @@ -125,7 +125,7 @@ class OllamaOptionsState extends State { // Save the updated settings to disk await file.create(recursive: true); - await file.writeAsString(json.encode(settings)); + await file.writeAsString(const JsonEncoder.withIndent(' ').convert(settings)); // Reset model values if(widget.appData.api.aiProvider.name == AiProvider.ollama.name) { diff --git a/confichat/lib/ui_chatgpt_options.dart b/confichat/lib/ui_openai_options.dart similarity index 95% rename from confichat/lib/ui_chatgpt_options.dart rename to confichat/lib/ui_openai_options.dart index 24e1258..588a3a1 100644 --- a/confichat/lib/ui_chatgpt_options.dart +++ b/confichat/lib/ui_openai_options.dart @@ -1,185 +1,185 @@ -/* - * Copyright 2024 Rune Berg (http://runeberg.io | https://github.com/1runeberg) - * Licensed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) - * SPDX-License-Identifier: Apache-2.0 - */ - -import 'dart:io'; -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:confichat/ui_widgets.dart'; -import 'package:path_provider/path_provider.dart'; - -import 'package:confichat/app_data.dart'; - - -class ChatGPTOptions extends StatefulWidget { - final AppData appData; - - const ChatGPTOptions({super.key, required this.appData}); - - @override - ChatGPTOptionsState createState() => ChatGPTOptionsState(); -} - -class ChatGPTOptionsState extends State { - final TextEditingController _apiKeyController = TextEditingController(); - final FocusNode _focusNode = FocusNode(); - - @override - void initState() { - super.initState(); - _loadSettings(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - } - - @override - void dispose() { - _apiKeyController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - Future _loadSettings() async { - final directory = AppData.instance.rootPath.isEmpty ? await getApplicationDocumentsDirectory() : Directory(AppData.instance.rootPath); - final filePath ='${directory.path}/${AppData.appStoragePath}/${AppData.appSettingsFile}'; - - if (await File(filePath).exists()) { - final fileContent = await File(filePath).readAsString(); - final Map settings = json.decode(fileContent); - - if (settings.containsKey(AiProvider.openai.name)) { - - // Set the form text - _apiKeyController.text = settings[AiProvider.openai.name]['apikey'] ?? ''; - - if(widget.appData.api.aiProvider.name == AiProvider.openai.name){ _applyValues(); } - - } else { - _useDefaultSettings(); - } - } else { - _useDefaultSettings(); - } - } - - void _useDefaultSettings() { - //_apiKeyController.text = ''; - _applyValues(); - } - - void _applyValues() { - if(widget.appData.api.aiProvider.name == AiProvider.openai.name) { - AppData.instance.api.apiKey = _apiKeyController.text; } - } - - Future _saveSettings() async { - // Set file path - final directory = AppData.instance.rootPath.isEmpty ? await getApplicationDocumentsDirectory() : Directory(AppData.instance.rootPath); - final filePath = '${directory.path}/${AppData.appStoragePath}/${AppData.appSettingsFile}'; - - // Set new valuie - final newSetting = { - 'apikey': _apiKeyController.text, - }; - - // Save to disk - Map settings; - final file = File(filePath); - - if (await file.exists()) { - // If the file exists, read the content and parse it - final content = await file.readAsString(); - settings = json.decode(content) as Map; - - // Check if the object name exists, and update it - if (settings.containsKey(AiProvider.openai.name)) { - settings[AiProvider.openai.name] = newSetting; - } else { - settings[AiProvider.openai.name] = newSetting; - } - } else { - settings = { AiProvider.openai.name: newSetting }; - } - - // Update in-memory values - _applyValues(); - - // Save the updated settings to disk - await file.create(recursive: true); - await file.writeAsString(json.encode(settings)); - - // Reset model values - if(widget.appData.api.aiProvider.name == AiProvider.openai.name) { - AppData.instance.callbackSwitchProvider(AiProvider.openai); - } - - // Close window - if (mounted) { - Navigator.of(context).pop(); - } - } - - - @override - Widget build(BuildContext context) { - return Dialog( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - - // Window title - DialogTitle(title: '${AiProvider.openai.name} Options'), - const SizedBox(height: 24), - - TextField( - controller: _apiKeyController, - decoration: InputDecoration( - labelText: 'API Key', - labelStyle: Theme.of(context).textTheme.labelSmall, - border: const UnderlineInputBorder(), - ), - ), - - const SizedBox(height: 16), - Align( - alignment: Alignment.bottomRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - - ElevatedButton( - onPressed: () async { - await _saveSettings(); - }, - child: const Text('Save'), - ), - - const SizedBox(width: 8), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - focusNode: _focusNode, - child: const Text('Cancel'), - ), - - ], - ), - ), - ], - ), - ), - ), - ); - } - -} +/* + * Copyright 2024 Rune Berg (http://runeberg.io | https://github.com/1runeberg) + * Licensed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) + * SPDX-License-Identifier: Apache-2.0 + */ + +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:confichat/ui_widgets.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:confichat/app_data.dart'; + + +class ChatGPTOptions extends StatefulWidget { + final AppData appData; + + const ChatGPTOptions({super.key, required this.appData}); + + @override + ChatGPTOptionsState createState() => ChatGPTOptionsState(); +} + +class ChatGPTOptionsState extends State { + final TextEditingController _apiKeyController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _loadSettings(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _apiKeyController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _loadSettings() async { + final directory = AppData.instance.rootPath.isEmpty ? await getApplicationDocumentsDirectory() : Directory(AppData.instance.rootPath); + final filePath ='${directory.path}/${AppData.appStoragePath}/${AppData.appSettingsFile}'; + + if (await File(filePath).exists()) { + final fileContent = await File(filePath).readAsString(); + final Map settings = json.decode(fileContent); + + if (settings.containsKey(AiProvider.openai.name)) { + + // Set the form text + _apiKeyController.text = settings[AiProvider.openai.name]['apikey'] ?? ''; + + if(widget.appData.api.aiProvider.name == AiProvider.openai.name){ _applyValues(); } + + } else { + _useDefaultSettings(); + } + } else { + _useDefaultSettings(); + } + } + + void _useDefaultSettings() { + //_apiKeyController.text = ''; + _applyValues(); + } + + void _applyValues() { + if(widget.appData.api.aiProvider.name == AiProvider.openai.name) { + AppData.instance.api.apiKey = _apiKeyController.text; } + } + + Future _saveSettings() async { + // Set file path + final directory = AppData.instance.rootPath.isEmpty ? await getApplicationDocumentsDirectory() : Directory(AppData.instance.rootPath); + final filePath = '${directory.path}/${AppData.appStoragePath}/${AppData.appSettingsFile}'; + + // Set new valuie + final newSetting = { + 'apikey': _apiKeyController.text, + }; + + // Save to disk + Map settings; + final file = File(filePath); + + if (await file.exists()) { + // If the file exists, read the content and parse it + final content = await file.readAsString(); + settings = json.decode(content) as Map; + + // Check if the object name exists, and update it + if (settings.containsKey(AiProvider.openai.name)) { + settings[AiProvider.openai.name] = newSetting; + } else { + settings[AiProvider.openai.name] = newSetting; + } + } else { + settings = { AiProvider.openai.name: newSetting }; + } + + // Update in-memory values + _applyValues(); + + // Save the updated settings to disk + await file.create(recursive: true); + await file.writeAsString(const JsonEncoder.withIndent(' ').convert(settings)); + + // Reset model values + if(widget.appData.api.aiProvider.name == AiProvider.openai.name) { + AppData.instance.callbackSwitchProvider(AiProvider.openai); + } + + // Close window + if (mounted) { + Navigator.of(context).pop(); + } + } + + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + // Window title + DialogTitle(title: '${AiProvider.openai.name} Options'), + const SizedBox(height: 24), + + TextField( + controller: _apiKeyController, + decoration: InputDecoration( + labelText: 'API Key', + labelStyle: Theme.of(context).textTheme.labelSmall, + border: const UnderlineInputBorder(), + ), + ), + + const SizedBox(height: 16), + Align( + alignment: Alignment.bottomRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + + ElevatedButton( + onPressed: () async { + await _saveSettings(); + }, + child: const Text('Save'), + ), + + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + focusNode: _focusNode, + child: const Text('Cancel'), + ), + + ], + ), + ), + ], + ), + ), + ), + ); + } + +} diff --git a/confichat/lib/ui_save_session.dart b/confichat/lib/ui_save_session.dart index 743d15e..d3a57d6 100644 --- a/confichat/lib/ui_save_session.dart +++ b/confichat/lib/ui_save_session.dart @@ -8,7 +8,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'dart:convert'; import 'package:confichat/app_data.dart'; @@ -102,10 +101,18 @@ class SaveChatSessionState extends State { ); } + // Cleanup filename + final RegExp pattern = RegExp(r'[<>:"/\\|?*\x00-\x1F]'); + + // Replace all matches of the pattern with an underscore + String cleaned = _sessionNameController.text.replaceAll(pattern, ''); + + // Trim leading and trailing spaces and dots + cleaned = cleaned.trim().replaceAll(RegExp(r'^\.+|\.+$'), ''); + // Check encryption params if (_encrypt && _encryptionKeyController.text.isNotEmpty - && _confirmKeyController.text.isNotEmpty && _encryptionKeyController.text != _confirmKeyController.text ){ ScaffoldMessenger.of(context).showSnackBar( @@ -130,7 +137,7 @@ class SaveChatSessionState extends State { } // Save the messages as a json object/array - String fileName = _sessionNameController.text.trim(); + String fileName = cleaned; String content = jsonEncode(widget.chatData); try { @@ -239,15 +246,6 @@ class SaveChatSessionState extends State { ), maxLines: 3, maxLength: 150, - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'^[a-zA-Z0-9]([a-zA-Z0-9 _\-]*(?!\.\.))*[a-zA-Z0-9 _\-.]?$'), - ), - // Legend: - // ^[a-zA-Z0-9] - Starts with an alphanumeric character - // ([a-zA-Z0-9 _\-]*(?!\.\.))* - Allows alphanumeric, space, _, -, . but no consecutive periods - // [a-zA-Z0-9 _\-.] - Ends with an alphanumeric character and allowed special chars - ], validator: (value) { if (value == null || value.isEmpty) { return 'Please enter a name (this will also be used as the filename)'; diff --git a/confichat/lib/ui_sidebar.dart b/confichat/lib/ui_sidebar.dart index d8845c8..2e227c3 100644 --- a/confichat/lib/ui_sidebar.dart +++ b/confichat/lib/ui_sidebar.dart @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import 'package:confichat/ui_llamacpp_options.dart'; import 'package:confichat/ui_terms_and_conditions.dart'; import 'package:flutter/material.dart'; @@ -13,7 +14,7 @@ import 'package:confichat/app_data.dart'; import 'package:confichat/chat_notifiers.dart'; import 'package:confichat/persistent_storage.dart'; import 'package:confichat/ui_app_settings.dart'; -import 'package:confichat/ui_chatgpt_options.dart'; +import 'package:confichat/ui_openai_options.dart'; import 'package:confichat/ui_ollama_options.dart'; import 'package:confichat/ui_widgets.dart'; import 'package:path_provider/path_provider.dart'; @@ -200,7 +201,21 @@ class SidebarState extends State { }, ), - // (2.2.3) App settings + // (2.2.3) LlamaCpp options + ListTile( + title: Text(AiProvider.llamacpp.name), + onTap: () { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return LlamaCppOptions(appData: widget.appData); + }, + ); + }, + ), + + // (2.2.4) App settings ListTile( title: const Text('Application settings'), onTap: () { diff --git a/confichat/lib/ui_widgets.dart b/confichat/lib/ui_widgets.dart index 2969f18..0407ee6 100644 --- a/confichat/lib/ui_widgets.dart +++ b/confichat/lib/ui_widgets.dart @@ -340,7 +340,10 @@ class CodePreviewBuilder extends MarkdownElementBuilder { class ChatBubble extends StatelessWidget { final String textData; final bool isUser; + final bool animateIcon; + final Function(int)? fnCancelProcessing; + final int? indexProcessing; final List? images; final Iterable? documents; final Iterable? codeFiles; @@ -348,7 +351,10 @@ class ChatBubble extends StatelessWidget { const ChatBubble({ super.key, required this.isUser, + required this.animateIcon, required this.textData, + this.fnCancelProcessing, + this.indexProcessing, this.images, this.documents, this.codeFiles @@ -402,7 +408,16 @@ class ChatBubble extends StatelessWidget { // Icon const SizedBox(width:10), - Icon( + + // Animated icon + if(!isUser && animateIcon) const AnimIconColorFade( + icon: Icons.psychology, + size: 24.0, + duration: 2 + ), + + // Regular icon + if(!animateIcon) Icon( isUser? Icons.person : Icons.psychology, color: Colors.grey, size: 24.0, @@ -430,11 +445,28 @@ class ChatBubble extends StatelessWidget { ) ) ), + // Cancel + if (!isUser && animateIcon && indexProcessing != null) + Container( + constraints: const BoxConstraints( + maxWidth: 50, + ), + child: IconButton( + icon: const Icon(Icons.cancel), + onPressed: indexProcessing == null ? null : () { + if (fnCancelProcessing != null && indexProcessing != null) { + fnCancelProcessing!(indexProcessing!); + } + } + ), + ), + + // Images if (images != null && images!.isNotEmpty) Container( - constraints: const BoxConstraints( - maxWidth: 250, + constraints: BoxConstraints( + maxWidth: AppData.instance.getUserDeviceType(context) == UserDeviceType.phone ? 80 : 250, ), child: Wrap( spacing: 3.0, @@ -498,6 +530,89 @@ class ChatBubble extends StatelessWidget { } +class AnimIconColorFade extends StatefulWidget { + final IconData icon; + final double size; + final int duration; + const AnimIconColorFade({super.key, required this.icon, required this.size, required this.duration}); + + @override + AnimIconColorFadeState createState() => AnimIconColorFadeState(); +} + +class AnimIconColorFadeState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _colorAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + + // Initialize the animation controller + _controller = AnimationController( + duration: Duration(seconds: widget.duration), // Duration (in seconds) of the full spectrum transition + vsync: this, + )..repeat(reverse: true); // Repeat back and forth + + // Define the color tween sequence to cover the entire color spectrum + _colorAnimation = _controller.drive( + TweenSequence([ + TweenSequenceItem( + tween: ColorTween(begin: Colors.red, end: Colors.purple), + weight: 1, + ), + TweenSequenceItem( + tween: ColorTween(begin: Colors.green, end: Colors.cyan), + weight: 1, + ), + TweenSequenceItem( + tween: ColorTween(begin: Colors.cyan, end: Colors.blue), + weight: 1, + ), + TweenSequenceItem( + tween: ColorTween(begin: Colors.blue, end: Colors.purple), + weight: 1, + ), + TweenSequenceItem( + tween: ColorTween(begin: Colors.purple, end: Colors.red), + weight: 1, + ), + ]), + ); + + // Define the fade transition animation + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fadeAnimation, + child: AnimatedBuilder( + animation: _colorAnimation, + builder: (context, child) { + return Icon( + widget.icon, + size: widget.size, + color: _colorAnimation.value, + ); + }, + ), + ); + } + +} + class ErrorDialog extends StatelessWidget { final String titleText; final String message; @@ -558,4 +673,4 @@ void showErrorDialog(BuildContext context, String title, String message) { ); }, ); -} +} \ No newline at end of file