马良AI写作初始化仓库

This commit is contained in:
邓滨杰
2025-09-10 00:07:52 +08:00
parent 3c06bb1a03
commit 39c0f8840f
1309 changed files with 318528 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
import 'package:ainoval/config/app_config.dart'; // <<< Import AppConfig
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/services/api_service/repositories/impl/user_ai_model_config_repository_impl.dart';
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/l10n/app_localizations.dart';
import 'package:ainoval/widgets/common/theme_toggle_button.dart';
import 'widgets/add_edit_ai_config_dialog.dart';
import 'widgets/ai_config_list_item.dart';
class AiConfigManagementScreen extends StatelessWidget {
const AiConfigManagementScreen({super.key});
// TODO: Replace with proper dependency injection for repository
static final _tempApiClient =
ApiClient(); // Temporary - use injected instance
static final UserAIModelConfigRepository _repository =
UserAIModelConfigRepositoryImpl(apiClient: _tempApiClient); // Temporary
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// <<< Get userId from AppConfig >>>
// Ensure userId is available before navigating here, or handle null case
final String? currentUserId = AppConfig.userId; // Allow null initially
// Show an error/loading state if userId is null and required
if (currentUserId == null) {
// <<< Check for null
return Scaffold(
// appBar: AppBar(title: Text(l10n.errorTitle)), // TODO: Add l10n.errorTitle='错误'
appBar: AppBar(title: const Text('错误')), // Placeholder
// body: Center(child: Text(l10n.errorUserNotLoggedIn)) // TODO: Add l10n.errorUserNotLoggedIn = '无法加载配置:用户未登录。'
body: const Center(child: Text('无法加载配置:用户未登录。')) // Placeholder
); // <<< 修正: 移除了多余的括号并添加了分号
}
return BlocProvider(
// Use ! because we checked for null above
create: (context) => AiConfigBloc(repository: _repository)
..add(LoadAiConfigs(userId: currentUserId)),
child: Scaffold(
appBar: AppBar(
// TODO: Add l10n.aiModelConfigTitle string
// title: Text(l10n.aiModelConfigTitle), // Placeholder 'AI 模型配置'
title: const Text('AI 模型配置'), // Placeholder
actions: [
const ThemeToggleButton(),
const SizedBox(width: 16),
],
),
body: BlocConsumer<AiConfigBloc, AiConfigState>(
listener: (context, state) {
if (state.actionStatus == AiConfigActionStatus.error &&
state.actionErrorMessage != null) {
TopToast.error(context, '操作失败: ${state.actionErrorMessage!}');
}
// Optional: Show success message
else if (state.actionStatus == AiConfigActionStatus.success) {
// Consider showing temporary success confirmations if needed
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(content: Text(l10n.operationSuccessful), backgroundColor: Colors.green), // TODO: Add l10n.operationSuccessful = '操作成功'
// );
// Reset action status after showing message? Maybe handle in BLoC directly.
}
},
builder: (context, state) {
if (state.status == AiConfigStatus.loading &&
state.configs.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == AiConfigStatus.error && state.configs.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Text(l10n.errorLoadingConfig, style: TextStyle(color: Colors.red)), // TODO: Add l10n.errorLoadingConfig = '加载配置时出错'
const Text('加载配置时出错',
style: TextStyle(color: Colors.red)), // Placeholder
if (state.errorMessage != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(state.errorMessage!),
),
ElevatedButton(
// Use ! because userId is checked non-null here
onPressed: () => context
.read<AiConfigBloc>()
.add(LoadAiConfigs(userId: currentUserId)),
// child: Text(l10n.retry), // TODO: Add l10n.retry = '重试'
child: const Text('重试'), // Placeholder
)
],
));
}
final configs = state.configs;
final bool isActionLoading =
state.actionStatus == AiConfigActionStatus.loading;
return Stack(
children: [
if (configs.isEmpty && state.status != AiConfigStatus.loading)
// Center(child: Text(l10n.noConfigsFound)), // TODO: Add l10n.noConfigsFound = '未找到任何配置'
const Center(child: Text('未找到任何配置')), // Placeholder
ListView.builder(
padding: const EdgeInsets.only(
bottom: 80), // Add padding to avoid FAB overlap
itemCount: configs.length,
itemBuilder: (context, index) {
final config = configs[index];
// Pass specific loading state for the item if we track it by ID, otherwise use global action loading state
// bool itemIsLoading = isActionLoading && state.loadingConfigId == config.id; // Need state.loadingConfigId
return AiConfigListItem(
config: config,
// If not tracking individual item loading, disable buttons globally during action
isLoading: isActionLoading,
// Use ! for userId
onEdit: () => _showAddEditDialog(context, currentUserId,
config: config), // Pass userId
onDelete: () => _showDeleteConfirmation(
context, currentUserId, config), // Pass userId
onValidate: () => context.read<AiConfigBloc>().add(
ValidateAiConfig(
userId: currentUserId,
configId: config.id)), // Use userId
onSetDefault: () => context.read<AiConfigBloc>().add(
SetDefaultAiConfig(
userId: currentUserId,
configId: config.id)), // Use userId
);
},
),
// Optional: Global loading indicator overlay
// if (isActionLoading)
// Positioned.fill(
// child: Container(
// color: Colors.black.withOpacity(0.1),
// child: const Center(child: CircularProgressIndicator()),
// ),
// ),
],
);
},
),
floatingActionButton: FloatingActionButton(
// Use ! for userId
onPressed: () =>
_showAddEditDialog(context, currentUserId), // Pass userId
// tooltip: l10n.addConfigTooltip, // TODO: Add l10n.addConfigTooltip = '添加配置'
tooltip: '添加配置', // Placeholder
child: const Icon(Icons.add),
),
),
);
}
// <<< Add userId parameter >>>
void _showAddEditDialog(BuildContext context, String userId,
{UserAIModelConfigModel? config}) {
final aiConfigBloc =
context.read<AiConfigBloc>(); // Get BLoC from current context
showDialog(
context: context,
barrierDismissible:
false, // Prevent closing while dialog action is in progress
builder: (_) => BlocProvider.value(
// Provide the *existing* BLoC instance to the dialog
value: aiConfigBloc,
child: AddEditAiConfigDialog(
userId: userId, // Pass userId from parameter
configToEdit: config,
),
),
);
}
// <<< Add userId parameter >>>
void _showDeleteConfirmation(
BuildContext context, String userId, UserAIModelConfigModel config) {
final l10n = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
// title: Text(l10n.deleteConfigTitle), // TODO: Add l10n.deleteConfigTitle = '删除配置'
title: const Text('删除配置'), // Placeholder
// content: Text(l10n.deleteConfigConfirmation(config.alias)), // TODO: Add l10n.deleteConfigConfirmation
content: Text('确定要删除配置 ${config.alias} 吗?此操作无法撤销。'), // Placeholder
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
// child: Text(l10n.cancel), // TODO: Add l10n.cancel = '取消'
child: const Text('取消'), // Placeholder
),
TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.red),
onPressed: () {
// <<< Use userId from parameter >>>
context
.read<AiConfigBloc>()
.add(DeleteAiConfig(userId: userId, configId: config.id));
Navigator.pop(ctx); // Close confirmation dialog
},
// child: Text(l10n.delete), // TODO: Add l10n.delete = '删除'
child: const Text('删除'), // Placeholder
),
],
),
);
}
}

