Skip to content

Commit bbd2c93

Browse files
committed
feat(conversation): store reasoning separately from content, add show/hide UI
- Add Message.reasoning field; content holds only the final response - Add content_utils with START_BLOCK_REASONING, END_REASONING, split_reasoning_and_content - DB: add reasoning column (migration 003), save/load, backward compat for legacy ```think...``` - Anthropic, DeepSeek: populate reasoning and content separately; streaming yields ("reasoning", chunk) or ("content", chunk) - Completion handler: route structured chunks, wrap streaming reasoning with `<think>`, parse legacy format after stream - Display: show reasoning when checkbox on, using `<think>` tags - Config: default in Preferences, per-tab override in conversation panel (always visible) - API: send only content
1 parent 5d22b46 commit bbd2c93

File tree

16 files changed

+295
-166
lines changed

16 files changed

+295
-166
lines changed

basilisk/completion_handler.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
import wx
1717

1818
from basilisk import global_vars
19+
from basilisk.conversation.content_utils import (
20+
END_REASONING,
21+
START_BLOCK_REASONING,
22+
split_reasoning_and_content,
23+
)
1924
from basilisk.conversation.conversation_model import (
2025
Conversation,
2126
Message,
@@ -80,6 +85,7 @@ def __init__(
8085
self._last_completed_block: Optional[MessageBlock] = None
8186
self.last_time = 0
8287
self.stream_buffer: str = ""
88+
self._stream_reasoning_started: bool = False
8389

8490
@ensure_no_task_running
8591
def start_completion(
@@ -184,6 +190,28 @@ def _handle_stream_chunk(
184190
if not message_block.response.citations:
185191
message_block.response.citations = []
186192
message_block.response.citations.append(chunk_data)
193+
elif chunk_type == "reasoning":
194+
message_block.response.reasoning = (
195+
message_block.response.reasoning or ""
196+
) + chunk_data
197+
if not self._stream_reasoning_started:
198+
self._stream_reasoning_started = True
199+
wx.CallAfter(
200+
self._handle_stream_buffer,
201+
f"{START_BLOCK_REASONING}\n{chunk_data}",
202+
)
203+
else:
204+
wx.CallAfter(self._handle_stream_buffer, chunk_data)
205+
elif chunk_type == "content":
206+
message_block.response.content += chunk_data
207+
if self._stream_reasoning_started:
208+
self._stream_reasoning_started = False
209+
wx.CallAfter(
210+
self._handle_stream_buffer,
211+
f"\n{END_REASONING}\n\n{chunk_data}",
212+
)
213+
else:
214+
wx.CallAfter(self._handle_stream_buffer, chunk_data)
187215
else:
188216
logger.warning(
189217
"Unknown chunk type in streaming response: %s", chunk_type
@@ -199,6 +227,20 @@ def flush_stream_buffer(self, message_block: MessageBlock) -> None:
199227
wx.CallAfter(self._handle_stream_buffer, self.stream_buffer)
200228
self.stream_buffer = ""
201229

230+
def _split_reasoning_from_content(
231+
self, message_block: MessageBlock
232+
) -> None:
233+
"""Parse legacy ```think...``` format into reasoning and content."""
234+
if not message_block.response:
235+
return
236+
reasoning, content = split_reasoning_and_content(
237+
message_block.response.content
238+
)
239+
if reasoning is not None:
240+
message_block.response = message_block.response.model_copy(
241+
update={"reasoning": reasoning, "content": content}
242+
)
243+
202244
def _handle_streaming_completion(
203245
self,
204246
engine: BaseEngine,
@@ -219,7 +261,10 @@ def _handle_streaming_completion(
219261
Returns:
220262
True if streaming was handled successfully, False if stopped
221263
"""
222-
new_block.response = Message(role=MessageRoleEnum.ASSISTANT, content="")
264+
new_block.response = Message(
265+
role=MessageRoleEnum.ASSISTANT, content="", reasoning=None
266+
)
267+
self._stream_reasoning_started = False
223268

224269
# Notify that streaming has started
225270
if self.on_stream_start:
@@ -233,6 +278,10 @@ def _handle_streaming_completion(
233278

234279
# Notify that streaming has finished
235280
self.flush_stream_buffer(new_block)
281+
if self._stream_reasoning_started:
282+
wx.CallAfter(self._handle_stream_buffer, f"\n{END_REASONING}\n\n")
283+
# Parse legacy ```think...``` format into reasoning + content
284+
self._split_reasoning_from_content(new_block)
236285
if self.on_stream_finish:
237286
wx.CallAfter(self.on_stream_finish, new_block)
238287
return True

basilisk/config/main_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class ConversationSettings(BaseModel):
4949
auto_save_draft: bool = Field(default=True)
5050
reopen_last_conversation: bool = Field(default=False)
5151
last_active_conversation_id: int | None = Field(default=None)
52+
show_reasoning_blocks: bool = Field(default=True)
5253

5354

5455
class ImagesSettings(BaseModel):
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Utilities for message content processing."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
START_BLOCK_REASONING = "<think>"
8+
END_REASONING = "</think>"
9+
10+
_THINK_BLOCK_PATTERN = re.compile(r"```think\s*\n(.*?)\n```\s*", re.DOTALL)
11+
12+
13+
def split_reasoning_and_content(text: str) -> tuple[str | None, str]:
14+
"""Split content into reasoning and official response.
15+
16+
Handles legacy format where reasoning was concatenated as ```think...```
17+
before the response. Used when loading from DB or after streaming.
18+
19+
Args:
20+
text: Content that may contain ```think...``` block.
21+
22+
Returns:
23+
Tuple of (reasoning, content). If no think block, returns (None, text).
24+
"""
25+
if not text:
26+
return None, text or ""
27+
match = _THINK_BLOCK_PATTERN.search(text)
28+
if not match:
29+
return None, text
30+
reasoning = match.group(1).strip()
31+
content = (_THINK_BLOCK_PATTERN.sub("", text) or "").strip()
32+
return reasoning or None, content

basilisk/conversation/conversation_model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class BaseMessage(BaseModel):
6464
class Message(BaseMessage):
6565
"""Represents a message in a conversation. The message may contain text content and optional attachments."""
6666

67+
reasoning: str | None = Field(default=None)
6768
attachments: list[AttachmentFile | ImageFile] | None = Field(default=None)
6869
citations: list[dict[str, Any]] | None = Field(default=None)
6970
audio_data: str | None = Field(default=None)

basilisk/conversation/database/manager.py

Lines changed: 59 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
AttachmentFileTypes,
1818
ImageFile,
1919
)
20+
from basilisk.conversation.content_utils import split_reasoning_and_content
2021
from basilisk.conversation.conversation_model import (
2122
Conversation,
2223
Message,
@@ -234,6 +235,7 @@ def _save_message(
234235
message_block_id=block_id,
235236
role=role,
236237
content=message.content,
238+
reasoning=getattr(message, "reasoning", None),
237239
audio_data=getattr(message, "audio_data", None),
238240
audio_format=getattr(message, "audio_format", None),
239241
)
@@ -740,56 +742,72 @@ def load_conversation(self, conv_id: int) -> Conversation:
740742
version=BSKC_VERSION,
741743
)
742744

745+
def _load_message_attachments(
746+
self, db_msg: DBMessage
747+
) -> list[AttachmentFile | ImageFile] | None:
748+
"""Load attachments from a DB message."""
749+
if not db_msg.attachment_links:
750+
return None
751+
attachments = []
752+
for link in sorted(db_msg.attachment_links, key=lambda x: x.position):
753+
attachment = self._load_attachment(
754+
link.attachment, link.description
755+
)
756+
if attachment:
757+
attachments.append(attachment)
758+
return attachments or None
759+
760+
def _load_message_citations(self, db_msg: DBMessage) -> list[dict] | None:
761+
"""Load citations from a DB message."""
762+
if not db_msg.citations:
763+
return None
764+
citations = []
765+
for db_cit in sorted(db_msg.citations, key=lambda x: x.position):
766+
citation = {}
767+
if db_cit.cited_text is not None:
768+
citation["cited_text"] = db_cit.cited_text
769+
if db_cit.source_title is not None:
770+
citation["source_title"] = db_cit.source_title
771+
if db_cit.source_url is not None:
772+
citation["source_url"] = db_cit.source_url
773+
if db_cit.start_index is not None:
774+
citation["start_index"] = db_cit.start_index
775+
if db_cit.end_index is not None:
776+
citation["end_index"] = db_cit.end_index
777+
citations.append(citation)
778+
return citations
779+
780+
def _resolve_reasoning_and_content(
781+
self, reasoning: str | None, content: str
782+
) -> tuple[str | None, str]:
783+
"""Resolve reasoning and content, parsing legacy format if needed."""
784+
if reasoning is not None or not content:
785+
return reasoning, content
786+
parsed_reasoning, parsed_content = split_reasoning_and_content(content)
787+
if parsed_reasoning is not None:
788+
return parsed_reasoning, parsed_content
789+
return reasoning, content
790+
743791
def _load_message(self, db_msg: DBMessage) -> Message:
744792
"""Convert a DB message to a Pydantic message."""
745793
role = (
746794
MessageRoleEnum.USER
747795
if db_msg.role == "user"
748796
else MessageRoleEnum.ASSISTANT
749797
)
750-
751-
# Load attachments
752-
attachments = None
753-
if db_msg.attachment_links:
754-
attachments = []
755-
sorted_links = sorted(
756-
db_msg.attachment_links, key=lambda x: x.position
757-
)
758-
for link in sorted_links:
759-
db_att = link.attachment
760-
attachment = self._load_attachment(db_att, link.description)
761-
if attachment:
762-
attachments.append(attachment)
763-
764-
# Load citations
765-
citations = None
766-
if db_msg.citations:
767-
citations = []
768-
sorted_cites = sorted(db_msg.citations, key=lambda x: x.position)
769-
for db_cit in sorted_cites:
770-
citation = {}
771-
if db_cit.cited_text is not None:
772-
citation["cited_text"] = db_cit.cited_text
773-
if db_cit.source_title is not None:
774-
citation["source_title"] = db_cit.source_title
775-
if db_cit.source_url is not None:
776-
citation["source_url"] = db_cit.source_url
777-
if db_cit.start_index is not None:
778-
citation["start_index"] = db_cit.start_index
779-
if db_cit.end_index is not None:
780-
citation["end_index"] = db_cit.end_index
781-
citations.append(citation)
782-
783-
audio_data = getattr(db_msg, "audio_data", None)
784-
audio_format = getattr(db_msg, "audio_format", None)
785-
798+
attachments = self._load_message_attachments(db_msg)
799+
citations = self._load_message_citations(db_msg)
800+
reasoning, content = self._resolve_reasoning_and_content(
801+
getattr(db_msg, "reasoning", None), db_msg.content
802+
)
786803
return Message(
787804
role=role,
788-
content=db_msg.content,
789-
attachments=attachments or None,
790-
citations=citations or None,
791-
audio_data=audio_data,
792-
audio_format=audio_format,
805+
content=content,
806+
reasoning=reasoning,
807+
attachments=attachments,
808+
citations=citations,
809+
audio_data=getattr(db_msg, "audio_data", None),
810+
audio_format=getattr(db_msg, "audio_format", None),
793811
)
794812

795813
@staticmethod

basilisk/conversation/database/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ class DBMessage(Base):
133133
)
134134
role: Mapped[str]
135135
content: Mapped[str]
136+
reasoning: Mapped[str | None] = mapped_column(default=None)
136137
audio_data: Mapped[str | None] = mapped_column(default=None)
137138
audio_format: Mapped[str | None] = mapped_column(default=None)
138139

basilisk/presenters/conversation_presenter.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,11 @@ def _on_stream_start(
260260
):
261261
"""Called when streaming starts."""
262262
self.conversation.add_block(new_block, system_message)
263-
self.view.messages.display_new_block(new_block, streaming=True)
263+
self.view.messages.display_new_block(
264+
new_block,
265+
streaming=True,
266+
show_reasoning_blocks=self.view.get_effective_show_reasoning_blocks(),
267+
)
264268
self.view.messages.SetInsertionPointEnd()
265269

266270
@_guard_destroying
@@ -276,7 +280,10 @@ def _on_non_stream_finish(
276280
):
277281
"""Called when non-streaming completion finishes."""
278282
self.conversation.add_block(new_block, system_message)
279-
self.view.messages.display_new_block(new_block)
283+
self.view.messages.display_new_block(
284+
new_block,
285+
show_reasoning_blocks=self.view.get_effective_show_reasoning_blocks(),
286+
)
280287
audio_data = getattr(new_block.response, "audio_data", None)
281288
if audio_data:
282289
from basilisk.audio_utils import play_audio_from_base64

basilisk/presenters/preferences_presenter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ def on_ok(self) -> None:
108108
conf.conversation.reopen_last_conversation = (
109109
self.view.reopen_last_conversation.GetValue()
110110
)
111+
conf.conversation.show_reasoning_blocks = (
112+
self.view.show_reasoning_blocks.GetValue()
113+
)
111114
conf.images.resize = self.view.image_resize.GetValue()
112115
conf.images.max_height = int(self.view.image_max_height.GetValue())
113116
conf.images.max_width = int(self.view.image_max_width.GetValue())

0 commit comments

Comments
 (0)