Coverage for vibefpmgui/window/ai.py: 93%
388 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The AI menu, the local Ollama server and model, AI reviews, and the AI's worker threads.
3The Review panel (`vibefpmgui.review_monitor`) shows what comes back.
4"""
6from __future__ import annotations
8import time
10from qtpy.QtCore import QProcess, QTimer
11from qtpy.QtGui import QActionGroup, QKeySequence, QTextCursor
12from qtpy.QtWidgets import QInputDialog, QMessageBox, QSizePolicy, QWidget
14from .. import ai_context as ctx
15from .. import ai_review as ai
16from ..background import call_in_background
17from ..editor import EditorTab
18from ..processes import kill_process_tree
19from ..review_monitor import Entry, ReviewMonitor
22class AIMixin:
23 """The AI menu, Ollama and AI reviews (a part of `vibefpmgui.main_window.MainWindow`)."""
25 def _build_ai_menu(self):
26 self.ai_model = self.settings.value("ai_model", ai.DEFAULT_MODEL)
27 self.ai_focus = self.settings.value("ai_focus", "general")
28 self._ai_thread = None
29 self._ai_worker = None
30 self._ai_editor: EditorTab | None = None
31 self._ollama_process: QProcess | None = None
32 # Ollama is asked things on a pool thread (see background.call_in_background):
33 self._ai_starting = False # a review is getting ready (server, model): AI Review waits
34 self._known_models: list[str] | None = None # installed models, as last heard (None: not yet)
35 self._after_model = lambda: None # what runs once the model is there: a review, an explanation, a chat
36 self._turns: list[ctx.Turn] = [] # what the model remembers: every entry since New Chat (see ai_context)
37 self._token_ratio = 1.0 # measured tokens over estimated ones (see _chat_finished)
38 self._model_contexts: dict[str, int | None] = {} # each model's longest context, as Ollama says
39 self._ai_entry: Entry | None = None # the Review panel's entry for what's running (or last ran)
40 # What the model remembers starts over with New Chat, Clear or another
41 # project: a request started before (stopped, but still ending) mustn't
42 # add to it.
43 self._memory_generation = 0
44 self._request_generation = 0
45 self._edit_selection: tuple[EditorTab, int, int] | None = None # Edit Selection with AI: the lines
46 self._asking_models = False
48 menu = self.menuBar().addMenu("&AI")
49 self.review_action = menu.addAction("Review Current File")
50 self.review_action.setShortcut(QKeySequence("Ctrl+Alt+R"))
51 self.review_action.setStatusTip("Ask a local AI model (via Ollama) to review the file in the editor")
52 self.review_action.triggered.connect(self.start_review)
53 # Also on the toolbar, at the far right.
54 self.review_action.setIconText("AI Review")
55 keys = self.review_action.shortcut().toString(QKeySequence.NativeText)
56 self.review_action.setToolTip(f"AI Review ({keys}) — review the file in the editor "
57 "with a local AI model")
58 spacer = QWidget()
59 spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
60 self.command_toolbar.addWidget(spacer)
61 self.command_toolbar.addAction(self.format_action)
62 self.command_icons[self.format_action] = "format"
63 self.command_toolbar.addAction(self.review_action)
64 self.command_icons[self.review_action] = "robot"
65 self._update_command_icons()
66 self.stop_review_action = menu.addAction("Stop")
67 self.stop_review_action.setEnabled(False)
68 self.stop_review_action.triggered.connect(self.stop_review)
69 self.edit_with_ai_action = menu.addAction("Edit with AI…")
70 self.edit_with_ai_action.setShortcut(QKeySequence("Ctrl+Alt+E"))
71 self.edit_with_ai_action.setStatusTip("Ask the AI to change the file in the editor (the selection, if "
72 "there is one); you accept or reject each edit")
73 self.edit_with_ai_action.triggered.connect(self.edit_with_ai)
74 self.stop_ollama_action = menu.addAction("Stop Ollama Server")
75 self.stop_ollama_action.setStatusTip("Stop the Ollama server the app started (and the model it has "
76 "loaded, freeing its memory); it starts again when the AI is next used")
77 self.stop_ollama_action.triggered.connect(self.stop_ollama)
78 menu.aboutToShow.connect(self._update_ollama_action)
79 self._update_ollama_action()
80 self.clear_review_action = menu.addAction("Clear Review Markers")
81 self.clear_review_action.triggered.connect(
82 lambda: self._with_editor(lambda e: e.clear_review()))
83 menu.addSeparator()
84 self.model_menu = menu.addMenu("Model")
85 self.model_menu.aboutToShow.connect(self._fill_model_menu)
86 self.ai_think = self.settings.value("ai_think", False, type=bool)
87 self.ai_answer_tokens = self.settings.value("ai_answer_tokens", ai.MAX_ANSWER_TOKENS, type=int)
88 self.think_action = menu.addAction("Think Before Answering")
89 self.think_action.setCheckable(True)
90 self.think_action.setChecked(self.ai_think)
91 self.think_action.setStatusTip(
92 "Let a thinking model (qwen3, deepseek-r1…) reason before it answers: slower, sometimes better. "
93 f"The reasoning gets {ai.REASONING_TOKENS:,} tokens of its own, on top of the answer's length")
94 self.think_action.toggled.connect(self._set_ai_think)
95 length_menu = self.answer_length_menu = menu.addMenu("Answer Length")
96 lengths = QActionGroup(self)
97 for tokens in ai.ANSWER_LENGTHS:
98 action = length_menu.addAction(f"{tokens:,} tokens" + (" (default)" if tokens == ai.MAX_ANSWER_TOKENS
99 else "") if tokens else "No limit")
100 action.setCheckable(True)
101 action.setChecked(tokens == self.ai_answer_tokens)
102 action.setStatusTip("How much the model may write in an answer (edits get at least "
103 f"{ai.EDIT_ANSWER_TOKENS:,}); a longer answer needs more of its context")
104 action.triggered.connect(lambda _=False, t=tokens: self._set_ai_answer_tokens(t))
105 lengths.addAction(action)
106 focus_menu = menu.addMenu("Focus")
107 group = QActionGroup(self)
108 for key, label in ai.FOCUS_LABELS.items():
109 action = focus_menu.addAction(label)
110 action.setCheckable(True)
111 action.setChecked(key == self.ai_focus)
112 action.setStatusTip(ai.FOCUS[key])
113 action.triggered.connect(lambda _=False, k=key: self._set_ai_focus(k))
114 group.addAction(action)
116 def _build_review_panel(self):
117 """The Review panel: the AI's timeline."""
118 self.review_monitor = ReviewMonitor()
119 monitor = self.review_monitor
120 monitor.stop_requested.connect(self.stop_review)
121 monitor.chat_requested.connect(self.send_chat)
122 monitor.new_chat_requested.connect(self.new_chat)
123 monitor.clear_requested.connect(self.clear_ai_history)
124 monitor.selection_cleared.connect(lambda: setattr(self, "_edit_selection", None))
125 monitor.finding_selected.connect(self.go_to_finding)
126 monitor.fix_requested.connect(self.fix_finding)
127 monitor.show_in_editor_requested.connect(self.show_findings_in_editor)
128 monitor.accept_requested.connect(self.accept_edit)
129 monitor.reject_requested.connect(self.reject_edit)
130 monitor.accept_all_requested.connect(self.accept_all_edits)
131 monitor.reject_all_requested.connect(self.reject_all_edits)
132 monitor.edit_clicked.connect(self.show_edit)
134 def _update_review_actions(self):
135 """AI review needs an editor tab in front (and no review running)."""
136 has_editor = self.current_editor() is not None
137 busy = self._ai_thread is not None or getattr(self, "_ai_starting", False)
138 self.review_action.setEnabled(has_editor and not busy)
139 self.review_monitor.set_sending(not busy) # (a question while it's answering: wait for it)
140 self.clear_review_action.setEnabled(has_editor)
142 def _set_ai_think(self, on: bool):
143 self.ai_think = on
144 self.settings.setValue("ai_think", on)
146 def _set_ai_answer_tokens(self, tokens: int):
147 self.ai_answer_tokens = tokens
148 self.settings.setValue("ai_answer_tokens", tokens)
150 def _answer_options(self, edit: bool = False) -> dict:
151 """The worker's settings from AI → Think Before Answering and Answer
152 Length (an edit's answer, with its old and new text: at least
153 EDIT_ANSWER_TOKENS)."""
154 tokens = self.ai_answer_tokens
155 if edit and tokens:
156 tokens = max(tokens, ai.EDIT_ANSWER_TOKENS)
157 return {"num_predict": tokens, "think": self.ai_think}
159 def _start_answer(self, worker) -> None:
160 """Start a worker whose reasoning (if the model thinks) goes to the
161 running entry."""
162 worker.reasoning.connect(self.review_monitor.add_reasoning)
163 self._start_ai_worker(worker)
165 def _set_ai_focus(self, key: str):
166 self.ai_focus = key
167 self.settings.setValue("ai_focus", key)
169 def _set_ai_model(self, name: str):
170 self.ai_model = name
171 self.settings.setValue("ai_model", name)
172 self.statusBar().showMessage(f"AI model: {name}", 4000)
174 def _fill_model_menu(self):
175 """Installed models (if Ollama is running), plus the current choice:
176 the list as last heard, at once, while Ollama is asked again."""
177 self._show_model_menu()
178 if ai.ollama_available() and not self._asking_models:
179 self._asking_models = True
180 call_in_background(ai.installed_models, self._models_heard, self)
182 def _models_heard(self, models: list[str]):
183 self._asking_models = False
184 self._known_models = models
185 if self.model_menu.isVisible(): 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 self._show_model_menu()
188 def _show_model_menu(self):
189 menu = self.model_menu
190 menu.clear()
191 models = self._known_models or []
192 names = sorted(set(models) | {self.ai_model})
193 group = QActionGroup(menu)
194 for name in names:
195 label = name if name in models else f"{name} (not downloaded yet)"
196 action = menu.addAction(label)
197 action.setCheckable(True)
198 action.setChecked(name == self.ai_model)
199 action.triggered.connect(lambda _=False, n=name: self._set_ai_model(n))
200 group.addAction(action)
201 if self._known_models is None and ai.ollama_available():
202 note = menu.addAction("(Checking for installed models…)")
203 note.setEnabled(False)
204 elif not models:
205 note = menu.addAction("(Ollama isn't running — installed models appear once it is)")
206 note.setEnabled(False)
207 menu.addSeparator()
208 other = menu.addAction("Other Model…")
209 other.triggered.connect(self._choose_other_model)
211 def _choose_other_model(self):
212 name, ok = QInputDialog.getText(
213 self, "AI model", "Ollama model name (e.g. qwen2.5-coder:14b, qwen3-coder:30b, "
214 "gpt-oss:20b). It's downloaded the first time it's used:", text=self.ai_model)
215 if ok and name.strip(): 215 ↛ exitline 215 didn't return from function '_choose_other_model' because the condition on line 215 was always true
216 self._set_ai_model(name.strip())
218 def start_review(self):
219 editor = self.current_editor()
220 if editor is None:
221 QMessageBox.information(self, "AI review", "Open a file in the editor first "
222 "(double-click a file, or File → Open File in Editor).")
223 return
224 if not self._can_ask("AI review"):
225 return
226 self._ai_editor = editor
227 self._after_model = self._run_review
228 focus = ai.FOCUS_LABELS.get(self.ai_focus, self.ai_focus)
229 self._add_entry(Entry("review", focus, editor.path, editor.path.name, self.ai_model))
230 self._ensure_ollama(self._check_model)
232 def _can_ask(self, title: str) -> bool:
233 """The AI is free, and Ollama's Python package is there (if not: why)."""
234 if self._ai_thread is not None or self._ai_starting:
235 self.statusBar().showMessage("The AI is busy: try again when it's done", 5000)
236 return False
237 if not ai.ollama_available():
238 QMessageBox.warning(self, title, "The 'ollama' Python package isn't installed.\n"
239 "Run 'pixi install' (or 'pip install ollama').")
240 return False
241 return True
243 def _add_entry(self, entry: Entry) -> Entry:
244 """A new entry in the Review panel's timeline, for what's starting."""
245 self._ai_entry = entry
246 self._request_generation = self._memory_generation
247 self.show_panel(self.review_monitor)
248 if entry.kind not in ("ask", "edit"):
249 self.review_monitor.context_note = ""
250 return self.review_monitor.add_entry(entry)
252 def _remember(self, entry: Entry, turn: ctx.Turn):
253 """What the model was asked and answered: it's part of what later
254 questions send (see ai_context)."""
255 if self._request_generation != self._memory_generation:
256 return # (asked before New Chat, Clear or another project: forgotten)
257 turn.answer, turn.edits = entry.answer, entry.edits
258 entry.turn = turn
259 self._turns.append(turn)
261 def _set_ai_starting(self, on: bool):
262 """A review is getting ready (asking Ollama, starting it): AI Review
263 waits, so a second click doesn't start a second server."""
264 self._ai_starting = on
265 self._update_review_actions()
267 def _ensure_ollama(self, then):
268 """Start "ollama serve" if no server answers, then call ``then``
269 (Ollama is asked on a pool thread: the window never waits for it)."""
270 self._set_ai_starting(True)
271 call_in_background(ai.server_running, lambda up: self._server_checked(up, then), self)
273 def _server_checked(self, up: bool, then):
274 if up:
275 then()
276 return
277 exe = ai.ollama_executable()
278 if exe is None:
279 self._set_ai_starting(False)
280 self._end_explain_setup("Ollama isn't running")
281 QMessageBox.warning(self, "AI review", "Ollama isn't running and the 'ollama' program "
282 "wasn't found. Run 'pixi install', or install Ollama from ollama.com.")
283 return
284 self._ai_status("starting the local Ollama server", "Starting Ollama")
285 self._ollama_process = QProcess(self)
286 self._ollama_process.setProcessChannelMode(QProcess.MergedChannels)
287 self._ollama_process.start(exe, ["serve"])
288 deadline = time.monotonic() + 20
290 def poll():
291 call_in_background(ai.server_running, polled, self)
293 def polled(up: bool):
294 if up: 294 ↛ 296line 294 didn't jump to line 296 because the condition on line 294 was always true
295 then()
296 elif time.monotonic() > deadline or self._ollama_process.state() == QProcess.NotRunning:
297 self._set_ai_starting(False)
298 self.review_monitor.finish(False, "Could not start the Ollama server.")
299 else:
300 QTimer.singleShot(300, self, poll)
302 QTimer.singleShot(300, self, poll)
304 def _check_model(self):
305 """Is the model downloaded? (Asked on a pool thread.)"""
306 call_in_background(ai.installed_models, self._model_checked, self)
308 def _model_checked(self, installed: list[str]):
309 self._known_models = installed
310 self._set_ai_starting(False) # (from here a worker thread, or nothing, follows)
311 model = self.ai_model if ":" in self.ai_model else self.ai_model + ":latest"
312 if model in installed or self.ai_model in installed:
313 if self.ai_model not in self._model_contexts: # (for the next chat question: asked once)
314 self._model_contexts[self.ai_model] = None
315 call_in_background(lambda m=self.ai_model: ai.model_context_length(m),
316 lambda n, m=self.ai_model: self._model_contexts.__setitem__(m, n), self)
317 self._after_model()
318 return
319 answer = QMessageBox.question(
320 self, "Download AI model",
321 f"The model '{self.ai_model}' isn't downloaded yet. Download it now?\n\n"
322 "Models are a few GB; this happens once, and everything runs locally afterwards.")
323 if answer != QMessageBox.Yes:
324 self._end_explain_setup(f"{self.ai_model} isn't downloaded")
325 return
326 self._ai_status(f"downloading {self.ai_model}", "Downloading")
327 worker = ai.ReviewWorker(ai.DEFAULT_HOST, self.ai_model, pull_only=True)
328 worker.progress.connect(self._pull_progress)
329 # (A method of the window, not a lambda: a lambda would run in the
330 # worker's thread, which is ending, and the review would never start.)
331 worker.finished.connect(self._pull_finished)
332 worker.failed.connect(self._review_failed)
333 self._start_ai_worker(worker)
335 def _ai_status(self, description: str, status: str):
336 """Getting ready (starting Ollama, downloading the model): the status
337 of the entry that's waiting for it."""
338 self.show_panel(self.review_monitor)
339 if self.review_monitor.running: 339 ↛ exitline 339 didn't return from function '_ai_status' because the condition on line 339 was always true
340 self.review_monitor.set_status(status)
342 def _pull_finished(self, *_):
343 self._ai_done(then=self._after_model)
345 def _pull_progress(self, status: str, fraction: float):
346 if fraction >= 0:
347 self.review_monitor.set_status(f"Downloading {self.ai_model} {fraction * 100:.0f}%", fraction)
348 else:
349 self.review_monitor.set_status(status.capitalize() or "Downloading")
351 def _run_review(self):
352 editor = self._ai_editor
353 if editor is None or editor not in self.editors(): 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true
354 self.review_monitor.finish(False, "The file was closed")
355 return
356 code = editor.editor.toPlainText()
357 diagnostics = editor.compiler_diagnostics()
358 lines = ctx.file_lines(code)
359 options = self._answer_options()
360 cap = self._chat_context()
361 room = options["num_predict"] + (ai.REASONING_TOKENS if options["think"] else 0) \
362 if options["num_predict"] else ai.REASONING_TOKENS
364 def prompt_for(first: int, last: int) -> str:
365 return ai.build_prompt(editor.path.name, editor.highlighter.lexer.name, "\n".join(lines[first - 1:last]),
366 self.ai_focus, diagnostics, first, len(lines))
368 # The selection (two lines or more), or the whole file if it fits the
369 # model's context with room for the answer; else the procedure at the
370 # cursor. (Sent anyway, a file too long loses its start, instructions
371 # included: Ollama doesn't refuse, it drops what doesn't fit.)
372 first, last, why = 1, len(lines), ""
373 selection = editor.editor.selected_lines()
374 if selection is not None and selection[1] > selection[0]:
375 (first, last), why = selection, "the selection"
376 elif ctx.estimate_tokens(prompt_for(1, len(lines)) + ai.SYSTEM_PROMPT, self._token_ratio) + room \
377 > cap * 0.9:
378 first, last = ctx.window(lines, None, editor.editor.textCursor().blockNumber() + 1)
379 why = "the procedure at the cursor: the file is too long to review at once"
380 prompt = prompt_for(first, last)
381 entry = self._ai_entry
382 if why:
383 entry.where = f"{editor.path.name}:{first}–{last}"
384 entry.question = f"{entry.question} · {why}"
385 self.review_monitor.refresh(entry)
386 num_ctx = ai.context_for(ctx.estimate_tokens(prompt + ai.SYSTEM_PROMPT, self._token_ratio), cap, room)
387 self.review_monitor.set_status("Thinking")
388 self._show_single_output(f"AI review of {editor.path.name} ({self.ai_model})", "")
389 self._review_text = ""
390 self._review_keys = []
391 self._review_code = code
392 editor.clear_review() # markers from an earlier review would be misleading
393 worker = ai.ReviewWorker(ai.DEFAULT_HOST, self.ai_model, prompt, num_ctx=num_ctx, **options)
394 worker.chunk.connect(self._review_chunk)
395 worker.finished.connect(self._review_finished)
396 worker.failed.connect(self._review_failed)
397 self._start_answer(worker)
399 def _start_ai_worker(self, worker):
400 self._ai_worker = worker
401 self._ai_thread = ai.start_worker(worker, self)
402 self._ai_thread.finished.connect(self._ai_thread_done)
403 self._update_review_actions()
404 self.stop_review_action.setEnabled(True)
405 self.review_monitor.set_stoppable(True)
407 def _ai_thread_done(self):
408 self._ai_thread = None
409 self._ai_worker = None
410 self._update_review_actions()
411 self.stop_review_action.setEnabled(False)
412 self.review_monitor.set_stoppable(False)
414 def _ai_done(self, then=None):
415 # Let the download thread finish before starting the review thread.
416 if then is not None: 416 ↛ exitline 416 didn't return from function '_ai_done' because the condition on line 416 was always true
417 QTimer.singleShot(100, self, lambda: self._after_ai_thread(then))
419 def _after_ai_thread(self, then):
420 if self._ai_thread is not None: 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true
421 QTimer.singleShot(100, self, lambda: self._after_ai_thread(then))
422 else:
423 then()
425 def _review_chunk(self, text: str):
426 cursor = self.test_output.textCursor()
427 cursor.movePosition(QTextCursor.End)
428 cursor.insertText(text)
429 self.test_output.setTextCursor(cursor)
430 self.test_output.ensureCursorVisible()
431 self.review_monitor.add_answer(text)
432 # Text arrives a line at a time: re-read the answer so far and show
433 # the findings found so far (the entry's list and the editor's markers).
434 self._review_text += text
435 editor, entry = self._ai_editor, self._ai_entry
436 max_line = editor.editor.blockCount() if editor in self.editors() else None
437 findings, _summary = ai.parse_findings(self._review_text, max_line)
438 keys = [(f.line, f.end_line, f.category, f.comment) for f in findings]
439 if keys != self._review_keys:
440 self._review_keys = keys
441 entry.findings = findings
442 self.review_monitor.refresh_soon(entry) # (with the answer's next showing)
443 if editor in self.editors(): 443 ↛ exitline 443 didn't return from function '_review_chunk' because the condition on line 443 was always true
444 editor.show_review(findings)
446 def _review_finished(self, text: str, stats: dict):
447 editor, entry = self._ai_editor, self._ai_entry
448 max_line = editor.editor.blockCount() if editor in self.editors() else None
449 findings, summary = ai.parse_findings(text, max_line)
450 entry.findings, entry.summary = findings, summary
451 name = editor.path.name if editor is not None else entry.where.split(":")[0]
452 self._remember(entry, ctx.Turn(
453 f"Review {entry.where}", f"Review my file {entry.where} ({entry.question.lower()}): list the issues "
454 "you find.",
455 ctx.file_attachment(name, self._review_code, numbers=True, context=self._chat_context(),
456 ratio=self._token_ratio)))
457 detail = f"{stats.get('seconds', 0):.1f} s · {self.ai_model}" if stats else self.ai_model
458 if stats.get("repeated"): 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true
459 detail += " · stopped a repetition loop"
460 elif stats.get("thinking"): 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 detail += " · the model spent its answer's length on reasoning (see AI → Answer Length)"
462 elif stats.get("truncated"): 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 detail += " · answer cut off at the length limit"
464 self.review_monitor.finish(True, detail)
465 if editor in self.editors(): 465 ↛ 467line 465 didn't jump to line 467 because the condition on line 465 was always true
466 editor.show_review(findings)
467 self.statusBar().showMessage(f"AI review: {len(findings)} findings", 5000)
469 def _review_failed(self, message: str):
470 hint = ""
471 if "not found" in message.lower():
472 hint = f" (is '{self.ai_model}' downloaded? See AI → Model)"
473 self.review_monitor.finish(False, message + hint)
474 self.statusBar().showMessage(f"AI: {message}{hint}", 6000)
476 def stop_review(self):
477 if self._ai_worker is not None:
478 self._ai_worker.cancel()
480 def _ollama_running(self) -> bool:
481 """A server the app started is running (one started otherwise isn't ours to stop)."""
482 return self._ollama_process is not None and self._ollama_process.state() != QProcess.NotRunning
484 def _update_ollama_action(self):
485 self.stop_ollama_action.setEnabled(self._ollama_running())
487 def stop_ollama(self, quitting: bool = False):
488 """AI → Stop Ollama Server: the server the app started, with the
489 model runner it started (killing only the server would leave that
490 running, holding the model's memory)."""
491 if not self._ollama_running():
492 return
493 if not quitting: 493 ↛ 495line 493 didn't jump to line 495 because the condition on line 493 was always true
494 self.stop_review()
495 kill_process_tree(self._ollama_process)
496 self._ollama_process.waitForFinished(3000)
497 self._ollama_process = None
498 self._update_ollama_action()
499 if not quitting: 499 ↛ exitline 499 didn't return from function 'stop_ollama' because the condition on line 499 was always true
500 self.statusBar().showMessage("Stopped the Ollama server (it starts again when the AI is next used)", 5000)
502 def _shutdown_ai(self):
503 if self._ai_worker is not None: 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true
504 self._ai_worker.cancel()
505 if self._ai_thread is not None: 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true
506 self._ai_thread.quit()
507 self._ai_thread.wait(3000)
508 self.stop_ollama(quitting=True)