Files
English/kouyu_english/lib/core/ai_service.dart
T

872 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'models.dart';
import 'generated_content.dart';
import 'a0_core.dart';
class AiConnectionResult {
const AiConnectionResult({required this.ok, required this.message});
final bool ok;
final String message;
}
/// Stores the secret separately from normal app settings. Compatible endpoints
/// support both OpenAI chat-completions shape (/v1/chat/completions) and
/// responses shape (/v1/responses), including a user-run CLIProxyAPI.
class AiService {
AiService._();
static final instance = AiService._();
static const _keyName = 'ai_api_key';
final _secureStorage = const FlutterSecureStorage();
String? _fallbackApiKey;
void setFallbackApiKey(String? key) {
_fallbackApiKey = key?.trim();
}
Future<void> saveApiKey(String value) async {
if (value.trim().isEmpty) {
await _secureStorage.delete(key: _keyName);
} else {
await _secureStorage.write(key: _keyName, value: value.trim());
}
}
Future<String?> resolveApiKey([String? explicit]) async {
if (explicit != null && explicit.trim().isNotEmpty) {
return explicit.trim();
}
try {
final stored = await _secureStorage.read(key: _keyName);
if (stored != null && stored.trim().isNotEmpty) {
return stored.trim();
}
} catch (_) {}
if (_fallbackApiKey != null && _fallbackApiKey!.trim().isNotEmpty) {
return _fallbackApiKey!.trim();
}
return null;
}
Future<bool> hasApiKey() async =>
(await resolveApiKey())?.isNotEmpty ?? false;
Future<String?> getApiKey() async => await resolveApiKey();
/// Resolves the target endpoint URI.
/// If explicitly set to /responses or /chat/completions, it respects that path.
/// If ending in /v1 or base URL, it defaults to /v1/chat/completions.
static Uri? resolveEndpointUri({
required AiProviderType provider,
required String endpoint,
required String model,
}) {
final base = endpoint.trim().replaceFirst(RegExp(r'/+$'), '');
if (base.isEmpty) return null;
if (provider == AiProviderType.gemini) {
if (base.contains(':generateContent')) {
return Uri.tryParse(base);
}
return Uri.tryParse('$base/models/$model:generateContent');
}
if (base.endsWith('/chat/completions')) {
return Uri.tryParse(base);
}
if (base.endsWith('/responses')) {
return Uri.tryParse(base);
}
if (base.endsWith('/v1')) {
return Uri.tryParse('$base/chat/completions');
}
return Uri.tryParse('$base/v1/chat/completions');
}
static Map<String, dynamic> _buildOpenAiPayload({
required Uri uri,
required String model,
required List<Map<String, String>> messages,
double? temperature,
int? maxTokens,
}) {
final isResponses = uri.path.endsWith('/responses');
if (isResponses) {
return {
'model': model,
'input': messages,
if (temperature != null) 'temperature': temperature,
if (maxTokens != null) 'max_output_tokens': maxTokens,
};
}
return {
'model': model,
'messages': messages,
if (temperature != null) 'temperature': temperature,
if (maxTokens != null) 'max_tokens': maxTokens,
};
}
/// Returns a display-only Chinese gloss for an unknown word or phrase.
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
Future<String?> temporaryDefinition({
required AiProviderType provider,
required String endpoint,
required String model,
required String text,
}) async {
if (provider == AiProviderType.mock ||
text.length > 120 ||
text.trim().isEmpty) {
return null;
}
final key = await resolveApiKey();
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (key == null ||
key.isEmpty ||
model.trim().isEmpty ||
uri == null ||
(uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const instruction =
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': '$instruction\nText: $text'},
],
},
],
'generationConfig': {
'maxOutputTokens': 200,
'responseMimeType': 'application/json',
},
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{
'role': 'user',
'content': '$instruction\nText: $text',
},
],
maxTokens: 200,
),
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null || content.length > 300) return null;
final parsed = jsonDecode(content);
if (parsed is! Map<String, dynamic>) return null;
final definition = parsed['definition'];
return definition is String && definition.trim().isNotEmpty
? definition.trim()
: null;
} catch (_) {
return null;
}
}
Future<AiConnectionResult> testConnection({
required AiProviderType provider,
required String endpoint,
required String model,
String? explicitApiKey,
}) async {
if (provider == AiProviderType.mock) {
return const AiConnectionResult(ok: true, message: '内置练习模式可用,无需网络。');
}
const probe =
'Return JSON only: {"reply":"Hi!","slots":{},"evidence":[],"suggestsComplete":false,"feedback":null}';
final key = await resolveApiKey(explicitApiKey);
if (key == null || key.isEmpty) {
return const AiConnectionResult(ok: false, message: '请先填写或保存 API Key。');
}
if (endpoint.trim().isEmpty || model.trim().isEmpty) {
return const AiConnectionResult(
ok: false,
message: '请填写 Base URL 和模型名称。',
);
}
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
return const AiConnectionResult(
ok: false,
message: '请使用有效的 HTTP 或 HTTPS 地址。',
);
}
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': probe},
],
},
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 200,
'responseMimeType': 'application/json',
},
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': probe},
],
temperature: 0,
maxTokens: 200,
),
),
)
.timeout(const Duration(seconds: 15));
if (response.statusCode >= 200 && response.statusCode < 300) {
final content = _extractResponseContent(provider, response.body);
if (_decodeDialogueResponse(content) != null) {
return const AiConnectionResult(
ok: true,
message: '连接成功,AI 对话服务可用!',
);
}
return const AiConnectionResult(
ok: true,
message: '连接成功,接口响应正常。',
);
}
if (response.statusCode == 401 || response.statusCode == 403) {
return AiConnectionResult(
ok: false,
message: '鉴权失败 (HTTP ${response.statusCode}),请检查 API Key 是否正确。',
);
}
if (response.statusCode == 404) {
return const AiConnectionResult(
ok: false,
message: '服务返回 404,请检查 Base URL(如是否缺少 /v1)或模型名称。',
);
}
if (response.statusCode == 429) {
return const AiConnectionResult(
ok: false,
message: '请求受限 (HTTP 429)API 额度不足或达到并发限制。',
);
}
return AiConnectionResult(
ok: false,
message: '服务返回 HTTP ${response.statusCode},请检查地址和模型配置。',
);
} catch (e) {
return AiConnectionResult(
ok: false,
message: '无法连接服务 ($e)。请检查网络、地址或代理连通性。',
);
}
}
Future<DialogueAiResponse?> dialogueReply({
required AiProviderType provider,
required String endpoint,
required String model,
required List<Map<String, String>> history,
required String requiredTask,
}) async {
if (provider == AiProviderType.mock) {
return null;
}
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const system =
'You are Mia, a patient A0 English conversation partner. Use only very simple English. Reply in one short sentence or question. Do not explain grammar. Return JSON only, with exactly these fields: reply (string, maximum 20 English words), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'systemInstruction': {
'parts': [
{'text': '$system$requiredTask'},
],
},
'contents': history
.map(
(turn) => {
'role': turn['role'] == 'assistant'
? 'model'
: 'user',
'parts': [
{'text': turn['content']},
],
},
)
.toList(),
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 300,
'responseMimeType': 'application/json',
},
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'system', 'content': '$system$requiredTask'},
...history,
],
temperature: 0.3,
maxTokens: 300,
),
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
return _decodeDialogueResponse(
_extractResponseContent(provider, response.body),
);
} catch (_) {
return null;
}
}
/// Generates only a bounded variant of an existing review target. A network
/// response is never exposed unless [decodeGeneratedReviewVariant] accepts it.
Future<GeneratedReviewVariant?> generateReviewVariant({
required AiProviderType provider,
required String endpoint,
required String model,
required String targetItemId,
required String basePrompt,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
final instruction =
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". Return JSON only with exactly: schemaVersion (must be "review-variant-1"), targetItemId (must be "$targetItemId"), prompt (short Chinese instruction), stimulus (English sentence, maximum 12 words), answer (exact expected English answer, maximum 10 words), acceptedAnswers (array of 1 to 4 strings), requiredAnyPhrases (array of 1 to 3 arrays of strings), forbiddenPhrases (array of up to 4 strings). Stay strictly within A0. Do not introduce new vocabulary. The answer must satisfy the spec.${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 300,
'responseMimeType': 'application/json',
},
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': instruction},
],
temperature: 0.2,
maxTokens: 300,
),
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null) return null;
final variant = decodeGeneratedReviewVariant(
content,
expectedTargetItemId: targetItemId,
);
if (variant == null && !repairAttempt) {
return generateReviewVariant(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
basePrompt: basePrompt,
repairAttempt: true,
);
}
return variant;
} catch (_) {
return null;
}
}
/// Evaluates an open-ended writing response against a bounded schema.
Future<WritingAiFeedback?> writingFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String lessonId,
required String taskPrompt,
required String answer,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
final instruction =
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
Task: $taskPrompt
Learner wrote: $answer''';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 300,
'responseMimeType': 'application/json',
},
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': instruction},
],
temperature: 0,
maxTokens: 300,
),
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null) return null;
return decodeWritingAiFeedback(
content,
expectedLessonId: lessonId,
);
} catch (_) {
return null;
}
}
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
Future<GeneratedLesson?> generateAdaptiveLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required String targetItemId,
required String targetLabel,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
final instruction =
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, lessonId $lessonId, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [$targetItemId], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [$targetItemId]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple A0 English for $targetLabel. No new vocabulary, markdown, real phone numbers, or personal data.${repairAttempt ? ' Previous response was invalid: repair all constraints.' : ''}';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0.1,
'maxOutputTokens': 850,
'responseMimeType': 'application/json',
},
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': instruction},
],
temperature: 0.1,
maxTokens: 850,
),
),
)
.timeout(const Duration(seconds: 45));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null) return null;
final lesson = decodeGeneratedLesson(
content,
expectedTargetItemId: targetItemId,
);
if (lesson == null && !repairAttempt) {
return generateAdaptiveLesson(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
targetLabel: targetLabel,
repairAttempt: true,
);
}
return lesson;
} catch (_) {
return null;
}
}
/// Sends the entire generated lesson structure to an independent LLM audit.
Future<bool> auditGeneratedLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required GeneratedLesson lesson,
}) async {
if (provider == AiProviderType.mock) return true;
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return false;
}
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return false;
final lessonJson = jsonEncode({
'lessonId': lesson.lessonId,
'stageVersion': lesson.stageVersion,
'targetItemIds': lesson.targetItemIds,
'tasks': lesson.tasks
.map(
(task) => {
'taskId': task.taskId,
'skill': task.skill,
'type': task.type,
'prompt': task.prompt,
'stimulus': task.stimulus,
'answer': task.answer,
'answerSpec': {
'requiredAnyPhrases': task.localAnswerSpec.requiredAnyPhrases,
'acceptedAnswers': task.localAnswerSpec.acceptedAnswers,
'forbiddenPhrases': task.localAnswerSpec.forbiddenPhrases,
},
},
)
.toList(),
});
final instruction =
'Audit this A0 English lesson independently. Check naturalness, that every answer follows its stimulus, that it stays A0, and that each task is solvable without giving the answer. Return JSON only with exactly schemaVersion, approved, reason. schemaVersion must be lesson-audit-1. approved is boolean and reason is a short Chinese string. Lesson: $lessonJson';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': instruction},
],
},
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 200,
'responseMimeType': 'application/json',
},
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': instruction},
],
temperature: 0,
maxTokens: 200,
),
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return false;
}
return _decodeLessonAudit(
_extractResponseContent(provider, response.body),
);
} catch (_) {
return false;
}
}
bool _decodeLessonAudit(String? raw) {
if (raw == null || raw.length > 600) return false;
try {
final data = jsonDecode(raw);
return data is Map<String, dynamic> &&
data.length == 3 &&
data['schemaVersion'] == 'lesson-audit-1' &&
data['approved'] == true &&
data['reason'] is String &&
(data['reason'] as String).length <= 160;
} catch (_) {
return false;
}
}
String? _extractResponseContent(AiProviderType provider, String body) {
try {
final data = jsonDecode(body) as Map<String, dynamic>;
String? rawContent;
if (provider == AiProviderType.gemini) {
final candidate = (data['candidates'] as List?)?.firstOrNull as Map?;
final candidateContent = candidate?['content'] as Map?;
final parts = candidateContent?['parts'] as List?;
rawContent = (parts?.firstOrNull as Map?)?['text'] as String?;
} else {
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
final choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
choice?['text'] as String?;
if (choiceContent != null && choiceContent.isNotEmpty) {
rawContent = choiceContent;
} else if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
rawContent = data['output_text'] as String;
} else {
final outputList = data['output'] as List?;
if (outputList != null && outputList.isNotEmpty) {
for (final item in outputList) {
if (item is Map) {
if (item['content'] is List) {
for (final sub in item['content'] as List) {
if (sub is Map && sub['text'] is String) {
rawContent = sub['text'] as String;
break;
}
}
} else if (item['text'] is String) {
rawContent = item['text'] as String;
break;
}
}
if (rawContent != null) break;
}
}
if (rawContent == null) {
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
rawContent = data['response'] as String;
} else if (data['text'] is String && (data['text'] as String).isNotEmpty) {
rawContent = data['text'] as String;
} else if (data['content'] is String && (data['content'] as String).isNotEmpty) {
rawContent = data['content'] as String;
}
}
}
}
if (rawContent == null) return null;
var trimmed = rawContent.trim();
if (trimmed.startsWith('```')) {
trimmed = trimmed.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
trimmed = trimmed.replaceFirst(RegExp(r'\s*```$'), '');
}
return trimmed.trim();
} catch (_) {
return null;
}
}
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
try {
var sanitized = raw.trim();
if (sanitized.startsWith('```')) {
sanitized = sanitized.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
sanitized = sanitized.replaceFirst(RegExp(r'\s*```$'), '');
}
final data = jsonDecode(sanitized.trim()) as Map<String, dynamic>;
final reply = data['reply'] as String?;
final rawSlots = data['slots'];
final rawEvidence = data['evidence'];
final suggestsComplete = data['suggestsComplete'];
final feedback = data['feedback'];
if (reply == null ||
reply.trim().isEmpty ||
reply.length > 240 ||
rawSlots is! Map ||
rawEvidence is! List ||
suggestsComplete is! bool ||
(feedback != null && feedback is! String)) {
return null;
}
final slots = <String, String>{};
for (final entry in rawSlots.entries) {
if (entry.key is! String || entry.value is! String) return null;
if ((entry.key as String).length > 40 ||
(entry.value as String).length > 80) {
return null;
}
slots[entry.key as String] = entry.value as String;
}
final evidence = <String>[];
for (final item in rawEvidence) {
if (item is! String || item.length > 240) return null;
evidence.add(item);
}
return DialogueAiResponse(
reply: reply.trim(),
slots: slots,
evidence: evidence,
suggestsComplete: suggestsComplete,
feedback: feedback as String?,
);
} catch (_) {
return null;
}
}
}