Skip to content

Commit

Permalink
ChatGPT support
Browse files Browse the repository at this point in the history
  • Loading branch information
arianneorpilla committed Mar 29, 2023
1 parent b6e6030 commit 760d8a5
Show file tree
Hide file tree
Showing 14 changed files with 865 additions and 10 deletions.
18 changes: 16 additions & 2 deletions yuuna/lib/i18n/strings.g.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/// Generated file. Do not edit.
///
/// Locales: 1
/// Strings: 309
/// Strings: 316
///
/// Built on 2023-03-12 at 04:12 UTC
/// Built on 2023-03-28 at 13:06 UTC
// coverage:ignore-file
// ignore_for_file: type=lint
Expand Down Expand Up @@ -456,6 +456,13 @@ class _StringsEn implements BaseTranslations<AppLocale, _StringsEn> {
String get no_text_in_clipboard => 'No text to display';
String file_downloaded({required Object name}) => 'File downloaded: ${name}';
String get change_sort_order => 'Change Sort Order';
String get login => 'Login';
String get access_token_missing_expired => 'Access token missing or expired';
String get send => 'Send';
String get no_messages => 'Enter text to chat';
String get enter_message => 'Enter message...';
String get clear_message_title => 'Clear Messages';
String get clear_message_description => 'This will clear all messages. Are you sure?';
late final _StringsViewRepliesEn view_replies = _StringsViewRepliesEn._(_root);
}