View File

@@ -0,0 +1,336 @@
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/l10n/app_localizations.dart';
class AddEditAiConfigDialog extends StatefulWidget {
// Needed for add/update events
const AddEditAiConfigDialog({
super.key,
required this.userId,
this.configToEdit,
});
final UserAIModelConfigModel? configToEdit;
final String userId;
@override
State<AddEditAiConfigDialog> createState() => _AddEditAiConfigDialogState();
}
class _AddEditAiConfigDialogState extends State<AddEditAiConfigDialog> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _aliasController;
late TextEditingController _apiKeyController;
late TextEditingController _apiEndpointController;
String? _selectedProvider;
String? _selectedModel;
bool _isLoadingProviders = false;
bool _isLoadingModels = false;
bool _isSaving = false; // Track internal saving state
List<String> _providers = [];
List<String> _models = [];
bool get _isEditMode => widget.configToEdit != null;
@override
void initState() {
super.initState();
// Initialize controllers
_aliasController =
TextEditingController(text: widget.configToEdit?.alias ?? '');
_apiKeyController =
TextEditingController(); // API key is never pre-filled for editing
_apiEndpointController =
TextEditingController(text: widget.configToEdit?.apiEndpoint ?? '');
_selectedProvider = widget.configToEdit?.provider;
_selectedModel = widget.configToEdit?.modelName;
// Request providers immediately if needed
if (!_isEditMode) {
_loadProviders();
} else if (_selectedProvider != null) {
// If editing, load providers to populate dropdown, and models for the selected provider
_loadProviders();
_loadModels(_selectedProvider!);
}
}
@override
void dispose() {
_aliasController.dispose();
_apiKeyController.dispose();
_apiEndpointController.dispose();
// Clear models when dialog is closed
context.read<AiConfigBloc>().add(ClearProviderModels());
super.dispose();
}
void _loadProviders() {
setState(() {
_isLoadingProviders = true;
});
// Use the Bloc provided via context
context.read<AiConfigBloc>().add(LoadAvailableProviders());
}
void _loadModels(String provider) {
setState(() {
_isLoadingModels = true;
_selectedModel = null; // Reset model selection when provider changes
_models = []; // Clear previous models
});
context.read<AiConfigBloc>().add(LoadModelsForProvider(provider: provider));
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
setState(() {
_isSaving = true;
});
final bloc = context.read<AiConfigBloc>();
if (_isEditMode) {
bloc.add(UpdateAiConfig(
userId: widget.userId,
configId: widget.configToEdit!.id,
alias: _aliasController.text.trim().isEmpty
? null
: _aliasController.text
.trim(), // Only send if not empty, or let backend decide
apiKey: _apiKeyController.text.trim().isEmpty
? null
: _apiKeyController.text.trim(), // Only send if changed
apiEndpoint: _apiEndpointController.text
.trim(), // Send empty string to clear endpoint
));
} else {
bloc.add(AddAiConfig(
userId: widget.userId,
provider: _selectedProvider!,
modelName: _selectedModel!,
apiKey: _apiKeyController.text.trim(),
alias: _aliasController.text.trim().isEmpty
? _selectedModel
: _aliasController.text
.trim(), // Default alias to model name if empty
apiEndpoint: _apiEndpointController.text.trim(),
));
}
// Listen for completion state change to close dialog
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return BlocListener<AiConfigBloc, AiConfigState>(
listener: (context, state) {
// Update local lists and loading states based on Bloc state
setState(() {
_providers = state.availableProviders;
_isLoadingProviders =
false; // Assuming load finishes once providers appear
if (state.selectedProviderForModels == _selectedProvider) {
_models = state.modelsForProvider;
_isLoadingModels = false;
} else if (_selectedProvider != null &&
state.selectedProviderForModels != _selectedProvider) {
// Handle case where Bloc state is for a different provider than selected
_isLoadingModels = false; // Stop loading indicator
}
// Handle save completion or error
if (_isSaving) {
if (state.actionStatus == AiConfigActionStatus.success ||
state.actionStatus == AiConfigActionStatus.error) {
_isSaving = false;
if (state.actionStatus == AiConfigActionStatus.success &&
mounted) {
Navigator.of(context).pop(); // Close dialog on success
}
// Error message is handled by the main screen's listener
}
}
});
},
child: AlertDialog(
// title: Text(_isEditMode ? l10n.editConfigTitle : l10n.addConfigTitle), // TODO: Add l10n
title: Text(_isEditMode ? '编辑配置' : '添加配置'), // Placeholder
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// --- Provider Dropdown ---
DropdownButtonFormField<String>(
value: _selectedProvider,
// hint: Text(l10n.selectProviderHint), // TODO: Add l10n
hint: const Text('选择提供商'), // Placeholder
isExpanded: true,
onChanged: _isEditMode
? null // Cannot change provider when editing
: (String? newValue) {
if (newValue != null &&
newValue != _selectedProvider) {
setState(() {
_selectedProvider = newValue;
_selectedModel = null; // Reset model
_models = []; // Clear models
});
_loadModels(newValue);
}
},
items:
_providers.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
// validator: (value) => value == null ? l10n.providerRequired : null, // TODO: Add l10n
validator: (value) =>
value == null ? '请选择提供商' : null, // Placeholder
decoration: InputDecoration(
// labelText: l10n.providerLabel, // TODO: Add l10n
labelText: '提供商', // Placeholder
border: const OutlineInputBorder(),
suffixIcon: _isLoadingProviders
? const Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(strokeWidth: 2))
: null,
),
disabledHint: _isEditMode
? Text(_selectedProvider ?? '')
: null, // Show selected value when disabled
style: _isEditMode
? TextStyle(color: Theme.of(context).disabledColor)
: null,
),
const SizedBox(height: 16),
// --- Model Dropdown ---
DropdownButtonFormField<String>(
value: _selectedModel,
// hint: Text(l10n.selectModelHint), // TODO: Add l10n
hint: const Text('选择模型'), // Placeholder
isExpanded: true,
onChanged: _isEditMode ||
_selectedProvider == null ||
_isLoadingModels
? null // Cannot change model when editing or provider not selected or loading
: (String? newValue) {
setState(() {
_selectedModel = newValue;
});
},
items: _models.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value, overflow: TextOverflow.ellipsis),
);
}).toList(),
// validator: (value) => value == null ? l10n.modelRequired : null, // TODO: Add l10n
validator: (value) =>
value == null ? '请选择模型' : null, // Placeholder
decoration: InputDecoration(
// labelText: l10n.modelLabel, // TODO: Add l10n
labelText: '模型', // Placeholder
border: const OutlineInputBorder(),
suffixIcon: _isLoadingModels
? const Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(strokeWidth: 2))
: null,
),
disabledHint: _isEditMode
? Text(_selectedModel ?? '')
: null, // Show selected value when disabled
style: _isEditMode
? TextStyle(color: Theme.of(context).disabledColor)
: null,
),
const SizedBox(height: 16),
// --- Alias ---
TextFormField(
controller: _aliasController,
decoration: InputDecoration(
// labelText: l10n.aliasLabel, // TODO: Add l10n
labelText: '别名 (可选)', // Placeholder
// hintText: l10n.aliasHint( _selectedModel ?? 'model'), // TODO: Add l10n
hintText: '例如:我的${_selectedModel ?? '模型'}', // Placeholder
border: const OutlineInputBorder()),
// No validator, alias is optional or defaults
),
const SizedBox(height: 16),
// --- API Key ---
TextFormField(
controller: _apiKeyController,
obscureText: true,
decoration: InputDecoration(
// labelText: l10n.apiKeyLabel, // TODO: Add l10n
labelText: 'API Key', // Placeholder
// hintText: _isEditMode ? l10n.apiKeyEditHint : null, // TODO: Add l10n
hintText: _isEditMode ? '留空则不更新' : null, // Placeholder
border: const OutlineInputBorder()),
validator: (value) {
if (!_isEditMode &&
(value == null || value.trim().isEmpty)) {
// return l10n.apiKeyRequired; // TODO: Add l10n
return 'API Key 不能为空'; // Placeholder
}
return null;
},
),
const SizedBox(height: 16),
// --- API Endpoint ---
TextFormField(
controller: _apiEndpointController,
decoration: const InputDecoration(
// labelText: l10n.apiEndpointLabel, // TODO: Add l10n
labelText: 'API Endpoint (可选)', // Placeholder
// hintText: l10n.apiEndpointHint, // TODO: Add l10n
hintText: '例如: https://api.openai.com/v1', // Placeholder
border: OutlineInputBorder()),
// No validator, endpoint is optional
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
// child: Text(l10n.cancel), // TODO: Add l10n
child: const Text('取消'), // Placeholder
),
ElevatedButton(
onPressed: _isSaving ? null : _submitForm,
child: _isSaving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
// : Text(_isEditMode ? l10n.saveChanges : l10n.add), // TODO: Add l10n
: Text(_isEditMode ? '保存更改' : '添加'), // Placeholder
),
],
),
);
}
}
// TODO: Add localization strings: editConfigTitle, addConfigTitle, selectProviderHint, providerRequired, providerLabel,
// selectModelHint, modelRequired, modelLabel, aliasLabel, aliasHint, apiKeyLabel, apiKeyEditHint, apiKeyRequired,
// apiEndpointLabel, apiEndpointHint, cancel, saveChanges, add

