From 852acfc21bad1e0ba7e9c028da9a87fa7d7bf64d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 6 Sep 2025 11:01:20 +0530 Subject: [PATCH] Add a copy to clipboard for individual responses --- src/calibre/gui2/chat_widget.py | 14 +++---- src/calibre/gui2/viewer/llm.py | 66 ++++++++++++++++++++++++--------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/calibre/gui2/chat_widget.py b/src/calibre/gui2/chat_widget.py index 9581520be7..c1c11b768d 100644 --- a/src/calibre/gui2/chat_widget.py +++ b/src/calibre/gui2/chat_widget.py @@ -39,7 +39,7 @@ class Button(NamedTuple): @property def as_html(self) -> str: - return f'''{escape(self.text)}''' + return f'''{escape(self.text)}''' class Header(NamedTuple): @@ -48,7 +48,7 @@ class Header(NamedTuple): @property def as_html(self) -> str: - links = '\xa0'.join(b.as_html for b in self.buttons) + links = '\xa0\xa0'.join(b.as_html for b in self.buttons) title = '' + escape(self.title) if links: return f''' @@ -149,7 +149,7 @@ class ChatWidget(QWidget): self.blocks: list[str] = [] self.current_message = '' pal = self.palette() - self.alternate_color = pal.color(QPalette.ColorRole.Window).name() + self.response_color = pal.color(QPalette.ColorRole.Window).name() self.base_color = pal.color(QPalette.ColorRole.Base).name() self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) @@ -158,19 +158,19 @@ class ChatWidget(QWidget): return f'''
{title}\xa0
{html}
''' # API {{{ - def add_block(self, body_html: str, header: Header = Header(), is_alternate: bool = False) -> None: + def add_block(self, body_html: str, header: Header = Header(), is_response: bool = False) -> None: self.current_message = '' html = '' if header.title or header.buttons: html += f'
{header.as_html}
' html += f'
{body_html}
' - bg = self.alternate_color if is_alternate else self.base_color + bg = self.response_color if is_response else self.base_color self.blocks.append(self.wrap_content_in_padding_table(html, bg)) - def replace_last_block(self, body_html: str, header: Header = Header(), is_alternate: bool = False) -> None: + def replace_last_block(self, body_html: str, header: Header = Header(), is_response: bool = False) -> None: if self.blocks: del self.blocks[-1] - self.add_block(body_html, header, is_alternate) + self.add_block(body_html, header, is_response) def show_message(self, msg_html: str, details: str = '', level: int = INFO) -> None: self.blocks = [] diff --git a/src/calibre/gui2/viewer/llm.py b/src/calibre/gui2/viewer/llm.py index ce23fe5650..fb98440bad 100644 --- a/src/calibre/gui2/viewer/llm.py +++ b/src/calibre/gui2/viewer/llm.py @@ -11,6 +11,7 @@ from typing import NamedTuple from qt.core import ( QAbstractItemView, + QApplication, QDateTime, QDialog, QDialogButtonBox, @@ -114,6 +115,9 @@ class ConversationHistory: def __iter__(self) -> Iterator[ChatMessage]: return iter(self.items) + def reverse_iter(self) -> Iterator[ChatMessage]: + return reversed(self.items) + def __len__(self) -> int: return len(self.items) @@ -132,6 +136,11 @@ class ConversationHistory: ans.items = self.items[:upto] return ans + def only(self, message_index: int) -> 'ConversationHistory': + ans = self.copy(message_index + 1) + ans.items = [ans.items[-1]] + return ans + def at(self, x: int) -> ChatMessage: return self.items[x] @@ -160,7 +169,7 @@ def format_llm_note(conversation: ConversationHistory, assistant_name: str) -> s return '' main_response = '' - for message in reversed(conversation): + for message in conversation.reverse_iter(): if message.from_assistant: main_response = message.query.strip() break @@ -168,8 +177,11 @@ def format_llm_note(conversation: ConversationHistory, assistant_name: str) -> s if not main_response: return '' - timestamp = QLocale.system().toString(QDateTime.currentDateTime(), QLocale.FormatType.LongFormat) - header = f'--- {_("AI Assistant Note")} ({timestamp}) ---' + timestamp = QLocale.system().toString(QDateTime.currentDateTime(), QLocale.FormatType.ShortFormat) + sep = '―――' + header = f'{sep} {_("AI Assistant Note")} ({timestamp}) {sep}' + if len(conversation) == 1: + return f'{header}\n\n{main_response}' record_lines = [] for message in conversation: @@ -185,11 +197,10 @@ def format_llm_note(conversation: ConversationHistory, assistant_name: str) -> s record_lines.append(entry) record_body = '\n\n'.join(record_lines) - record_header = f'--- {_("Conversation record")} ---' + record_header = f'{sep} {_("Conversation record")} {sep}' return ( f'{header}\n\n{main_response}\n\n' - f'------------------------------------\n\n' f'{record_header}\n\n{record_body}' ) @@ -200,8 +211,10 @@ class LLMPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) - self.save_note_hostname = f'{uuid4().lower()}.calibre' - self.configure_ai_hostname = f'{uuid4().lower()}.calibre' + hid = uuid4().lower() + self.save_note_hostname = f'{hid}.save.calibre' + self.configure_ai_hostname = f'{hid}.config.calibre' + self.copy_hostname = f'{hid}.copy.calibre' self.counter = count(start=1) self.conversation_history = ConversationHistory() @@ -360,15 +373,19 @@ class LLMPanel(QWidget): if not content_for_display: continue header = Header() - alternate = False + is_response = False if message.from_assistant: - alternate = True - header = Header(assistant, (Button( - _('Save'), f'http://{self.save_note_hostname}/{i}', _('Save this specific response as the note')),)) - self.result_display.add_block(content_for_display, header, alternate) + is_response = True + header = Header(assistant, ( + Button(_('💾'), f'http://{self.save_note_hostname}/{i}', _( + 'Save this specific response as the note')), + Button('📋', f'http://{self.copy_hostname}/{i}', _( + 'Copy this specific response to the clipboard')), + )) + self.result_display.add_block(content_for_display, header, is_response) if self.conversation_history.api_call_active: content_for_display = for_display_to_human(ChatMessage(self.conversation_history.accumulator.all_content)) - self.result_display.add_block(content_for_display, Header(_('{} thinking…').format(assistant))) + self.result_display.add_block(content_for_display, Header(_('{} thinking…').format(assistant)), is_response=True) self.result_display.re_render() def scroll_to_bottom(self) -> None: @@ -451,23 +468,36 @@ class LLMPanel(QWidget): } self.add_note_requested.emit(payload) - def save_specific_note(self, message_index): + def get_conversation_history_for_specific_response(self, message_index: int) -> ConversationHistory | None: if not (0 <= message_index < len(self.conversation_history)): - return - if not self.conversation_history.at(message_index).from_assistant: - return - history_for_record = self.conversation_history.copy(message_index + 1) + return None + ans = self.conversation_history.at(message_index) + if not ans.from_assistant: + return None + return self.conversation_history.only(message_index) + + def save_specific_note(self, message_index: int) -> None: + history_for_record = self.get_conversation_history_for_specific_response(message_index) payload = { 'highlight': self.latched_highlight_uuid, 'llm_note': format_llm_note(history_for_record, self.assistant_name), } self.add_note_requested.emit(payload) + def copy_specific_note(self, message_index: int) -> None: + history_for_record = self.get_conversation_history_for_specific_response(message_index) + text = format_llm_note(history_for_record, self.assistant_name) + if text: + QApplication.instance().clipboard().setText(text) + def on_chat_link_clicked(self, qurl: QUrl): match qurl.host(): case self.save_note_hostname: index = int(qurl.path().strip('/')) self.save_specific_note(index) + case self.copy_hostname: + index = int(qurl.path().strip('/')) + self.copy_specific_note(index) case self.configure_ai_hostname: self.show_settings()