feat reasoning
This commit is contained in:
1 parent
7543798a0e
commit
719e822f6f
2 files changed
+233
-51
No files matched your search
@@ -6,9 +6,10 @@ from pygments import highlight
|
||||
from pygments.formatters import HtmlFormatter
|
||||
from pygments.lexers import TextLexer, get_lexer_by_name, guess_lexer
|
||||
from pygments.util import ClassNotFound
|
||||
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal
|
||||
from PyQt6.QtCore import Qt, QThread, QTimer, QUrl, pyqtSignal
|
||||
from PyQt6.QtGui import (
|
||||
QColor,
|
||||
QDesktopServices,
|
||||
QFont,
|
||||
QIcon,
|
||||
QLinearGradient,
|
||||
@@ -87,8 +88,14 @@ ROLE_STYLES = {
|
||||
"user": (CORAL_GLOW, "#3a2c26"),
|
||||
"assistant": (SILVER, "#2f3342"),
|
||||
"system": (BLUE_SLATE, "#262a36"),
|
||||
"thinking": (TEXT_SECONDARY, "#282b38"),
|
||||
}
|
||||
|
||||
# Prefix for the fake anchor href used to make a thinking block's header
|
||||
# clickable (e.g. "think:think-3"), intercepted in _on_chat_anchor_clicked
|
||||
# rather than let QTextBrowser try to navigate to it.
|
||||
_THINK_ANCHOR_PREFIX = "think:"
|
||||
|
||||
_STYLESHEET = f"""
|
||||
QMainWindow {{
|
||||
background-color: {BG_APP};
|
||||
@@ -376,6 +383,7 @@ class AskThread(QThread):
|
||||
tool_call = pyqtSignal(str)
|
||||
usage = pyqtSignal(dict)
|
||||
token = pyqtSignal(str)
|
||||
thinking_token = pyqtSignal(str)
|
||||
finished_ok = pyqtSignal(str)
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
@@ -392,6 +400,7 @@ class AskThread(QThread):
|
||||
on_tool_call=self.tool_call.emit,
|
||||
on_usage=self.usage.emit,
|
||||
on_token=self.token.emit,
|
||||
on_thinking_token=self.thinking_token.emit,
|
||||
)
|
||||
except Exception as e:
|
||||
self.failed.emit(str(e))
|
||||
@@ -414,6 +423,22 @@ class ChatWindow(QMainWindow):
|
||||
# incoming tokens; both None when no answer is currently streaming.
|
||||
self._stream_start_pos: int | None = None
|
||||
self._stream_body_cursor: QTextCursor | None = None
|
||||
# Whether a real (non-newline) answer character has been inserted
|
||||
# yet for the in-progress card - the model's text after </think>
|
||||
# typically opens with a blank line or two, which _on_token trims
|
||||
# rather than showing as a gap at the top of the answer bubble.
|
||||
self._answer_started = False
|
||||
|
||||
# Thinking blocks (one per model reasoning round), keyed by an id
|
||||
# referenced from the clickable "think:<id>" anchor in their header.
|
||||
# Each holds the full reasoning text, whether it's currently shown
|
||||
# expanded, a fixed cursor marking where its content region starts,
|
||||
# and a live cursor tracking where that region currently ends - see
|
||||
# _render_thinking(). self._stream_thinking_id names whichever block
|
||||
# (if any) is still actively streaming in the current round.
|
||||
self._thinking_blocks: dict[str, dict] = {}
|
||||
self._thinking_seq = 0
|
||||
self._stream_thinking_id: str | None = None
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
@@ -448,7 +473,12 @@ class ChatWindow(QMainWindow):
|
||||
|
||||
self.chat_view = QTextBrowser()
|
||||
self.chat_view.setObjectName("chatView")
|
||||
self.chat_view.setOpenExternalLinks(True)
|
||||
# Links are handled by hand in _on_chat_anchor_clicked (real URLs get
|
||||
# opened externally there too) rather than left to QTextBrowser,
|
||||
# since its own link handling doesn't know about the fake
|
||||
# "think:<id>" anchors used to toggle a thinking block.
|
||||
self.chat_view.setOpenLinks(False)
|
||||
self.chat_view.anchorClicked.connect(self._on_chat_anchor_clicked)
|
||||
self.chat_view.setFont(QFont("Segoe UI Variable Text", 10))
|
||||
layout.addWidget(self.chat_view)
|
||||
|
||||
@@ -551,6 +581,8 @@ class ChatWindow(QMainWindow):
|
||||
self.statusBar().showMessage("Thinking...")
|
||||
self._stream_start_pos = None
|
||||
self._stream_body_cursor = None
|
||||
self._answer_started = False
|
||||
self._stream_thinking_id = None
|
||||
|
||||
messages = chatbot.build_messages(self._history, question)
|
||||
self._pending_question = question
|
||||
@@ -559,12 +591,14 @@ class ChatWindow(QMainWindow):
|
||||
self._ask_thread.tool_call.connect(self._on_tool_call)
|
||||
self._ask_thread.usage.connect(self._on_usage)
|
||||
self._ask_thread.token.connect(self._on_token)
|
||||
self._ask_thread.thinking_token.connect(self._on_thinking_token)
|
||||
self._ask_thread.finished_ok.connect(self._on_answer)
|
||||
self._ask_thread.failed.connect(self._on_ask_failed)
|
||||
self._ask_thread.start()
|
||||
|
||||
def _on_token(self, piece: str) -> None:
|
||||
if self._stream_body_cursor is None:
|
||||
self._collapse_open_thinking()
|
||||
self.statusBar().showMessage("Generating...")
|
||||
cursor = self.chat_view.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
@@ -574,6 +608,13 @@ class ChatWindow(QMainWindow):
|
||||
body.insertHtml(f'<span style="color:{accent}; font-weight:600; font-size:8.5pt;">ASSISTANT</span>')
|
||||
body.insertBlock(QTextBlockFormat(), QTextCharFormat())
|
||||
self._stream_body_cursor = body
|
||||
self._answer_started = False
|
||||
|
||||
if not self._answer_started:
|
||||
piece = piece.lstrip("\n")
|
||||
if not piece:
|
||||
return
|
||||
self._answer_started = True
|
||||
|
||||
char_format = QTextCharFormat()
|
||||
char_format.setForeground(QColor(TEXT_PRIMARY))
|
||||
@@ -593,7 +634,95 @@ class ChatWindow(QMainWindow):
|
||||
self._stream_start_pos = None
|
||||
self._stream_body_cursor = None
|
||||
|
||||
def _on_thinking_token(self, piece: str) -> None:
|
||||
if self._stream_thinking_id is None:
|
||||
cursor = self.chat_view.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
accent, bg = ROLE_STYLES["thinking"]
|
||||
body = _insert_message_card(cursor, accent, bg)
|
||||
body.insertHtml(f'<span style="color:{accent}; font-weight:600; font-size:8.5pt;">THINKING</span>')
|
||||
body.insertBlock(QTextBlockFormat(), QTextCharFormat())
|
||||
|
||||
self._thinking_seq += 1
|
||||
block_id = f"think-{self._thinking_seq}"
|
||||
content_start = QTextCursor(body)
|
||||
# Without this, inserting the header right at content_start's own
|
||||
# position (both here and on every later toggle) would drag it
|
||||
# forward along with that insertion, per QTextCursor's default -
|
||||
# it needs to stay pinned at the true start of the region instead.
|
||||
content_start.setKeepPositionOnInsert(True)
|
||||
self._thinking_blocks[block_id] = {
|
||||
"text": "",
|
||||
"expanded": True,
|
||||
"content_start": content_start,
|
||||
"cursor": body,
|
||||
}
|
||||
self._insert_thinking_header(body, block_id, expanded=True)
|
||||
body.insertBlock(QTextBlockFormat(), QTextCharFormat())
|
||||
self._stream_thinking_id = block_id
|
||||
|
||||
block = self._thinking_blocks[self._stream_thinking_id]
|
||||
block["text"] += piece
|
||||
italic_format = QTextCharFormat()
|
||||
italic_format.setForeground(QColor(TEXT_SECONDARY))
|
||||
italic_format.setFontItalic(True)
|
||||
block["cursor"].insertText(piece, italic_format)
|
||||
self.chat_view.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
def _insert_thinking_header(self, cursor: QTextCursor, block_id: str, expanded: bool) -> None:
|
||||
arrow = "▼" if expanded else "▶"
|
||||
cursor.insertHtml(
|
||||
f'<a href="{_THINK_ANCHOR_PREFIX}{block_id}" '
|
||||
f'style="color:{TEXT_SECONDARY}; text-decoration:none;">{arrow} Thinking</a>'
|
||||
)
|
||||
|
||||
def _render_thinking(self, block_id: str, expanded: bool) -> None:
|
||||
"""Rewrite a thinking block's header and body to match expanded,
|
||||
replacing everything from its fixed content_start cursor through
|
||||
wherever its content currently ends (tracked by its own cursor,
|
||||
which - like any QTextCursor - keeps up with edits made elsewhere in
|
||||
the document, so this stays correct even if other messages have
|
||||
since been appended below it)."""
|
||||
block = self._thinking_blocks[block_id]
|
||||
cursor = block["cursor"]
|
||||
old_end = cursor.position()
|
||||
cursor.setPosition(block["content_start"].position())
|
||||
cursor.setPosition(old_end, QTextCursor.MoveMode.KeepAnchor)
|
||||
cursor.removeSelectedText()
|
||||
|
||||
self._insert_thinking_header(cursor, block_id, expanded)
|
||||
if expanded:
|
||||
cursor.insertBlock(QTextBlockFormat(), QTextCharFormat())
|
||||
italic_format = QTextCharFormat()
|
||||
italic_format.setForeground(QColor(TEXT_SECONDARY))
|
||||
italic_format.setFontItalic(True)
|
||||
cursor.insertText(block["text"], italic_format)
|
||||
block["expanded"] = expanded
|
||||
self.chat_view.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
def _collapse_open_thinking(self) -> None:
|
||||
if self._stream_thinking_id is not None:
|
||||
self._render_thinking(self._stream_thinking_id, expanded=False)
|
||||
self._stream_thinking_id = None
|
||||
|
||||
def _on_chat_anchor_clicked(self, url: QUrl) -> None:
|
||||
href = url.toString()
|
||||
if not href.startswith(_THINK_ANCHOR_PREFIX):
|
||||
QDesktopServices.openUrl(url)
|
||||
return
|
||||
# A generation currently in flight may be inserting content through
|
||||
# this same block's cursor further down; leave the block alone
|
||||
# rather than risk racing that live cursor.
|
||||
if self._ask_thread is not None:
|
||||
return
|
||||
block_id = href[len(_THINK_ANCHOR_PREFIX) :]
|
||||
block = self._thinking_blocks.get(block_id)
|
||||
if block is None:
|
||||
return
|
||||
self._render_thinking(block_id, expanded=not block["expanded"])
|
||||
|
||||
def _on_tool_call(self, query: str) -> None:
|
||||
self._collapse_open_thinking()
|
||||
self._discard_stream_card()
|
||||
self.statusBar().showMessage(f"Searching: {query}")
|
||||
self._append_system(f"Searching the web for: {query}")
|
||||
@@ -604,6 +733,10 @@ class ChatWindow(QMainWindow):
|
||||
self.context_status_label.setText(f"Context: {total} / {self._n_ctx} ({pct:.0f}%)")
|
||||
|
||||
def _on_answer(self, answer: str) -> None:
|
||||
# Normally already collapsed by the first answer token (_on_token),
|
||||
# but a response that hit max_tokens mid-<think> would leave this
|
||||
# block open with nothing to trigger the collapse - catch that here.
|
||||
self._collapse_open_thinking()
|
||||
# The streamed card holds the raw, plain-text answer; swap it out for
|
||||
# a fully markdown-rendered and syntax-highlighted one now that the
|
||||
# complete text is available.
|
||||
@@ -615,6 +748,7 @@ class ChatWindow(QMainWindow):
|
||||
self._set_input_enabled(True)
|
||||
|
||||
def _on_ask_failed(self, message: str) -> None:
|
||||
self._collapse_open_thinking()
|
||||
self._discard_stream_card()
|
||||
self._append_system(f"Error: {message}")
|
||||
self.statusBar().showMessage("Ready.")
|
||||
|
||||
Reference in new issue
Block a user