View File

@@ -0,0 +1,268 @@
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:flutter/material.dart';
import 'package:ainoval/l10n/app_localizations.dart';
import 'package:intl/intl.dart'; // For date formatting
class AiConfigListItem extends StatelessWidget {
// Indicate if an action is pending for this item (optional, for finer control)
const AiConfigListItem({
super.key,
required this.config,
required this.onEdit,
required this.onDelete,
required this.onValidate,
required this.onSetDefault,
this.isLoading = false, // Default to false
});
final UserAIModelConfigModel config;
final VoidCallback onEdit;
final VoidCallback onDelete;
final VoidCallback onValidate;
final VoidCallback onSetDefault;
final bool isLoading;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final disabledColor = theme.disabledColor;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
clipBehavior: Clip.antiAlias,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.surface.withAlpha(255),
isDark
? theme.colorScheme.surfaceContainerHighest.withAlpha(255)
: theme.colorScheme.surfaceContainerLowest.withAlpha(255),
],
),
border: Border.all(
color: isDark
? Colors.white.withAlpha(13) // 0.05 opacity
: Colors.black.withAlpha(13), // 0.05 opacity
width: 0.5,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withAlpha(51) // 0.2 opacity
: Colors.black.withAlpha(13), // 0.05 opacity
blurRadius: 8,
spreadRadius: 0,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
config.alias,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold, fontSize: 18),
overflow: TextOverflow.ellipsis,
),
),
if (config.isDefault)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isDark
? Colors.green.withAlpha(51) // 0.2 opacity
: Colors.green.shade100,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(13), // 0.05 opacity
blurRadius: 2,
spreadRadius: 0,
),
],
),
child: Text('默认',
style: TextStyle(
color: isDark ? Colors.green.shade300 : Colors.green.shade900,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') onEdit();
if (value == 'delete') onDelete();
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(
value: 'delete',
child: Text('删除', style: TextStyle(color: Colors.red))),
],
icon: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.more_vert),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isDark
? theme.colorScheme.surfaceContainerHighest.withAlpha(77) // 0.3 opacity
: theme.colorScheme.surfaceContainerLowest.withAlpha(128), // 0.5 opacity
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${config.provider} / ${config.modelName}',
style: theme.textTheme.bodyMedium?.copyWith(
color: isDark
? theme.colorScheme.onSurface.withAlpha(230) // 0.9 opacity
: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
if (config.apiEndpoint != null && config.apiEndpoint!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
Icon(Icons.link,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(128)),
const SizedBox(width: 4),
Expanded(
child: Text(
config.apiEndpoint!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(179), // 0.7 opacity
fontFamily: 'monospace',
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: config.isValidated
? (isDark ? Colors.green.withAlpha(26) : Colors.green.withAlpha(13)) // 0.1/0.05 opacity
: (isDark ? Colors.grey.withAlpha(26) : Colors.grey.withAlpha(13)), // 0.1/0.05 opacity
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: config.isValidated
? Colors.green.withAlpha(77) // 0.3 opacity
: Colors.grey.withAlpha(77), // 0.3 opacity
width: 0.5,
),
),
child: Row(
children: [
Icon(
config.isValidated ? Icons.check_circle : Icons.error_outline,
color: config.isValidated
? Colors.green
: Colors.grey,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
config.isValidated
? '已验证'
: '未验证',
style: theme.textTheme.bodySmall?.copyWith(
color: config.isValidated
? Colors.green
: Colors.grey,
fontStyle: config.isValidated
? FontStyle.normal
: FontStyle.italic,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.update,
size: 14,
color: theme.colorScheme.onSurface.withAlpha(128)),
const SizedBox(width: 4),
Text(
DateFormat.yMd().add_jm().format(config.updatedAt.toLocal()),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(128)),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Divider(height: 1, thickness: 0.5),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (!config.isValidated)
ElevatedButton.icon(
icon: const Icon(Icons.sync, size: 16),
label: const Text('验证'),
onPressed: isLoading ? null : onValidate,
style: ElevatedButton.styleFrom(
foregroundColor: theme.colorScheme.onSecondaryContainer,
backgroundColor: theme.colorScheme.secondaryContainer,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
visualDensity: VisualDensity.compact,
),
),
if (config.isValidated && !config.isDefault)
ElevatedButton.icon(
icon: const Icon(Icons.star_border, size: 16),
label: const Text('设为默认'),
onPressed: isLoading ? null : onSetDefault,
style: ElevatedButton.styleFrom(
foregroundColor: theme.colorScheme.onPrimaryContainer,
backgroundColor: theme.colorScheme.primaryContainer,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
visualDensity: VisualDensity.compact,
),
),
],
),
],
),
),
));
}
}

