Rework PromptService

- Use dependency injection and Get.find instead of manual singleton pattern
- Move from domain to data layer
- Prevents multiple API calls if the previous request hasn't finished
This commit is contained in:
Théo Poizat
2026-02-26 11:47:00 +01:00
committed by Dat H. Pham
parent 6d8d10cf26
commit 1982f199ac
8 changed files with 182 additions and 155 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ export 'scribe/ai/data/datasource/ai_datasource.dart';
export 'scribe/ai/data/repository/ai_repository_impl.dart';
export 'scribe/ai/domain/model/ai_response.dart';
export 'scribe/ai/domain/repository/ai_scribe_repository.dart';
export 'scribe/ai/domain/service/prompt_service.dart';
export 'scribe/ai/data/service/prompt_service.dart';
export 'scribe/ai/domain/state/generate_ai_text_state.dart';
export 'scribe/ai/domain/usecases/generate_ai_text_interactor.dart';
export 'scribe/ai/localizations/scribe_localizations.dart';
@@ -0,0 +1,100 @@
import 'dart:async';
import 'dart:convert';
import 'package:core/data/network/dio_client.dart';
import 'package:core/utils/app_logger.dart';
import 'package:scribe/scribe/ai/data/model/ai_message.dart';
import 'package:scribe/scribe/ai/domain/model/prompt_data.dart';
import 'package:flutter/services.dart' show rootBundle;
class PromptService {
static const String _defaultAssetPath = 'packages/scribe/assets/prompts.json';
final DioClient _dioClient;
String? _promptUrl;
PromptData? _promptData;
Future<PromptData>? _loadingFuture;
PromptService(this._dioClient);
void setPromptUrl(String? url) {
if (_promptUrl == url) return;
_promptUrl = url;
_promptData = null;
_loadingFuture = null;
}
String? get promptUrl => _promptUrl;
// Prevents multiple API calls if the previous request hasn't finished
Future<PromptData> loadPrompts() {
if (_promptData != null) {
return Future.value(_promptData!);
}
if (_loadingFuture != null) {
return _loadingFuture!;
}
_loadingFuture = _fetchAndCachePrompts().whenComplete(() {
_loadingFuture = null;
});
return _loadingFuture!;
}
// Prioritize loading from remote or fallback to load local assets
Future<PromptData> _fetchAndCachePrompts() async {
if (_promptUrl != null) {
try {
_promptData = await _fetchPromptsFromUrl(_promptUrl!);
return _promptData!;
} catch (e) {
log('PromptService::loadPrompts: failed to fetch from remote URL: $e');
}
}
_promptData = await _loadPromptsFromAssets();
return _promptData!;
}
Future<PromptData> _fetchPromptsFromUrl(String url) async {
log('PromptService::_fetchPromptsFromUrl: Fetching from $url');
try {
final data = await _dioClient.get(url);
final promptsMap = data is String
? jsonDecode(data) as Map<String, dynamic>
: data as Map<String, dynamic>;
return PromptData.fromJson(promptsMap);
} catch (e) {
throw Exception('Failed to fetch prompts: $e');
}
}
Future<PromptData> _loadPromptsFromAssets() async {
try {
final jsonString = await rootBundle.loadString(_defaultAssetPath);
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
return PromptData.fromJson(jsonData);
} catch (e) {
throw Exception('Failed to load local prompts: $e');
}
}
Future<Prompt> getPromptByName(String name) async {
final promptData = await loadPrompts();
try {
return promptData.prompts.firstWhere((prompt) => prompt.name == name);
} catch (_) {
throw Exception('Prompt not found: $name');
}
}
Future<List<AIMessage>> buildPromptByName(String name, String inputText, {String? task}) async {
final prompt = await getPromptByName(name);
return prompt.buildPrompt(inputText, task: task);
}
}
@@ -1,10 +1,11 @@
import 'package:scribe/scribe/ai/data/model/ai_message.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_action.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
import 'package:scribe/scribe/ai/domain/service/prompt_service.dart';
import 'package:scribe/scribe/ai/data/service/prompt_service.dart';
import 'package:get/get.dart';
class AIPrompts {
static final PromptService _promptService = PromptService();
static final PromptService _promptService = Get.find<PromptService>();
static Future<List<AIMessage>> buildPrompt(AIAction action, String? text) async {
return switch (action) {
@@ -7,17 +7,15 @@ class PromptData {
required this.prompts,
});
factory PromptData.fromJson(Map<String, dynamic> json) {
final promptsJson = json['prompts'];
if (promptsJson is! List<dynamic>) {
return PromptData(prompts: []);
}
factory PromptData.fromJson(Map<String, dynamic> json) {
final promptsJson = json['prompts'] as List?;
return PromptData(
prompts: promptsJson
.whereType<Map<String, dynamic>>()
.map((promptJson) => Prompt.fromJson(promptJson))
.toList(),
?.whereType<Map<String, dynamic>>()
.map(Prompt.fromJson)
.toList() ??
const [],
);
}
}
@@ -37,41 +35,35 @@ class Prompt {
throw const FormatException('Prompt name must be a non-null String');
}
final messagesJson = json['messages'];
if (messagesJson is! List<dynamic>) {
return Prompt(name: name, messages: []);
}
final messagesJson = json['messages'] as List?;
return Prompt(
name: name,
messages: messagesJson
.whereType<Map<String, dynamic>>()
.map((messageJson) => AIMessage.fromJson(messageJson))
.toList(),
?.whereType<Map<String, dynamic>>()
.map(AIMessage.fromJson)
.toList() ??
const [],
);
}
List<AIMessage> buildPrompt(String inputText, {String? task}) {
final messages = <AIMessage>[];
for (final message in this.messages) {
if (message.role == AIRole.system) {
messages.add(AIMessage.ofSystem(message.content));
} else if (message.role == AIRole.user) {
final userContent = _replacePlaceholders(message.content, inputText, task);
messages.add(AIMessage.ofUser(userContent));
}
}
return messages;
return [
for (final message in messages)
if (message.role == AIRole.system)
AIMessage.ofSystem(message.content)
else if (message.role == AIRole.user)
AIMessage.ofUser(_replacePlaceholders(message.content, inputText, task))
];
}
String _replacePlaceholders(String content, String inputText, String? task) {
String result = content;
if (result.contains('{{input}}')) {
result = result.replaceAll('{{input}}', inputText);
}
if (task != null && result.contains('{{task}}')) {
var result = content.replaceAll('{{input}}', inputText);
if (task != null) {
result = result.replaceAll('{{task}}', task);
}
return result;
}
}
@@ -1,106 +0,0 @@
import 'dart:convert';
import 'package:core/data/network/dio_client.dart';
import 'package:core/utils/app_logger.dart';
import 'package:dio/dio.dart';
import 'package:scribe/scribe/ai/data/model/ai_message.dart';
import 'package:scribe/scribe/ai/domain/model/prompt_data.dart';
import 'package:flutter/services.dart' show rootBundle;
class PromptService {
static PromptService? _instance;
final DioClient _dioClient;
factory PromptService({DioClient? dioClient}) {
_instance ??= PromptService._internal(dioClient: dioClient);
return _instance!;
}
PromptService._internal({DioClient? dioClient})
: _dioClient = dioClient ?? DioClient(Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
)));
PromptData? _promptData;
String? _promptUrl;
void setPromptUrl(String? url) {
_promptUrl = url;
}
String? get promptUrl => _promptUrl;
Future<PromptData> loadPrompts() async {
// App in memory cache
if (_promptData != null) {
return _promptData!;
}
// First try: remote prompts
if (_promptUrl != null) {
try {
_promptData = await _fetchPromptsFromUrl(_promptUrl!);
return _promptData!;
} catch (e) {
log('PromptService::loadPrompts: failed to fetch from remote URL: $e');
}
}
// Fallback: local prompts
return _loadPromptsFromAssets();
}
Future<PromptData> _fetchPromptsFromUrl(String url) async {
log('PromptService::_fetchPromptsFromUrl: Fetching from $url');
try {
final response = await _dioClient.get(url);
if (response.statusCode == 200) {
final jsonData = response.data;
Map<String, dynamic> promptsMap;
if (jsonData is String) {
promptsMap = jsonDecode(jsonData) as Map<String, dynamic>;
} else if (jsonData is Map<String, dynamic>) {
promptsMap = jsonData;
} else {
throw Exception('Failed to fetch prompts: invalid response format');
}
log('PromptService::_fetchPromptsFromUrl: Successfully fetched prompts');
return PromptData.fromJson(promptsMap);
} else {
throw Exception('Failed to fetch prompts: unexpected status code ${response.statusCode}');
}
} catch (e) {
log('PromptService::_fetchPromptsFromUrl: Exception: $e');
rethrow;
}
}
Future<PromptData> _loadPromptsFromAssets() async {
try {
final jsonString = await rootBundle.loadString('packages/scribe/assets/prompts.json');
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
_promptData = PromptData.fromJson(jsonData);
return _promptData!;
} catch (e) {
throw Exception('Failed to load prompts: $e');
}
}
Future<Prompt> getPromptByName(String name) async {
final promptData = await loadPrompts();
return promptData.prompts.firstWhere(
(prompt) => prompt.name == name,
orElse: () => throw Exception('Prompt not found: $name'),
);
}
Future<List<AIMessage>> buildPromptByName(String name, String inputText, {String? task}) async {
final prompt = await getPromptByName(name);
return prompt.buildPrompt(inputText, task: task);
}
}