这是indexloc提供的服务,不要输入任何密码
Skip to content
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ samples, guidance on mobile development, and a full API reference.
- Attach images to your chat messages.
- Preview a thumbnail of the attached image before sending.
- Thumbnail preview appears in the chat after sending a message with an image.
- Upload PDF files, generate embeddings, and receive a summary from the selected LLM service.
6 changes: 6 additions & 0 deletions lib/models/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ class Message {
final String content;
final DateTime timestamp;
final String? imageBase64;
final List<double>? embedding;

Message({
String? id,
required this.role,
required this.content,
this.imageBase64,
this.embedding,
DateTime? timestamp,
}) : id = id ?? const Uuid().v4(),
timestamp = timestamp ?? DateTime.now();
Expand All @@ -36,13 +38,15 @@ class Message {
MessageRole? role,
String? content,
String? imageBase64,
List<double>? embedding,
DateTime? timestamp,
}) {
return Message(
id: id ?? this.id,
role: role ?? this.role,
content: content ?? this.content,
imageBase64: imageBase64 ?? this.imageBase64,
embedding: embedding ?? this.embedding,
timestamp: timestamp ?? this.timestamp,
);
}
Expand All @@ -53,6 +57,7 @@ class Message {
'role': role.toString().split('.').last,
'content': content,
if (imageBase64 != null) 'imageBase64': imageBase64,
if (embedding != null) 'embedding': embedding,
'timestamp': timestamp.toIso8601String(),
};
}
Expand All @@ -66,6 +71,7 @@ class Message {
),
content: json['content'],
imageBase64: json['imageBase64'],
embedding: (json['embedding'] as List?)?.map((e) => (e as num).toDouble()).toList(),
timestamp: DateTime.parse(json['timestamp']),
);
}
Expand Down
51 changes: 51 additions & 0 deletions lib/providers/chat_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,55 @@ class ActiveChatSessionNotifier extends StateNotifier<ChatSession?> {
_ref.read(isLoadingProvider.notifier).state = false;
}
}

