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
8 changes: 8 additions & 0 deletions lib/config/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ class AppConstants {
static const String youtubeSuggestions = '$apiBase/youtube/suggestions';
static const String settingsYoutubeCookies =
'$apiBase/settings/youtube-cookies';
static const String settingsAiProviders = '$apiBase/settings/ai-providers';
static String settingsAiKey(String provider) =>
'$apiBase/settings/ai-providers/keys/$provider';
static String settingsAiSelection(String role) =>
'$apiBase/settings/ai-providers/selection/$role';
static const String settingsChatgptLogin = '$apiBase/settings/chatgpt-login';
static const String settingsChatgptLoginPoll =
'$apiBase/settings/chatgpt-login/poll';
static const String channels = '$apiBase/channels';
static const String channelSubscribe = '$apiBase/channels/subscribe';
static const String channelSubscribeBulk = '$apiBase/channels/subscribe-bulk';
Expand Down
158 changes: 158 additions & 0 deletions lib/models/ai_providers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/// Admin-managed AI provider configuration (Discover pipeline).
///
/// Parsed from `GET /api/settings/ai-providers`. Keys are never returned by
/// the server — only their status (configured / source / masked last 4).
library;

/// Status of one provider API key.
class AiKeyStatus {
const AiKeyStatus({
required this.configured,
required this.source,
required this.last4,
});

/// Whether an effective key exists (runtime override or env fallback).
final bool configured;

/// Where the effective value comes from: `runtime`, `env`, or null.
final String? source;

/// Last 4 chars of the effective key, or null when hidden/short.
final String? last4;

factory AiKeyStatus.fromJson(Map<String, dynamic> json) => AiKeyStatus(
configured: json['configured'] == true,
source: json['source'] as String?,
last4: json['last4'] as String?,
);
}

/// The embed or rank provider selection plus what's actually live.
class AiSelection {
const AiSelection({
required this.provider,
required this.model,
required this.source,
required this.effectiveProvider,
required this.effectiveModel,
required this.options,
});

/// The configured provider (`""` means auto-detect).
final String provider;

/// Optional model override (`""` means the provider default).
final String model;

/// `runtime` when pinned in-app, else `env`.
final String source;

/// The provider that will actually be used, or null when none is available.
final String? effectiveProvider;
final String? effectiveModel;

/// Valid provider options for this role.
final List<String> options;

factory AiSelection.fromJson(Map<String, dynamic> json) {
final effective = json['effective'] as Map<String, dynamic>?;
return AiSelection(
provider: (json['provider'] as String?) ?? '',
model: (json['model'] as String?) ?? '',
source: (json['source'] as String?) ?? 'env',
effectiveProvider: effective?['provider'] as String?,
effectiveModel: effective?['model'] as String?,
options: [for (final o in (json['options'] as List? ?? const [])) '$o'],
);
}
}

/// Full AI-config view for the admin panel.
class AiProvidersStatus {
const AiProvidersStatus({
required this.keys,
required this.embed,
required this.rank,
required this.availability,
});

/// Keyed by provider: anthropic / gemini / openai.
final Map<String, AiKeyStatus> keys;
final AiSelection embed;
final AiSelection rank;

/// Per-provider "usable right now", including `chatgpt` (sign-in based).
final Map<String, bool> availability;

factory AiProvidersStatus.fromJson(Map<String, dynamic> json) {
final rawKeys = (json['keys'] as Map<String, dynamic>?) ?? const {};
final rawAvail =
(json['availability'] as Map<String, dynamic>?) ?? const {};
return AiProvidersStatus(
keys: {
for (final e in rawKeys.entries)
e.key: AiKeyStatus.fromJson(e.value as Map<String, dynamic>),
},
embed: AiSelection.fromJson(
(json['embed'] as Map<String, dynamic>?) ?? const {},
),
rank: AiSelection.fromJson(
(json['rank'] as Map<String, dynamic>?) ?? const {},
),
availability: {for (final e in rawAvail.entries) e.key: e.value == true},
);
}
}

/// Status of the ChatGPT (Codex OAuth) sign-in.
class ChatgptLoginStatus {
const ChatgptLoginStatus({
required this.connected,
required this.needsReauth,
required this.pending,
required this.userCode,
required this.verificationUrl,
});

final bool connected;
final bool needsReauth;
final bool pending;

/// Device code to enter (only while a sign-in is pending).
final String? userCode;
final String? verificationUrl;

factory ChatgptLoginStatus.fromJson(Map<String, dynamic> json) =>
ChatgptLoginStatus(
connected: json['connected'] == true,
needsReauth: json['needs_reauth'] == true,
pending: json['pending'] == true,
userCode: json['user_code'] as String?,
verificationUrl: json['verification_url'] as String?,
);
}

/// One poll of the device sign-in.
class ChatgptPollResult {
const ChatgptPollResult({
required this.status,
required this.detail,
required this.userCode,
required this.verificationUrl,
});

/// idle | pending | connected | expired | error
final String status;
final String? detail;
final String? userCode;
final String? verificationUrl;

factory ChatgptPollResult.fromJson(Map<String, dynamic> json) =>
ChatgptPollResult(
status: (json['status'] as String?) ?? 'idle',
detail: json['detail'] as String?,
userCode: json['user_code'] as String?,
verificationUrl: json['verification_url'] as String?,
);
}
11 changes: 11 additions & 0 deletions lib/screens/settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import '../providers/settings_provider.dart';
import '../services/api_service.dart';
import '../utils/browser_link.dart';
import '../widgets/adaptive_layout.dart';
import '../widgets/ai_providers_section.dart';
import '../widgets/app_ui.dart';
import '../widgets/profile_avatar.dart';

Expand Down Expand Up @@ -373,6 +374,16 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
'for every profile on this server.',
),
const _YoutubeCookiesSection(),
const SizedBox(height: 32),
const _SectionHeader(
icon: Icons.auto_awesome_rounded,
title: 'AI providers',
description:
'Keys and provider choices for the Discover tab. '
'Set here to override the server environment; '
'clear to fall back to it.',
),
const AiProvidersSection(),
],
const SizedBox(height: 32),
const _SectionHeader(
Expand Down
68 changes: 68 additions & 0 deletions lib/services/api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import '../models/video_page.dart';
import '../models/feed.dart';
import '../models/recommendation.dart';
import '../models/youtube_import.dart';
import '../models/ai_providers.dart';
import 'storage_service.dart';

final apiServiceProvider = Provider<ApiService>((ref) {
Expand Down Expand Up @@ -493,6 +494,73 @@ class ApiService {
await _dio.delete('$_baseUrl${AppConstants.settingsYoutubeCookies}');
});

// --- AI providers (admin) -------------------------------------------------

Future<AiProvidersStatus> getAiProviders() => _guard(() async {
final r = await _dio.get('$_baseUrl${AppConstants.settingsAiProviders}');
return AiProvidersStatus.fromJson(r.data as Map<String, dynamic>);
});

Future<AiProvidersStatus> setAiKey(String provider, String key) =>
_guard(() async {
final r = await _dio.put(
'$_baseUrl${AppConstants.settingsAiKey(provider)}',
data: {'key': key},
);
return AiProvidersStatus.fromJson(r.data as Map<String, dynamic>);
});

Future<AiProvidersStatus> clearAiKey(String provider) => _guard(() async {
final r = await _dio.delete(
'$_baseUrl${AppConstants.settingsAiKey(provider)}',
);
return AiProvidersStatus.fromJson(r.data as Map<String, dynamic>);
});

/// Pin the [role] (`embed`/`rank`) provider (+ optional model), or pass an
/// empty provider to auto-detect.
Future<AiProvidersStatus> setAiSelection(
String role, {
required String provider,
String model = '',
}) => _guard(() async {
final r = await _dio.put(
'$_baseUrl${AppConstants.settingsAiSelection(role)}',
data: {'provider': provider, 'model': model},
);
return AiProvidersStatus.fromJson(r.data as Map<String, dynamic>);
});

// --- ChatGPT (Codex OAuth) sign-in (admin) --------------------------------

Future<ChatgptLoginStatus> getChatgptLogin() => _guard(() async {
final r = await _dio.get('$_baseUrl${AppConstants.settingsChatgptLogin}');
return ChatgptLoginStatus.fromJson(r.data as Map<String, dynamic>);
});

Future<ChatgptPollResult> startChatgptLogin() => _guard(() async {
final r = await _dio.post('$_baseUrl${AppConstants.settingsChatgptLogin}');
// The start response has the same shape as a pending poll.
final data = r.data as Map<String, dynamic>;
return ChatgptPollResult(
status: 'pending',
detail: null,
userCode: data['user_code'] as String?,
verificationUrl: data['verification_url'] as String?,
);
});

Future<ChatgptPollResult> pollChatgptLogin() => _guard(() async {
final r = await _dio.post(
'$_baseUrl${AppConstants.settingsChatgptLoginPoll}',
);
return ChatgptPollResult.fromJson(r.data as Map<String, dynamic>);
});

Future<void> clearChatgptLogin() => _guard(() async {
await _dio.delete('$_baseUrl${AppConstants.settingsChatgptLogin}');
});

/// Records an evictable cache claim on [videoId] and (server-side) kicks off
/// its HQ download, without it showing in the Downloads tab. Called when the
/// user starts instant playback of a not-yet-downloaded video so the player
Expand Down
Loading
Loading