Skip to content

Commit faaa79b

Browse files
committed
refactor(completion): Use a completion_handler class to provide a common interface for generating completion message
1 parent c6dff16 commit faaa79b

3 files changed

Lines changed: 407 additions & 166 deletions

File tree

basilisk/completion_handler.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
"""Common completion handling logic for conversation tab and edit dialog.
2+
3+
This module provides a shared completion handler that can be used by both
4+
ConversationTab and EditBlockDialog to avoid code duplication. It supports
5+
both streaming and non-streaming completion modes.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
import threading
12+
import time
13+
from typing import TYPE_CHECKING, Any, Callable, Optional
14+
15+
import wx
16+
17+
from basilisk import global_vars
18+
from basilisk.conversation.conversation_model import (
19+
Conversation,
20+
Message,
21+
MessageBlock,
22+
MessageRoleEnum,
23+
SystemMessage,
24+
)
25+
from basilisk.decorators import ensure_no_task_running
26+
from basilisk.sound_manager import play_sound, stop_sound
27+
28+
if TYPE_CHECKING:
29+
from basilisk.provider_engine.base_engine import BaseEngine
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
class CompletionHandler:
35+
"""Handles completion requests for both streaming and non-streaming modes.
36+
37+
This class provides a unified interface for handling AI completions that can be
38+
used by both ConversationTab and EditBlockDialog to avoid code duplication.
39+
"""
40+
41+
def __init__(
42+
self,
43+
on_completion_start: Optional[Callable[[], None]] = None,
44+
on_completion_end: Optional[Callable[[bool], None]] = None,
45+
on_stream_chunk: Optional[Callable[[str], None]] = None,
46+
on_completion_result: Optional[Callable[[str], None]] = None,
47+
on_error: Optional[Callable[[str], None]] = None,
48+
on_stream_start: Optional[
49+
Callable[[MessageBlock, Optional[SystemMessage]], None]
50+
] = None,
51+
on_stream_finish: Optional[Callable[[MessageBlock], None]] = None,
52+
on_non_stream_finish: Optional[
53+
Callable[[MessageBlock, Optional[SystemMessage]], None]
54+
] = None,
55+
):
56+
"""Initialize the completion handler.
57+
58+
Args:
59+
parent: The parent window for displaying error dialogs
60+
on_completion_start: Callback called when completion starts
61+
on_completion_end: Callback called when completion ends (success flag)
62+
on_stream_chunk: Callback called for each streaming chunk
63+
on_completion_result: Callback called with the final result
64+
on_error: Callback called when an error occurs
65+
on_stream_start: Callback called when streaming starts (new_block, system_message)
66+
on_stream_finish: Callback called when streaming finishes (new_block)
67+
on_non_stream_finish: Callback called when non-streaming finishes (new_block, system_message)
68+
"""
69+
self.on_completion_start = on_completion_start
70+
self.on_completion_end = on_completion_end
71+
self.on_stream_chunk = on_stream_chunk
72+
self.on_completion_result = on_completion_result
73+
self.on_error = on_error
74+
self.on_stream_start = on_stream_start
75+
self.on_stream_finish = on_stream_finish
76+
self.on_non_stream_finish = on_non_stream_finish
77+
self.task: Optional[threading.Thread] = None
78+
self._stop_completion = False
79+
self.last_time = 0
80+
81+
@ensure_no_task_running
82+
def start_completion(
83+
self,
84+
engine: BaseEngine,
85+
system_message: Optional[SystemMessage],
86+
conversation: Conversation,
87+
new_block: MessageBlock,
88+
stream: bool = False,
89+
**kwargs: Any,
90+
):
91+
"""Start a completion request.
92+
93+
Args:
94+
engine: The engine to use for completion
95+
system_message: Optional system message
96+
conversation: The conversation context
97+
new_block: The message block to complete
98+
stream: Whether to use streaming mode
99+
**kwargs: Additional arguments for the completion
100+
"""
101+
self._stop_completion = False
102+
103+
completion_args = {
104+
"engine": engine,
105+
"system_message": system_message,
106+
"conversation": conversation,
107+
"new_block": new_block,
108+
"stream": stream,
109+
**kwargs,
110+
}
111+
112+
if self.on_completion_start:
113+
self.on_completion_start()
114+
115+
self.task = threading.Thread(
116+
target=self._handle_completion, kwargs=completion_args
117+
)
118+
self.task.start()
119+
logger.debug(f"Completion task {self.task.ident} started")
120+
121+
def stop_completion(self):
122+
"""Stop the current completion if running."""
123+
self._stop_completion = True
124+
if self.is_running():
125+
logger.debug(f"Stopping completion task {self.task.ident}")
126+
self.task.join(timeout=1)
127+
128+
def is_running(self) -> bool:
129+
"""Check if a completion is currently running."""
130+
return self.task and self.task.is_alive()
131+
132+
def _handle_completion(self, engine: BaseEngine, **kwargs: dict[str, Any]):
133+
"""Handle the completion request in a background thread.
134+
135+
Args:
136+
engine: The engine to use for completion
137+
kwargs: The keyword arguments for the completion request
138+
"""
139+
try:
140+
play_sound("progress", loop=True)
141+
response = engine.completion(**kwargs)
142+
except Exception as e:
143+
logger.error("Error during completion", exc_info=True)
144+
wx.CallAfter(self._handle_error, str(e))
145+
return
146+
147+
kwargs["engine"] = engine
148+
kwargs["response"] = response
149+
if kwargs.get("stream", False):
150+
self._handle_streaming_completion(**kwargs)
151+
else:
152+
self._handle_non_streaming_completion(**kwargs)
153+
154+
def _handle_streaming_completion(
155+
self,
156+
engine: BaseEngine,
157+
response: Any,
158+
new_block: MessageBlock,
159+
system_message: Optional[SystemMessage],
160+
**kwargs: dict[str, Any],
161+
):
162+
"""Handle streaming completion response.
163+
164+
Args:
165+
engine: The engine used for completion
166+
response: The completion response
167+
new_block: The message block being completed
168+
system_message: Optional system message
169+
kwargs: Additional completion arguments
170+
"""
171+
new_block.response = Message(role=MessageRoleEnum.ASSISTANT, content="")
172+
173+
# Notify that streaming has started
174+
if self.on_stream_start:
175+
wx.CallAfter(self.on_stream_start, new_block, system_message)
176+
177+
for chunk in engine.completion_response_with_stream(response):
178+
if self._stop_completion or global_vars.app_should_exit:
179+
logger.debug("Stopping completion")
180+
break
181+
182+
if isinstance(chunk, str):
183+
new_block.response.content += chunk
184+
wx.CallAfter(self._handle_stream_chunk, chunk)
185+
elif isinstance(chunk, tuple):
186+
chunk_type, chunk_data = chunk
187+
if chunk_type == "citation":
188+
if not new_block.response.citations:
189+
new_block.response.citations = []
190+
new_block.response.citations.append(chunk_data)
191+
else:
192+
logger.warning(
193+
f"Unknown chunk type in streaming response: {chunk_type}"
194+
)
195+
196+
# Notify that streaming has finished
197+
if self.on_stream_finish:
198+
wx.CallAfter(self.on_stream_finish, new_block)
199+
200+
wx.CallAfter(
201+
self._completion_finished, new_block.response.content, True
202+
)
203+
204+
def _handle_non_streaming_completion(
205+
self,
206+
engine: BaseEngine,
207+
response: Any,
208+
new_block: MessageBlock,
209+
system_message: Optional[SystemMessage],
210+
**kwargs: dict[str, Any],
211+
):
212+
"""Handle non-streaming completion response.
213+
214+
Args:
215+
engine: The engine used for completion
216+
response: The completion response
217+
new_block: The message block being completed
218+
system_message: Optional system message
219+
kwargs: Additional completion arguments
220+
"""
221+
completed_block = engine.completion_response_without_stream(
222+
response=response, new_block=new_block, **kwargs
223+
)
224+
225+
# Notify that non-streaming completion has finished
226+
if self.on_non_stream_finish:
227+
wx.CallAfter(
228+
self.on_non_stream_finish, completed_block, system_message
229+
)
230+
231+
wx.CallAfter(
232+
self._completion_finished, completed_block.response.content, False
233+
)
234+
235+
def _handle_stream_chunk(self, chunk: str):
236+
"""Handle a streaming chunk on the main thread.
237+
238+
Args:
239+
chunk: The streaming chunk content
240+
"""
241+
if self.on_stream_chunk:
242+
self.on_stream_chunk(chunk)
243+
244+
# Play periodic sound during streaming
245+
new_time = time.time()
246+
if new_time - self.last_time > 4:
247+
play_sound("chat_response_pending")
248+
self.last_time = new_time
249+
250+
def _completion_finished(self, result: str, was_streaming: bool):
251+
"""Handle completion finish on the main thread.
252+
253+
Args:
254+
result: The completion result content
255+
was_streaming: Whether this was a streaming completion
256+
"""
257+
stop_sound()
258+
if not was_streaming:
259+
play_sound("chat_response_received")
260+
261+
if self.on_completion_result:
262+
self.on_completion_result(result)
263+
264+
if self.on_completion_end:
265+
self.on_completion_end(True)
266+
267+
self.task = None
268+
269+
def _handle_error(self, error_message: str):
270+
"""Handle completion error on the main thread.
271+
272+
Args:
273+
error_message: The error message
274+
"""
275+
stop_sound()
276+
277+
if self.on_error:
278+
self.on_error(error_message)
279+
else:
280+
wx.MessageBox(
281+
_("An error occurred during completion: ") + error_message,
282+
_("Error"),
283+
wx.OK | wx.ICON_ERROR,
284+
)
285+
286+
if self.on_completion_end:
287+
self.on_completion_end(False)
288+
289+
self.task = None

0 commit comments

Comments
 (0)