diff --git a/plugin.json b/plugin.json index 8df7d54..4dc543d 100644 --- a/plugin.json +++ b/plugin.json @@ -3,9 +3,12 @@ "name": "Chat Summaries", "description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.", "author": "@lyuksovannyy", - "version": "1.1.0", + "version": "1.1.1", "icon": "exteraPlugins/1", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", - "requirements": ["requests", "debugpy"] + "requirements": [ + "requests", + "debugpy" + ] } diff --git a/src/config.py b/src/config.py index c411e81..f82731d 100644 --- a/src/config.py +++ b/src/config.py @@ -57,6 +57,14 @@ SUMMARY_HASHTAG = "#aisummaries" # A summary card can only be placed directly after its newest analyzed # message, which with an offset may not be loaded yet. Placement asks the chat -# to load it, then retries this many times, this many ms apart. -PLACEMENT_RETRIES = 25 -PLACEMENT_RETRY_DELAY_MS = 120 +# to load it, then retries this many times, this many ms apart (~10 s), asking +# again every PLACEMENT_RELOAD_EVERY retries in case a request was dropped. +PLACEMENT_RETRIES = 80 +PLACEMENT_RETRY_DELAY_MS = 125 +PLACEMENT_RELOAD_EVERY = 16 + +# After jumping to a fresh card, check at these delays (ms) that it is still on +# screen and jump again if not. Loading a far anchor makes ChatActivity run its +# own postponed scroll, and a cache-then-server reload can re-scroll it, either +# of which can land after the jump and carry the view away from the card. +JUMP_SETTLE_DELAYS_MS = (250, 700, 1500, 3000) diff --git a/src/main.py b/src/main.py index 1195861..bf8d723 100644 --- a/src/main.py +++ b/src/main.py @@ -274,6 +274,9 @@ class ChatSummariesPlugin(BasePlugin): bulletin and leaves the chat exactly as the user left it: no error bubble to dismiss, no jump away from where they were reading. """ + diagnostics.note( + f"summary: dialog={dialog_id} topic={topic_id} count={count} offset={offset}" + ) BulletinHelper.show_info(f"Chat Summaries: reading {count} messages…", fragment) def on_fetched(messages, error): @@ -292,6 +295,7 @@ class ChatSummariesPlugin(BasePlugin): self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched) def _report_error(self, message: str, fragment: Any) -> None: + diagnostics.note(f"summary failed: {message}") BulletinHelper.show_error(f"Chat Summaries: {message}", fragment) def _stream_into( @@ -310,7 +314,11 @@ class ChatSummariesPlugin(BasePlugin): """ from services.rich_injector import SummaryInjection, virtual_id_for - anchor_msg_id = max(int(getattr(m, "id", 0) or 0) for m in messages) + ids = [int(getattr(m, "id", 0) or 0) for m in messages] + anchor_msg_id = max(ids) + diagnostics.note( + f"summary: fetched {len(messages)} messages, ids {min(ids)}..{anchor_msg_id}" + ) db = self._get_summary_db() state = { "answer": "", diff --git a/src/services/rich_injector.py b/src/services/rich_injector.py index 3f1065f..a64fe48 100644 --- a/src/services/rich_injector.py +++ b/src/services/rich_injector.py @@ -16,7 +16,13 @@ Verified against exteraGram 12.5.x (dex inspection): from typing import Any, Optional import diagnostics -from config import PLACEMENT_RETRIES, PLACEMENT_RETRY_DELAY_MS, SUMMARY_HASHTAG +from config import ( + JUMP_SETTLE_DELAYS_MS, + PLACEMENT_RELOAD_EVERY, + PLACEMENT_RETRIES, + PLACEMENT_RETRY_DELAY_MS, + SUMMARY_HASHTAG, +) from services.rich_markdown import build_rich_message, hashtag_block, markdown_to_plain # Above Telegram's issued id range, so this user exists only locally. @@ -379,10 +385,18 @@ class SummaryInjection: else: index = find_index_for_anchor(messages_list, self.anchor_msg_id) if index < 0: - if retries_left == PLACEMENT_RETRIES: + attempt = PLACEMENT_RETRIES - retries_left + if retries_left > 0 and attempt % PLACEMENT_RELOAD_EVERY == 0: # The analyzed range ends outside the loaded window # (an offset, or history scrolled away). Have the chat # load and show it, then place once it is in the list. + # Asked again periodically: scrollToMessageId drops the + # request outright while waitingForGetDifference, and + # a load can be superseded by another one. + diagnostics.note( + f"injector: anchor {self.anchor_msg_id} not loaded " + f"({_id_span(messages_list)}), requesting it (attempt {attempt})" + ) self._load_anchor() if retries_left > 0: _on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS) @@ -398,16 +412,87 @@ class SummaryInjection: messages_list.set(index, self.message_object) self.placed = True self._placing = False + diagnostics.note( + f"injector: card placed after anchor {self.anchor_msg_id} at index {index} " + f"(retries left {retries_left})" + ) adapter = get_chat_adapter(self.chat_activity) if adapter is not None: adapter.notifyDataSetChanged() - # Once, on placement only -- scrolling on every streaming update - # would fight the user for control of the list. - self.scroll_into_view() + # On placement only -- scrolling on every streaming update would + # fight the user for control of the list. The settle checks right + # after only undo scrolls the app itself makes in that window. + if self.scroll_into_view(): + self._schedule_settle(0) except Exception as exc: self._placing = False diagnostics.record_error("injector.place", exc) + def _schedule_settle(self, step: int) -> None: + if step >= len(JUMP_SETTLE_DELAYS_MS): + return + previous = JUMP_SETTLE_DELAYS_MS[step - 1] if step else 0 + _on_ui(lambda: self._settle(step), JUMP_SETTLE_DELAYS_MS[step] - previous) + + def _settle(self, step: int) -> None: + """Re-jumps if something scrolled the fresh card off screen. + + Stops for good as soon as the user touches the list: from then on + where the chat is scrolled is theirs to decide. + """ + try: + list_view = _chat_list_view(self.chat_activity) + if list_view is not None and int(list_view.getScrollState()) == 1: # SCROLL_STATE_DRAGGING + diagnostics.note("injector: user is scrolling, jump settled") + return + span = self._visible_span() + if span is not None: + position, first, last = span + if not first <= position <= last: + diagnostics.note( + f"injector: card row {position} scrolled away (visible {first}..{last}), " + f"jumping back (check {step})" + ) + self._jump_to_card() + elif self.placed and find_existing(get_messages_list(self.chat_activity), self.virtual_id) < 0: + # The list was reloaded under the card. Put it back. + messages_list = get_messages_list(self.chat_activity) + index = find_index_for_anchor(messages_list, self.anchor_msg_id) + if index >= 0: + diagnostics.note(f"injector: list reloaded, re-placing card (check {step})") + messages_list.add(index, self.message_object) + adapter = get_chat_adapter(self.chat_activity) + if adapter is not None: + adapter.notifyDataSetChanged() + self._jump_to_card() + except Exception as exc: + diagnostics.record_error("injector.settle", exc) + self._schedule_settle(step + 1) + + def _visible_span(self) -> Optional[tuple]: + """(card adapter position, first visible, last visible), or None.""" + try: + from hook_utils import get_private_field + + messages_list = get_messages_list(self.chat_activity) + adapter = get_chat_adapter(self.chat_activity) + layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field( + self.chat_activity, "chatLayoutManager" + ) + if messages_list is None or adapter is None or layout_manager is None: + return None + index = find_existing(messages_list, self.virtual_id) + if index < 0: + return None + first = int(layout_manager.findFirstVisibleItemPosition()) + last = int(layout_manager.findLastVisibleItemPosition()) + if first < 0 or last < 0: + return None + return int(adapter.messagesStartRow) + index, min(first, last), max(first, last) + except Exception as exc: + diagnostics.record_error("injector.visible_span", exc) + return None + def _load_anchor(self) -> None: """Asks the chat to load the anchor and scroll to it. @@ -464,6 +549,7 @@ class SummaryInjection: # bottom=False: offset counts from the top edge, as in # ChatActivity.scrollToMessageId's own scrollToPosition call. layout_manager.scrollToPositionWithOffset(position, offset, False) + diagnostics.note(f"injector: jump to row {position} offset {offset}") return True except Exception as exc: diagnostics.record_error("injector.jump_to_card", exc) @@ -583,6 +669,21 @@ def _chat_list_view(chat_activity: Any) -> Optional[Any]: return None +def _id_span(messages_list: Any) -> str: + """'loaded ids A..B, N rows' for diagnostics.""" + try: + ids = [ + int(getattr(getattr(messages_list.get(i), "messageOwner", None), "id", 0) or 0) + for i in range(messages_list.size()) + ] + real = [i for i in ids if i > 0] + if not real: + return f"no messages, {len(ids)} rows" + return f"loaded ids {min(real)}..{max(real)}, {len(ids)} rows" + except Exception: + return "loaded ids unknown" + + def remove_cards(chat_activity: Any, virtual_ids) -> None: """Drops injected cards from an open chat's list. diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py index bdea76f..bc04276 100755 --- a/tools/verify_bundle.py +++ b/tools/verify_bundle.py @@ -1845,6 +1845,10 @@ def main(argv=None) -> int: def __init__(self): self.cells = [] self.stopped = 0 + self.scroll_state = 0 + + def getScrollState(self): + return self.scroll_state def getMeasuredHeight(self): return 900 @@ -1859,11 +1863,30 @@ def main(argv=None) -> int: return self.cells[index] class _FakeLayoutManager: + """Records jumps and reports the visible row range they produce. + + `hijack` simulates ChatActivity's own postponed scroll landing + right after a jump and carrying the view elsewhere. + """ + def __init__(self): self.jumps = [] + self.visible = (-1, -1) + self.hijack = 0 def scrollToPositionWithOffset(self, position, offset, bottom): self.jumps.append((position, offset, bottom)) + if self.hijack: + self.hijack -= 1 + self.visible = (40, 46) + else: + self.visible = (max(0, position - 3), position + 3) + + def findFirstVisibleItemPosition(self): + return self.visible[0] + + def findLastVisibleItemPosition(self): + return self.visible[1] class _FakeChatActivity: def __init__(self, ids): @@ -2088,6 +2111,64 @@ def main(argv=None) -> int: f"ids={far_ids}", ) + # Loading a far anchor makes ChatActivity run its own postponed scroll, + # which can land after the jump and carry the view away from the card. + # The settle checks must bring it back. + hijacked = _FakeChatActivity([900, 800]) + hijacked.loadable[300] = [310, 300, 290] + hijacked.chatLayoutManager.hijack = 1 + hijacked_card = injector.virtual_id_for(7, 300) + injector.SummaryInjection(hijacked, 0, -4242, 0, 300, hijacked_card).insert( + thinking="chunk", streaming=True + ) + card_row = 2 + hijacked.ids().index(hijacked_card) + first, last = hijacked.chatLayoutManager.visible + check( + "a jump overridden by the app's own scroll is redone until the card is on screen", + len(hijacked.chatLayoutManager.jumps) == 2 and first <= card_row <= last, + f"jumps={hijacked.chatLayoutManager.jumps} visible={(first, last)} row={card_row}", + ) + + # Once the user takes hold of the list, the plugin stops steering it. + dragged = _FakeChatActivity([600, 555, 500]) + dragged.chatLayoutManager.hijack = 1 + dragged.chatListView.scroll_state = 1 + injector.SummaryInjection(dragged, 0, -4242, 0, 555, injector.virtual_id_for(8, 555)).insert( + thinking="chunk", streaming=True + ) + check( + "no re-jump while the user is dragging the list", + len(dragged.chatLayoutManager.jumps) == 1, + f"jumps={dragged.chatLayoutManager.jumps}", + ) + + # A cache-then-server reload can replace the list under a fresh card. + reloaded = _FakeChatActivity([600, 555, 500]) + reloaded_card = injector.virtual_id_for(9, 555) + reloaded_inj = injector.SummaryInjection(reloaded, 0, -4242, 0, 555, reloaded_card) + real_on_ui_for_reload = injector._on_ui + dropped = [] + + def _reloading_on_ui(fn, delay_ms=0): + if delay_ms and not dropped: + # First settle check: the chat reloads its window first. + reloaded.messages[:] = [m for m in reloaded.messages if m.messageOwner.id > 0] + dropped.append(True) + fn() + + injector._on_ui = _reloading_on_ui + try: + reloaded_inj.insert(thinking="chunk", streaming=True) + finally: + injector._on_ui = real_on_ui_for_reload + reloaded_ids = reloaded.ids() + check( + "a card dropped by a list reload right after placement is put back", + dropped and reloaded_ids.count(reloaded_card) == 1 + and reloaded_ids.index(reloaded_card) == reloaded_ids.index(555) - 1, + f"ids={reloaded_ids}", + ) + # An anchor that never loads: no card anywhere, rather than a card # attached to messages it did not analyze. never = _FakeChatActivity([900, 800]) @@ -2097,8 +2178,13 @@ def main(argv=None) -> int: never_inj.update(answer_markdown="more", streaming=True) check( "an anchor that never loads places no card at a wrong position", - never_card not in never.ids() and not never_inj.placed and never.scrolled_to == [300], - f"ids={never.ids()} scrolled_to={never.scrolled_to}", + never_card not in never.ids() and not never_inj.placed, + f"ids={never.ids()}", + ) + check( + "a far anchor that does not arrive is requested again, not just once", + len(never.scrolled_to) >= 2 and set(never.scrolled_to) == {300}, + f"scrolled_to={never.scrolled_to}", ) # ...and the card shows up once the user scrolls the anchor in. for mid in (310, 300):