Future<void> sendMessageWithPdf(
String content,
String pdfText,
) async {
if (state == null) return;

ChatSession? updatedSession;

_ref.read(isLoadingProvider.notifier).state = true;

try {
final llmService = _ref.read(selectedLlmServiceProvider);

final embedding = await llmService.embedText(pdfText);

final userMessage = Message(
role: MessageRole.user,
content: content,
embedding: embedding,
);

updatedSession = state!.addMessage(userMessage);
state = updatedSession;
_ref.read(chatSessionsProvider.notifier).updateSession(updatedSession!);

final messagesWithPdf = [
...updatedSession.messages,
Message(role: MessageRole.user, content: pdfText),
];

final assistantMessage =
await llmService.sendMessage(messagesWithPdf);

final finalSession = updatedSession.addMessage(assistantMessage);
state = finalSession;
_ref.read(chatSessionsProvider.notifier).updateSession(finalSession);
} catch (e) {
final errorMessage = Message(
role: MessageRole.assistant,
content: 'Error: ${e.toString()}',
);

final sessionForError = updatedSession ?? state!;
final finalSession = sessionForError.addMessage(errorMessage);
state = finalSession;
_ref.read(chatSessionsProvider.notifier).updateSession(finalSession);
} finally {
_ref.read(isLoadingProvider.notifier).state = false;
}
}
}
42 changes: 42 additions & 0 deletions lib/services/azure_openai_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,46 @@ class AzureOpenAiService implements LlmService {
Future<String> getApiVersion() async {
return await _secureStorage.read(key: _apiVersionKey) ?? _defaultApiVersion;
}

@override
Future<List<double>> embedText(String text) async {
final apiKey = await _secureStorage.read(key: _apiKeyKey);
final endpoint = await _secureStorage.read(key: _endpointKey);
final deployment = await getCurrentModel();
final apiVersion = await _secureStorage.read(key: _apiVersionKey) ?? _defaultApiVersion;

if (apiKey == null || apiKey.isEmpty) {
throw Exception('Azure OpenAI API key not configured');
}

if (endpoint == null || endpoint.isEmpty) {
throw Exception('Azure OpenAI endpoint not configured');
}

final url = '$endpoint/openai/deployments/$deployment/embeddings?api-version=$apiVersion';

try {
final response = await _dio.post(
url,
options: Options(
headers: {
'api-key': apiKey,
'Content-Type': 'application/json',
},
),
data: {
'input': text,
},
);

if (response.statusCode == 200) {
final data = response.data['data'][0]['embedding'] as List;
return data.map((e) => (e as num).toDouble()).toList();
} else {
throw Exception('Failed to get embedding from Azure OpenAI: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error requesting embedding from Azure OpenAI: $e');
}
}
}
3 changes: 3 additions & 0 deletions lib/services/llm_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ abstract class LlmService {
List<Message> messages,
String base64Image,
);

/// Generate an embedding vector for the provided text
Future<List<double>> embedText(String text);

/// Get the available models for this service
Future<List<String>> getAvailableModels();
Expand Down
26 changes: 26 additions & 0 deletions lib/services/ollama_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,30 @@ class OllamaService implements LlmService {
Future<String> getEndpoint() async {
return await _secureStorage.read(key: _endpointKey) ?? _defaultEndpoint;
}

@override
Future<List<double>> embedText(String text) async {
final endpoint = await _secureStorage.read(key: _endpointKey) ?? _defaultEndpoint;
final model = await getCurrentModel();

try {
final response = await _dio.post(
'$endpoint/api/embeddings',
options: Options(headers: {'Content-Type': 'application/json'}),
data: {
'model': model,
'prompt': text,
},
);

if (response.statusCode == 200) {
final data = response.data['embedding'] as List;
return data.map((e) => (e as num).toDouble()).toList();
} else {
throw Exception('Failed to get embedding from Ollama: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error communicating with Ollama: $e');
}
}
}
37 changes: 37 additions & 0 deletions lib/services/openai_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,43 @@ class OpenAiService implements LlmService {
}
}

@override
Future<List<double>> embedText(String text) async {
final apiKey = await _secureStorage.read(key: _apiKeyKey);
if (apiKey == null || apiKey.isEmpty) {
throw Exception('OpenAI API key not configured');
}

const embeddingModel = 'text-embedding-3-small';

try {
final response = await _dio.post(
'$_baseUrl/embeddings',
options: Options(
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
),
data: {
'model': embeddingModel,
'input': text,
},
);

if (response.statusCode == 200) {
final data = response.data['data'][0]['embedding'] as List;
return data.map((e) => (e as num).toDouble()).toList();
} else {
throw Exception(
'Failed to get embedding from OpenAI: ${response.statusCode}',
);
}
} catch (e) {
throw Exception('Error requesting embedding from OpenAI: $e');
}
}

@override
Future<List<String>> getAvailableModels() async {
return ['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo'];
Expand Down
82 changes: 80 additions & 2 deletions lib/ui/screens/chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:pdf_text/pdf_text.dart';
import '../../models/message.dart';
import '../../providers/chat_providers.dart';
import '../widgets/message_bubble.dart';
Expand Down Expand Up @@ -92,6 +94,7 @@ class _MessageInputState extends ConsumerState<MessageInput> {
final TextEditingController _controller = TextEditingController();
bool _canSend = false;
File? _selectedImage;
File? _selectedPdf;

@override
void initState() {
Expand All @@ -109,7 +112,8 @@ class _MessageInputState extends ConsumerState<MessageInput> {
void _updateCanSend() {
final text = _controller.text.trim();
setState(() {
_canSend = text.isNotEmpty || _selectedImage != null;
_canSend =
text.isNotEmpty || _selectedImage != null || _selectedPdf != null;
});
}

Expand All @@ -124,23 +128,45 @@ class _MessageInputState extends ConsumerState<MessageInput> {
}
}

Future<void> _pickPdf() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
if (result != null && result.files.single.path != null) {
setState(() {
_selectedPdf = File(result.files.single.path!);
_updateCanSend();
});
}
}

Future<void> _sendMessage() async {
final text = _controller.text.trim();
if (text.isEmpty && _selectedImage == null) return;
if (text.isEmpty && _selectedImage == null && _selectedPdf == null) return;

if (_selectedImage != null) {
final bytes = await _selectedImage!.readAsBytes();
final base64Image = base64Encode(bytes);
await ref
.read(activeChatSessionProvider.notifier)
.sendMessageWithImage(text, base64Image);
} else if (_selectedPdf != null) {
final doc = await PDFDoc.fromFile(_selectedPdf!);
final pdfText = await doc.text;
final displayText =
text.isNotEmpty ? text : 'Summarize PDF: ${_selectedPdf!.path.split('/').last}';
await ref
.read(activeChatSessionProvider.notifier)
.sendMessageWithPdf(displayText, pdfText);
} else {
await ref.read(activeChatSessionProvider.notifier).sendMessage(text);
}

_controller.clear();
setState(() {
_selectedImage = null;
_selectedPdf = null;
_updateCanSend();
});
}
Expand Down Expand Up @@ -194,6 +220,50 @@ class _MessageInputState extends ConsumerState<MessageInput> {
],
),
),
if (_selectedPdf != null)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Stack(
alignment: Alignment.topRight,
children: [
Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.picture_as_pdf, size: 24),
const SizedBox(width: 8),
Text(_selectedPdf!.path.split('/').last),
],
),
),
GestureDetector(
onTap: () {
setState(() {
_selectedPdf = null;
_updateCanSend();
});
},
child: Container(
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12.0),
),
child: const Icon(
Icons.close,
size: 16,
color: Colors.white,
),
),
),
],
),
),
Row(
children: [
Expanded(
Expand Down Expand Up @@ -228,6 +298,14 @@ class _MessageInputState extends ConsumerState<MessageInput> {
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 8.0),
IconButton(
icon: Icon(
_selectedPdf == null ? Icons.picture_as_pdf : Icons.check,
),
onPressed: isLoading ? null : _pickPdf,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 8.0),
FutureBuilder<bool>(
future: isConfigured,
builder: (context, snapshot) {
Expand Down
2 changes: 2 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ dependencies:
intl: ^0.19.0
path_provider: ^2.1.2
image_picker: ^1.0.4
file_picker: ^10.2.0
pdf_text: ^0.5.0

dev_dependencies:
flutter_test:
Expand Down