View File

@@ -0,0 +1,124 @@
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/l10n/app_localizations.dart';
// Callback type when a config is selected
typedef AiConfigSelectedCallback = void Function(
UserAIModelConfigModel? selectedConfig);
class AiModelSelector extends StatelessWidget {
// Allow pre-selecting a config
const AiModelSelector({
super.key,
required this.onConfigSelected,
this.initialSelection,
});
final AiConfigSelectedCallback onConfigSelected;
final UserAIModelConfigModel? initialSelection;
// Helper to find the config by ID in the list
UserAIModelConfigModel? _findConfigById(
List<UserAIModelConfigModel> configs, String? id) {
if (id == null) return null;
return configs.firstWhereOrNull((c) => c.id == id);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// Assume AiConfigBloc is provided higher up the tree
return BlocBuilder<AiConfigBloc, AiConfigState>(
builder: (context, state) {
final validatedConfigs = state.validatedConfigs;
// Determine the current selection based on initialSelection or state's default
UserAIModelConfigModel? currentSelection =
_findConfigById(validatedConfigs, initialSelection?.id) ??
state.defaultConfig;
// Ensure the current selection is actually in the validated list
if (currentSelection != null &&
!validatedConfigs.any((c) => c.id == currentSelection!.id)) {
currentSelection = validatedConfigs.firstWhereOrNull((_) => true);
}
if (state.status == AiConfigStatus.loading &&
validatedConfigs.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (validatedConfigs.isEmpty) {
return const Tooltip(
message: '前往设置添加或验证模型',
child: Chip(
avatar: Icon(Icons.error_outline, color: Colors.orange),
label: Text('无可用模型'),
),
);
}
return DropdownButton<UserAIModelConfigModel>(
value: currentSelection,
hint: const Text('选择AI模型'),
underline: Container(),
onChanged: (UserAIModelConfigModel? newValue) {
onConfigSelected(newValue);
},
selectedItemBuilder: (BuildContext context) {
return validatedConfigs.map<Widget>((UserAIModelConfigModel item) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
avatar: const Icon(Icons.smart_toy_outlined, size: 16),
label: Text(item.alias,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 4),
),
);
}).toList();
},
items: validatedConfigs.map<DropdownMenuItem<UserAIModelConfigModel>>(
(UserAIModelConfigModel config) {
return DropdownMenuItem<UserAIModelConfigModel>(
value: config,
child: Row(
children: [
Text(config.alias),
if (config.isDefault)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: Icon(Icons.star, size: 14, color: Colors.amber),
),
const Spacer(),
Text(
'(${config.provider})',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.grey),
)
],
),
);
}).toList(),
);
},
);
}
}
// TODO: Add localization strings to .arb files:
// - manageConfigsTooltip: '前往设置添加或验证模型'
// - noValidatedConfigsFound: '无可用模型'
// - selectAiModelHint: '选择AI模型'