Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release v0.5.0 #6

Merged
merged 1 commit into from
Aug 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<ul style="color: #555; font-size: 20px;">
<li><strong>Offline providers</strong> like <a href="https://ollama.com">Ollama</a> and <a href="https://github.com/ggerganov/llama.cpp">LlamaCpp</a> provide privacy by operating on your local machine or network without cloud services.</li>
<li><strong>Online providers</strong> like <a href="https://openai.com">OpenAI</a> offer cutting-edge models via APIs, which have different privacy policies than their chat services, giving you greater control over your data.</li>
<li><strong>Online providers</strong> like <a href="https://openai.com">OpenAI</a> and <a href="https://anthropic.com">Anthropic</a> offer cutting-edge models via APIs, which have different privacy policies than their chat services, giving you greater control over your data.</li>
</ul>


Expand Down Expand Up @@ -67,7 +67,7 @@ In a nutshell, ConfiChat caters to users who value transparent control over thei

- **Local Model Support (Ollama and LlamaCpp)**: [Ollama](https://ollama.com) & [LlamaCpp](https://github.com/ggerganov/llama.cpp) both offer a range of lightweight, open-source local models, such as [Llama by Meta](https://ai.meta.com/llama/), [Gemma by Google](https://ai.google.dev/gemma), and [Llava](https://github.com/haotian-liu/LLaVA) for multimodal/image support. These models are designed to run efficiently even on machines with limited resources.

- **OpenAI Integration**: Seamlessly integrates with [OpenAI](https://openai.com) to provide advanced language model capabilities using your [own API key](https://platform.openai.com/docs/quickstart). Please note that while the API does not store conversations like ChatGPT does, OpenAI retains input data for abuse monitoring purposes. You can review their latest [data retention and security policies](https://openai.com/enterprise-privacy/). In particular, check the "How does OpenAI handle data retention and monitoring for API usage?" in their FAQ (https://openai.com/enterprise-privacy/).
- **OpenAI and Anthropic Support**: Seamlessly integrates with [OpenAI](https://openai.com) and [Anthropic](https://anthropic.com) to provide advanced language model capabilities using your [own API key](https://platform.openai.com/docs/quickstart). Please note that while the API does not store conversations like ChatGPT does, OpenAI retains input data for abuse monitoring purposes. You can review their latest [data retention and security policies](https://openai.com/enterprise-privacy/). In particular, check the "How does OpenAI handle data retention and monitoring for API usage?" in their FAQ (https://openai.com/enterprise-privacy/).

- **Privacy-Focused**: Privacy is at the core of ConfiChat's development. The app is designed to prioritize user confidentiality, with optional chat history encryption ensuring that your data remains secure.

Expand Down
306 changes: 306 additions & 0 deletions confichat/lib/api_anthropic.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
/*
* 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:confichat/app_data.dart';


class ApiAnthropic extends LlmApi{

static String version = '2023-06-01';
static final ApiAnthropic _instance = ApiAnthropic._internal();
static ApiAnthropic get instance => _instance;

factory ApiAnthropic() {
return _instance;
}
ApiAnthropic._internal() : super(AiProvider.anthropic) {

scheme = 'https';
host = 'api.anthropic.com';
port = 443;
path = '/v1';

defaultTemperature = 1.0;
defaultProbability = 1.0;
defaultMaxTokens = 1024;
defaultStopSequences = [];

temperature = 1.0;
probability = 1.0;
maxTokens = 1024;
stopSequences = [];
}

bool isImageTypeSupported(String extension){
const allowedExtensions = ['jpeg', 'png', 'gif', 'webp'];
return allowedExtensions.contains(extension.toLowerCase());
}

// Implementations
@override
Future<void> 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<String, dynamic> settings = json.decode(fileContent);

if (settings.containsKey(AiProvider.anthropic.name)) {

// Override values in memory from disk
apiKey = settings[AiProvider.anthropic.name]['apikey'] ?? '';
}
}
}

@override
Future<void> getModels(List<ModelItem> outModels) async {

// As of this writing, there doesn't seem to be an api endpoint to grab model names
outModels.add(ModelItem('claude-3-5-sonnet-20240620', 'claude-3-5-sonnet-20240620'));
outModels.add(ModelItem('claude-3-opus-20240229', 'claude-3-opus-20240229'));
outModels.add(ModelItem('claude-3-sonnet-20240229', 'claude-3-sonnet-20240229'));
outModels.add(ModelItem('claude-3-haiku-20240307', 'claude-3-haiku-20240307'));
}

@override
Future<void> getCachedMessagesInModel(List<dynamic> outCachedMessages, String modelId) async {
}

@override
Future<void> loadModelToMemory(String modelId) async {
return; // no need to preload model with chatgpt online models
}

@override
Future<void> getModelInfo(ModelInfo outModelInfo, String modelId) async {
// No function for this exists in Anthropic as of this writing
}

@override
Future<void> deleteModel(String modelId) async {
// todo: allow deletion of tuned models
}

@override
Future<void> sendPrompt({
required String modelId,
required List<Map<String, dynamic>> messages,
bool? getSummary,
Map<String, String>? documents,
Map<String, String>? 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<String> filteredStopSequences = stopSequences.where((s) => s.trim().isNotEmpty).toList();

// Assemble headers - this sequence seems to matter with Anthropic streaming
Map<String, String> headers = {'anthropic-version': version};
headers.addAll(AppData.headerJson);
headers.addAll({'x-api-key': apiKey});

// Parse message for sending to chatgpt
List<Map<String, dynamic>> apiMessages = [];

String systemPrompt = '';
for (var message in messages) {
List<Map<String, dynamic>> 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 imageFile in message['images']) {

if(isImageTypeSupported(imageFile['ext'])){
contentList.add({
"type": "image",
"source": {
"type": "base64",
"media_type": "image/${imageFile['ext']}",
"data": imageFile['base64'],
}
});
}

}
}

// Check for valid message
if(message.containsKey('role')) {

// Check for system prompt
if(message['role'] == 'system') {
systemPrompt = message['content'];
} else {
// Add to message history
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('/messages'))
..headers.addAll(headers);

request.body = jsonEncode({
'model': modelId,
'messages': apiMessages,
'temperature': temperature,
'top_p': probability,
'max_tokens': maxTokens,
if (filteredStopSequences.isNotEmpty) 'stop_sequences': filteredStopSequences,
if (systemPrompt.isNotEmpty) 'system': systemPrompt,
'stream': true
});

// Send request and await streamed response
final response = await request.send();

// Check the status of the response
if (response.statusCode == 200) {

// Handle callback if any
int indexPayload = 0;
if(onStreamRequestSuccess != null) { indexPayload = onStreamRequestSuccess(); }

// Listen for json object stream from api
StreamSubscription<String>? 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)
{
// Uncomment for testing
//print(chunk);

// Parse the JSON string
Map<String, dynamic> jsonMap = jsonDecode(chunk);

// Extract the first choice
if (jsonMap.containsKey('delta') && jsonMap['delta'].isNotEmpty) {
var delta = jsonMap['delta'];

// Extract the content
if (delta.containsKey('text')) {
String content = delta['text'];
if (content.isNotEmpty && onStreamChunkReceived != null) {
onStreamChunkReceived(indexPayload, StreamChunk(content));
}
}
}

}

}, 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');
}
}

}

}

class SseTransformer extends StreamTransformerBase<String, String> {

@override
Stream<String> bind(Stream<String> stream) {
final controller = StreamController<String>();
final buffer = StringBuffer();

stream.listen((line) {

// Uncomment for troubleshooting
//print(line);

if (line.startsWith('data: {"type":"content_block_delta')) { // We're only interested with the content deltas
buffer.write(line.substring(6)); // Append line data to buffer, excluding the 'data: ' prefix
} else if (line.isEmpty) {
// Empty line indicates end of an event
if (buffer.isNotEmpty) {
final event = buffer.toString();
if (event != '[DONE]') { controller.add(event); }
buffer.clear();
}
}
}, onDone: () {
controller.close();
}, onError: (error) {
controller.addError(error);
});

return controller.stream;
}

}
25 changes: 24 additions & 1 deletion confichat/lib/api_ollama.dart
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,29 @@ class ApiOllama extends LlmApi{
// Filter out empty stop sequences
List<String> filteredStopSequences = stopSequences.where((s) => s.trim().isNotEmpty).toList();

// Process messages to extract images
List<Map<String, dynamic>> processedMessages = messages.map((message) {
// Check for images in the message
if (message['images'] != null) {
// If images exist, extract the base64 values
List<String> base64Images = [];
var images = message['images'] as List<Map<String, String>>;

for (var image in images) {
base64Images.add(image['base64'] ?? '');
}

// Create a new message with extracted base64 images
return {
"role": message['role'],
"content": message['content'],
"images": base64Images, // Use only base64 images
};
}
return message; // Return the message as is if no images
}).toList();


// Assemble request
final request = http.Request('POST', getUri('/chat'))
..headers.addAll(AppData.headerJson);
Expand All @@ -249,7 +272,7 @@ class ApiOllama extends LlmApi{
request.body = jsonEncode({
'model': modelId,
'messages': [
...messages,
...processedMessages,
if (getSummary) summaryRequest,
],
'options': {
Expand Down
Loading