forked from RosettaTechnologies/AnkiBrain
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAnkiBrainModule.py
More file actions
363 lines (292 loc) · 12.8 KB
/
Copy pathAnkiBrainModule.py
File metadata and controls
363 lines (292 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
import asyncio
import json
import platform
import signal
import threading
from anki.hooks import addHook
from aqt import mw, gui_hooks
from aqt.qt import *
from aqt.utils import showInfo
from dotenv import set_key, load_dotenv
from ChatAIModuleAdapter import ChatAIModuleAdapter
from ExplainTalkButtons import ExplainTalkButtons
from InterprocessCommand import InterprocessCommand as IC
from OpenAIAPIKeyDialog import OpenAIAPIKeyDialog
from PostUpdateDialog import PostUpdateDialog
from SidePanel import SidePanel
from UserModeDialog import show_user_mode_dialog
from card_injection import handle_card_will_show
from changelog import ChangelogDialog
from project_paths import dotenv_path
from util import run_win_install, run_macos_install, run_linux_install, UserMode
#The "GUIThreadSignaler" class allows the non-UI thread to modify/update the UI thread. Some uses include
#resetting the UI, opening a file browser, showing dialogs for missing API keys
class GUIThreadSignaler(QObject):
"""
Required class for calling UI updates from the non-UI thread.
"""
resetUISignal = pyqtSignal()
openFileBrowserSignal = pyqtSignal(int) # takes commandId so we can resolve the request
showNoAPIKeyDialogSignal = pyqtSignal()
sendToJSFromAsyncThreadSignal = pyqtSignal(dict)
def __init__(self):
super().__init__()
self.resetUISignal.connect(self.reset_ui)
self.openFileBrowserSignal.connect(self.open_file_browser)
self.showNoAPIKeyDialogSignal.connect(self.show_no_API_key_dialog)
self.sendToJSFromAsyncThreadSignal.connect(self.send_to_js_from_async_thread)
def send_to_js_from_async_thread(self, json_dict: dict):
mw.ankiBrain.sidePanel.webview.send_to_js(json_dict)
def show_no_API_key_dialog(self):
showInfo('AnkiBrain has loaded. There is no API key detected, please set one before using the app.')
def reset_ui(self):
mw.reset()
def open_file_browser(self, commandId):
print(f'Opening file browser with commandId {commandId}')
dialog = QFileDialog()
full_paths, _ = dialog.getOpenFileNames()
# No files selected (empty array).
if not full_paths:
mw.ankiBrain.reactBridge.trigger(IC.DID_CLOSE_DOCUMENT_BROWSER_NO_SELECTIONS, commandId=commandId)
return
documents = []
for path in full_paths:
file_name_with_extension = os.path.basename(path)
file_name, extension = os.path.splitext(file_name_with_extension)
documents.append({
'file_name_with_extension': os.path.basename(path),
'file_name': file_name,
'extension': extension,
'path': path,
'size': os.path.getsize(path)
})
print(f'Selected documents: {json.dumps(documents)}')
# user_mode = mw.settingsManager.get_user_mode()
# if user_mode == UserMode.SERVER:
mw.ankiBrain.reactBridge.send_cmd(
IC.DID_SELECT_DOCUMENTS,
data={'documents': documents},
commandId=commandId
)
# elif user_mode == UserMode.LOCAL:
# mw.ankiBrain.reactBridge.trigger(IC.ADD_DOCUMENTS, documents=documents)
#The "AnkiBrain" class is the main class. It is responsible for initializing the application, UI setup, file browser interactions,
#webview load handling.
class AnkiBrain:
def __init__(self, user_mode: UserMode = UserMode.LOCAL):
self.user_mode = user_mode
self.loop = None
self.sidePanel = SidePanel("AnkiBrain", mw)
self.sidePanel.webview.page().loadFinished.connect(self.on_webengine_load_finished)
self.webview_loaded = False
self.explainTalkButtons = None
self.selectedText = ''
self.chatAI = ChatAIModuleAdapter() # Requires async starting by calling .start
self.chatReady = False
self.openai_api_key_dialog = OpenAIAPIKeyDialog()
self.openai_api_key_dialog.hide()
# Should go last because this object takes self and can call items.
# Therefore, risk of things not completing setup.
from ReactBridge import ReactBridge
self.reactBridge = ReactBridge(self)
self.guiThreadSignaler = GUIThreadSignaler()
self.setup_ui()
def __del__(self):
self.sidePanel.deleteLater()
asyncio.run(self.chatAI.stop())
def setup_ui(self):
mw.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.sidePanel)
self.sidePanel.resize(500, mw.height())
# Set up api key dialog.
self.openai_api_key_dialog.on_key_save(self.handle_openai_api_key_save)
# Hook for injecting custom javascript into Anki cards.
addHook("prepareQA", handle_card_will_show)
# Hook for Anki's card webview JS function `pycmd`
gui_hooks.webview_did_receive_js_message.append(self.handle_anki_card_webview_pycmd)
add_ankibrain_menu_item('Show/Hide AnkiBrain', self.toggle_panel)
add_ankibrain_menu_item('Switch User Mode...', show_user_mode_dialog)
if self.user_mode == UserMode.LOCAL:
add_ankibrain_menu_item('Restart AI...', self.restart_async_members_from_sync)
add_ankibrain_menu_item('Set OpenAI API Key...', self.show_openai_api_key_dialog)
add_ankibrain_menu_item('Reinstall...', reinstall)
# Check if AnkiBrain has been updated.
has_updated = mw.settingsManager.has_ankibrain_updated()
if has_updated:
# If updated, need to have the user reinstall python dependencies.
# Show PostUpdateDialog.
mw.updateDialog = PostUpdateDialog(mw)
mw.updateDialog.show()
add_ankibrain_menu_item('Show Changelog', show_changelog)
self.main()
def on_webengine_load_finished(self):
print('Webview finished loading.')
self.webview_loaded = True
async def load_user_settings(self):
settings = mw.settingsManager.settings
print('Sending DID_LOAD_USER_FILES')
self.reactBridge.send_cmd(IC.DID_LOAD_SETTINGS, settings)
async def _start_async_members(self):
"""
Start up all async members here.
:return:
"""
# Make sure webview is loaded.
while not self.webview_loaded:
print('Webview is not loaded yet, sleeping async...')
await asyncio.sleep(0.1)
if self.user_mode == UserMode.LOCAL:
self.reactBridge.send_cmd(IC.SET_WEBAPP_LOADING_TEXT, {'text': 'Starting AI Engine...'})
print('Starting AnkiBrain...')
await self.chatAI.start()
self.chatReady = True
print('AnkiBrain ChatAI loaded. App is ready.')
self.reactBridge.send_cmd(IC.SET_WEBAPP_LOADING_TEXT, {'text': 'Loading your settings...'})
await self.load_user_settings()
self.reactBridge.send_cmd(IC.DID_FINISH_STARTUP)
# Check for key in .env file in user_files
if self.user_mode == UserMode.LOCAL:
load_dotenv(dotenv_path, override=True)
if os.getenv('OPENAI_API_KEY') is None or os.getenv('OPENAI_API_KEY') == '':
print('No API key detected')
self.guiThreadSignaler.showNoAPIKeyDialogSignal.emit()
else:
print(f'Detected API Key: {os.getenv("OPENAI_API_KEY")}')
async def _stop_async_members(self):
"""
Stop all async members here.
:return:
"""
if self.user_mode == UserMode.LOCAL:
print('Stopping AnkiBrain...')
await self.chatAI.stop()
self.chatReady = False
async def restart_async_members(self):
print('Restarting AnkiBrain...')
print('Setting web app loading: True')
self.reactBridge.set_webapp_loading(True)
await self._stop_async_members()
await self._start_async_members()
print('Setting web app loading: False')
self.reactBridge.set_webapp_loading(False)
self.reactBridge.send_cmd(IC.STOP_LOADERS)
def restart_async_members_from_sync(self):
"""
Restart AnkiBrain from a synchronous thread.
This dispatches a task in the async event loop that runs AnkiBrain.
This is a synchronous function but is a non-blocking operation.
:return:
"""
asyncio.run_coroutine_threadsafe(self.restart_async_members(), mw.ankiBrain.loop)
async def ask_dummy(self, query: str):
output = await self.chatAI.ask_dummy(query)
return output
def handle_openai_api_key_save(self, key):
self.openai_api_key_dialog.hide()
set_key(dotenv_path, 'OPENAI_API_KEY', key)
os.environ['OPENAI_API_KEY'] = key
self.restart_async_members_from_sync()
def _handle_process_signal(self, signal, frame):
try:
self.chatAI.scriptManager.terminate_sync()
except Exception as e:
print(str(e))
exit(0)
def main(self):
"""
Runs AnkiBrain's async members in an asyncio event loop in a separate thread to not block Anki's UI.
:return:
"""
# Set up signal handling in main thread.
signal.signal(signal.SIGINT, self._handle_process_signal)
signal.signal(signal.SIGTERM, self._handle_process_signal)
def start_async_loop(_loop):
asyncio.set_event_loop(_loop)
_loop.run_forever()
loop = asyncio.new_event_loop()
self.loop = loop
t = threading.Thread(target=start_async_loop, args=(loop,))
t.daemon = True
t.start()
try:
asyncio.run_coroutine_threadsafe(self._start_async_members(), loop)
except Exception as e:
print(e)
def stop_main(self):
asyncio.run_coroutine_threadsafe(self._stop_async_members(), self.loop)
# Cancel all tasks on the loop
for task in asyncio.all_tasks(self.loop):
task.cancel()
# Stop the loop
mw.ankiBrain.loop.call_soon_threadsafe(self.loop.stop)
def toggle_panel(self):
if self.sidePanel.isVisible():
self.sidePanel.hide()
mw.settingsManager.edit('showSidePanel', False)
else:
self.sidePanel.show()
mw.settingsManager.edit('showSidePanel', True)
def show_openai_api_key_dialog(self):
self.openai_api_key_dialog.show()
def handle_anki_card_webview_pycmd(self, handled, cmd, context):
try:
data = json.loads(cmd)
if data['cmd'] == 'selectedText':
print('detected text selection')
self.handle_text_selected(text=data['text'], position=data['position'])
return True, None
elif data['cmd'] == 'mousedown':
print('detected mousedown')
self.handle_mousedown()
return True, None
else:
return handled
except Exception as e:
print(e)
return handled
def handle_text_selected(self, text='', position=None):
if self.explainTalkButtons is not None:
self.explainTalkButtons.destroy()
self.selectedText = text
self.explainTalkButtons = ExplainTalkButtons(mw, position)
self.explainTalkButtons.on_explain_button_click(self.handle_explain_text_pressed)
self.explainTalkButtons.on_talk_button_click(self.handle_talk_text_pressed)
# Basically detecting highlight release.
def handle_mousedown(self):
if self.explainTalkButtons is not None:
self.explainTalkButtons.destroy()
self.selectedText = ''
def handle_explain_text_pressed(self):
self.sidePanel.webview.send_to_js({
'cmd': 'explainSelectedText',
'text': self.selectedText
})
self.explainTalkButtons.destroy()
self.selectedText = ''
def handle_talk_text_pressed(self):
self.sidePanel.webview.send_to_js({
'cmd': 'talkSelectedText',
'text': self.selectedText
})
self.explainTalkButtons.destroy()
self.selectedText = ''
def reinstall():
system = platform.system()
if system == 'Windows':
run_win_install()
elif system == 'Darwin':
run_macos_install()
elif system == 'Linux':
run_linux_install()
showInfo('Terminal updater has been launched. Restart Anki after install is completed.')
def show_changelog():
mw.changelog = ChangelogDialog(mw)
mw.changelog.show()
def add_ankibrain_menu_item(name: str, fn):
action = mw.ankibrain_menu.addAction(name)
qconnect(action.triggered, fn)
# Keep track of added actions for removal later if needed.
mw.menu_actions.append(action)
def remove_ankibrain_menu_actions():
for action in mw.menu_actions:
print(f'Removing menu action: {str(action)}')
mw.form.menubar.removeAction(action)