Skip to content
Open
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
29 changes: 29 additions & 0 deletions flutter_app/lib/screens/provider_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ class ProviderDetailScreen extends StatefulWidget {
final AiProvider provider;
final String? existingApiKey;
final String? existingModel;
final String? existingBaseUrl;

const ProviderDetailScreen({
super.key,
required this.provider,
this.existingApiKey,
this.existingModel,
this.existingBaseUrl,
});

@override
Expand All @@ -24,6 +26,7 @@ class _ProviderDetailScreenState extends State<ProviderDetailScreen> {
static const _customModelSentinel = '__custom__';

late final TextEditingController _apiKeyController;
late final TextEditingController _baseUrlController;
late final TextEditingController _customModelController;
late String _selectedModel;
bool _isCustomModel = false;
Expand All @@ -41,6 +44,9 @@ class _ProviderDetailScreenState extends State<ProviderDetailScreen> {
void initState() {
super.initState();
_apiKeyController = TextEditingController(text: widget.existingApiKey ?? '');
_baseUrlController = TextEditingController(
text: widget.existingBaseUrl ?? widget.provider.baseUrl,
);
_customModelController = TextEditingController();

final existing = widget.existingModel ?? widget.provider.defaultModels.first;
Expand All @@ -57,6 +63,7 @@ class _ProviderDetailScreenState extends State<ProviderDetailScreen> {
@override
void dispose() {
_apiKeyController.dispose();
_baseUrlController.dispose();
_customModelController.dispose();
super.dispose();
}
Expand All @@ -69,6 +76,13 @@ class _ProviderDetailScreenState extends State<ProviderDetailScreen> {
);
return;
}
final baseUrl = _baseUrlController.text.trim();
if (baseUrl.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('API URL cannot be empty')),
);
return;
}
final model = _effectiveModel;
if (model.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
Expand All @@ -82,6 +96,7 @@ class _ProviderDetailScreenState extends State<ProviderDetailScreen> {
await ProviderConfigService.saveProviderConfig(
provider: widget.provider,
apiKey: apiKey,
baseUrl: baseUrl,
model: model,
);
if (mounted) {
Expand Down Expand Up @@ -214,6 +229,20 @@ class _ProviderDetailScreenState extends State<ProviderDetailScreen> {
),
const SizedBox(height: 24),

// API URL
Text(
'API URL',
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
TextField(
controller: _baseUrlController,
decoration: const InputDecoration(
hintText: 'https://api.example.com/v1',
),
),
const SizedBox(height: 24),

// Model selection
Text(
'Model',
Expand Down
3 changes: 2 additions & 1 deletion flutter_app/lib/screens/providers_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ class _ProvidersScreenState extends State<ProvidersScreen> {
builder: (_) => ProviderDetailScreen(
provider: provider,
existingApiKey: providerConfig?['apiKey'] as String?,
existingModel: _activeModel,
existingBaseUrl: providerConfig?['baseUrl'] as String?,
existingModel: providerConfig?['model'] as String? ?? _activeModel,
),
),
);
Expand Down
29 changes: 24 additions & 5 deletions flutter_app/lib/services/provider_config_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,20 @@ class ProviderConfigService {
return "'${s.replaceAll("'", "'\\''")}'";
}

static String? _extractPrimaryModel(dynamic modelsRaw) {
if (modelsRaw is! List || modelsRaw.isEmpty) return null;
final first = modelsRaw.first;
if (first is String) return first;
if (first is Map) {
final id = first['id'];
if (id is String && id.isNotEmpty) return id;
}
return null;
}

/// Read the current config and return a map with:
/// - `activeModel`: the current primary model string (or null)
/// - `providers`: Map<providerId, {apiKey, model}> for configured providers
/// - `providers`: Map<providerId, {apiKey, baseUrl, model, ...}> for configured providers
static Future<Map<String, dynamic>> readConfig() async {
try {
final content = await NativeBridge.readRootfsFile(_configPath);
Expand Down Expand Up @@ -42,7 +53,14 @@ class ProviderConfigService {
final providerEntries = modelsSection['providers'] as Map<String, dynamic>?;
if (providerEntries != null) {
for (final entry in providerEntries.entries) {
providers[entry.key] = entry.value;
if (entry.value is Map) {
final normalized = Map<String, dynamic>.from(entry.value as Map);
final model = _extractPrimaryModel(normalized['models']);
if (model != null) {
normalized['model'] = model;
}
providers[entry.key] = normalized;
}
}
}
}
Expand All @@ -53,17 +71,18 @@ class ProviderConfigService {
}
}

/// Save a provider's API key and set its model as the active model.
/// Save a provider's API key/base URL and set its model as the active model.
/// Tries a Node.js one-liner in proot first, then falls back to a direct
/// file write via NativeBridge.writeRootfsFile if proot/DNS is unavailable.
static Future<void> saveProviderConfig({
required AiProvider provider,
required String apiKey,
required String baseUrl,
required String model,
}) async {
final providerIdJson = jsonEncode(provider.id);
final apiKeyJson = jsonEncode(apiKey);
final baseUrlJson = jsonEncode(provider.baseUrl);
final baseUrlJson = jsonEncode(baseUrl);
final modelJson = jsonEncode(model);

// Build the provider object with the model as an object containing `id`,
Expand Down Expand Up @@ -100,7 +119,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2));
await _saveConfigDirect(
providerId: provider.id,
apiKey: apiKey,
baseUrl: provider.baseUrl,
baseUrl: baseUrl,
model: model,
);
}
Expand Down
Loading