Expand Down Expand Up @@ -785,6 +792,13 @@ extension on _StringsEn {
case 'no_text_in_clipboard': return 'No text to display';
case 'file_downloaded': return ({required Object name}) => 'File downloaded: ${name}';
case 'change_sort_order': return 'Change Sort Order';
case 'login': return 'Login';
case 'access_token_missing_expired': return 'Access token missing or expired';
case 'send': return 'Send';
case 'no_messages': return 'Enter text to chat';
case 'enter_message': return 'Enter message...';
case 'clear_message_title': return 'Clear Messages';
case 'clear_message_description': return 'This will clear all messages. Are you sure?';
case 'view_replies.reply': return ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n,
one: 'SHOW ${n} REPLY',
other: 'SHOW ${n} REPLIES',
Expand Down
7 changes: 7 additions & 0 deletions yuuna/lib/i18n/strings.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,13 @@
"no_text_in_clipboard": "No text to display",
"file_downloaded": "File downloaded: $name",
"change_sort_order": "Change Sort Order",
"login": "Login",
"access_token_missing_expired": "Login token missing or expired",
"send": "Send",
"no_messages": "Start a chat",
"enter_message": "Enter message...",
"clear_message_title": "Clear Messages",
"clear_message_description": "This will clear all messages and start a new chat. Are you sure?",
"view_replies": {
"reply": {
"one": "SHOW $n REPLY",
Expand Down
1 change: 1 addition & 0 deletions yuuna/lib/media.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export 'src/media/sources/player_youtube_source.dart';
export 'src/media/sources/reader_ttu_source.dart';
export 'src/media/sources/reader_lyrics_source.dart';
export 'src/media/sources/reader_clipboard_source.dart';
export 'src/media/sources/reader_chatgpt_source.dart';
export 'src/media/sources/reader_websocket_source.dart';
export 'src/media/sources/viewer_camera_source.dart';
2 changes: 2 additions & 0 deletions yuuna/lib/pages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export 'src/pages/implementations/dictionary_settings_dialog_page.dart';
export 'src/pages/implementations/reader_clipboard_page.dart';
export 'src/pages/implementations/pip_page.dart';
export 'src/pages/implementations/audio_recorder_page.dart';
export 'src/pages/implementations/reader_chatgpt_page.dart';
export 'src/pages/implementations/reader_chatgpt_login_page.dart';

export 'src/pages/base_page.dart';
export 'src/pages/base_history_page.dart';
Expand Down
213 changes: 213 additions & 0 deletions yuuna/lib/src/media/sources/reader_chatgpt_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_chatgpt_api/flutter_chatgpt_api.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_floating_search_bar/material_floating_search_bar.dart';
import 'package:spaces/spaces.dart';
import 'package:yuuna/media.dart';
import 'package:yuuna/models.dart';
import 'package:yuuna/pages.dart';
import 'package:yuuna/utils.dart';

// ignore: implementation_imports
import 'package:flutter_chatgpt_api/src/models/chat_response.model.dart';

/// A global [Provider] for getting necessary cookies.
final accessCookieProvider = FutureProvider<Cookie?>((ref) {
return CookieManager.instance().getCookie(
url: Uri.parse('https://chat.openai.com/'),
name: '__Secure-next-auth.session-token',
);
});

/// A global [Provider] for getting necessary cookies.
final clearanceCookieProvider = FutureProvider<Cookie?>((ref) {
return CookieManager.instance().getCookie(
url: Uri.parse('https://chat.openai.com/'),
name: 'cf_clearance',
);
});

/// A media source that allows the user to paste and select text.
class ReaderChatgptSource extends ReaderMediaSource {
/// Define this media source.
ReaderChatgptSource._privateConstructor()
: super(
uniqueKey: 'reader_chatgpt',
sourceName: 'ChatGPT',
description:
'Allows the user to login to OpenAI and access its unofficial REST API.',
icon: Icons.chat_outlined,
implementsSearch: false,
implementsHistory: false,
);

/// Get the singleton instance of this media type.
static ReaderChatgptSource get instance => _instance;

static final ReaderChatgptSource _instance =
ReaderChatgptSource._privateConstructor();

/// List of chat messages sent to and received from ChatGPT.
List<ChatMessage> messages = [];

/// Gets the last response from ChatGPT.
ChatResponse? lastResponse;

/// Access token used for sending messages.
String? messageAccessToken;

/// Used for preparing the actual access token used for sending messages.
/// This is different from the access token persisted in the cookies.
Future<void> prepareMessageAccessToken() async {
bool webViewBusy = true;

if (messageAccessToken == null) {
HeadlessInAppWebView webView = HeadlessInAppWebView(
initialOptions: InAppWebViewGroupOptions(
crossPlatform: InAppWebViewOptions(
userAgent:
'Mozilla 5.0 (Linux; U; Android 13) Chrome/104.0.5112.99',
),
),
initialUrlRequest: URLRequest(
url: Uri.parse('https://chat.openai.com/api/auth/session'),
),
onLoadStop: (controller, uri) async {
messageAccessToken = await controller.evaluateJavascript(
source: 'JSON.parse(document.body.textContent).accessToken;');

webViewBusy = false;
});

await webView.run();

while (webViewBusy) {
await Future.delayed(const Duration(milliseconds: 100), () {});
}

await webView.dispose();
}
}

@override
Future<void> onSearchBarTap({
required BuildContext context,
required WidgetRef ref,
required AppModel appModel,
}) async {}

@override
BaseSourcePage buildLaunchPage({
MediaItem? item,
}) {
throw UnsupportedError('ChatGPT source does not launch any page');
}

@override
List<Widget> getActions({
required BuildContext context,
required WidgetRef ref,
required AppModel appModel,
}) {
return [
buildClearButton(
context: context,
ref: ref,
appModel: appModel,
),
buildLoginButton(context: context, ref: ref, appModel: appModel),
];
}

/// Menu bar action.
Widget buildLoginButton(
{required BuildContext context,
required WidgetRef ref,
required AppModel appModel}) {
return FloatingSearchBarAction(
child: JidoujishoIconButton(
size: Theme.of(context).textTheme.titleLarge?.fontSize,
tooltip: t.login,
icon: Icons.login,
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ReaderChatgptLoginPage()),
);

ref.refresh(accessCookieProvider);
ref.refresh(clearanceCookieProvider);
},
),
);
}

/// Menu bar action.
Widget buildClearButton(
{required BuildContext context,
required WidgetRef ref,
required AppModel appModel}) {
return FloatingSearchBarAction(
child: JidoujishoIconButton(
size: Theme.of(context).textTheme.titleLarge?.fontSize,
tooltip: t.clear_text_title,
icon: Icons.clear_all,
onTap: () {
showClearPrompt(
appModel: appModel,
context: context,
ref: ref,
);
},
),
);
}

/// Shows when the clear button is pressed.
void showClearPrompt(
{required BuildContext context,
required WidgetRef ref,
required AppModel appModel}) async {
Widget alertDialog = AlertDialog(
contentPadding: MediaQuery.of(context).orientation == Orientation.portrait
? Spacing.of(context).insets.exceptBottom.big
: Spacing.of(context).insets.exceptBottom.normal,
title: Text(t.clear_message_title),
content: Text(t.clear_message_description),
actions: <Widget>[
TextButton(
child: Text(
t.dialog_clear,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
),
onPressed: () async {
messages = [];
lastResponse = null;
appModel.refresh();

Navigator.pop(context);
},
),
TextButton(
child: Text(t.dialog_cancel),
onPressed: () => Navigator.pop(context),
),
],
);

await showDialog(
context: context,
builder: (context) => alertDialog,
);
}

@override
BasePage buildHistoryPage({MediaItem? item}) {
return const ReaderChatgptPage();
}
}
1 change: 1 addition & 0 deletions yuuna/lib/src/models/app_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@ class AppModel with ChangeNotifier {
ReaderMediaType.instance: [
ReaderTtuSource.instance,
ReaderLyricsSource.instance,
ReaderChatgptSource.instance,
ReaderClipboardSource.instance,
ReaderWebsocketSource.instance,
],
Expand Down
1 change: 0 additions & 1 deletion yuuna/lib/src/pages/implementations/creator_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import 'dart:ui';
import 'package:expandable/expandable.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_exit_app/flutter_exit_app.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:spaces/spaces.dart';
import 'package:subtitle/subtitle.dart';
Expand Down
61 changes: 61 additions & 0 deletions yuuna/lib/src/pages/implementations/reader_chatgpt_login_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:yuuna/media.dart';
import 'package:yuuna/pages.dart';
import 'package:yuuna/utils.dart';

/// A page for [ReaderChatgptSource] which shows the content of the current
/// clipboard as selectable text.
class ReaderChatgptLoginPage extends BasePage {
/// Create an instance of this tab page.
const ReaderChatgptLoginPage({
super.key,
});

@override
BasePageState createState() => _ReaderChatgptLoginPageState();
}

/// A base class for providing all tabs in the main menu. In large part, this
/// was implemented to define shortcuts for common lengthy methods across UI
/// code.
class _ReaderChatgptLoginPageState extends BasePageState {
ReaderChatgptSource get source => ReaderChatgptSource.instance;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(t.login),
leading: const BackButton(),
),
body: InAppWebView(
initialOptions: InAppWebViewGroupOptions(
crossPlatform: InAppWebViewOptions(
userAgent:
'Mozilla 5.0 (Linux; U; Android 13) Chrome/104.0.5112.99',
),
),
initialUrlRequest: URLRequest(
url: Uri.parse('https://chat.openai.com/auth/login'),
),
onLoadStop: (controller, uri) async {
Cookie? accessCookie = await CookieManager.instance().getCookie(
url: Uri.parse('https://chat.openai.com/'),
name: '__Secure-next-auth.session-token',
);
Cookie? clearanceCookie = await CookieManager.instance().getCookie(
url: Uri.parse('https://chat.openai.com/'),
name: 'cf_clearance',
);

if (accessCookie != null && clearanceCookie != null) {
if (mounted) {
Navigator.popUntil(context, (route) => route.isFirst);
}
}
},
),
);
}
}
Loading

0 comments on commit 760d8a5

Please sign in to comment.