generated from exteraGramPlugins/pluginBase
Keep the chat on a far-back summary card after its anchor loads
Publish plugin / publish (push) Successful in 3s
Publish plugin / publish (push) Successful in 3s
With an offset the newest analyzed message is usually outside the loaded window, so placement asks the chat to load it first. That path could leave the view away from the card while offset 0 always worked: - Placement gave up after ~3 s (25 x 120 ms) and asked for the anchor only once. scrollToMessageId drops the request while waitingForGetDifference, and a load can be superseded. It now waits ~10 s and re-requests every ~2 s. - Loading a far anchor makes ChatActivity run its own postponed scroll, and a cache-then-server reload can re-scroll or replace the list. Either can land after the jump. Settle checks at 0.25/0.7/1.5/3 s re-jump if the card row is outside the visible range, and re-place the card if a reload dropped it. They stop as soon as the user drags the list. Also logs each run's count/offset, the fetched id range, pre-stream failures, load requests, placement and jumps to the diagnostics file, since bulletin-only errors previously left no trace. Verified on device with count 1000, offset 1000: anchor loaded on the first retry, card placed after it and the chat jumped there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
16bf8de84c
commit
af23d2e682
5 files changed
+219
-13
No files matched your search
+5
-2
@@ -3,9 +3,12 @@
|
|||||||
"name": "Chat Summaries",
|
"name": "Chat Summaries",
|
||||||
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"icon": "exteraPlugins/1",
|
"icon": "exteraPlugins/1",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
"requirements": ["requests", "debugpy"]
|
"requirements": [
|
||||||
|
"requests",
|
||||||
|
"debugpy"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+11
-3
@@ -57,6 +57,14 @@ SUMMARY_HASHTAG = "#aisummaries"
|
|||||||
|
|
||||||
# A summary card can only be placed directly after its newest analyzed
|
# 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
|
# 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.
|
# to load it, then retries this many times, this many ms apart (~10 s), asking
|
||||||
PLACEMENT_RETRIES = 25
|
# again every PLACEMENT_RELOAD_EVERY retries in case a request was dropped.
|
||||||
PLACEMENT_RETRY_DELAY_MS = 120
|
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)
|
||||||
+9
-1
@@ -274,6 +274,9 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
bulletin and leaves the chat exactly as the user left it: no error
|
bulletin and leaves the chat exactly as the user left it: no error
|
||||||
bubble to dismiss, no jump away from where they were reading.
|
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)
|
BulletinHelper.show_info(f"Chat Summaries: reading {count} messages…", fragment)
|
||||||
|
|
||||||
def on_fetched(messages, error):
|
def on_fetched(messages, error):
|
||||||
@@ -292,6 +295,7 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched)
|
self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched)
|
||||||
|
|
||||||
def _report_error(self, message: str, fragment: Any) -> None:
|
def _report_error(self, message: str, fragment: Any) -> None:
|
||||||
|
diagnostics.note(f"summary failed: {message}")
|
||||||
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
|
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
|
||||||
|
|
||||||
def _stream_into(
|
def _stream_into(
|
||||||
@@ -310,7 +314,11 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
"""
|
"""
|
||||||
from services.rich_injector import SummaryInjection, virtual_id_for
|
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()
|
db = self._get_summary_db()
|
||||||
state = {
|
state = {
|
||||||
"answer": "",
|
"answer": "",
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ Verified against exteraGram 12.5.x (dex inspection):
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import diagnostics
|
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
|
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.
|
# Above Telegram's issued id range, so this user exists only locally.
|
||||||
@@ -379,10 +385,18 @@ class SummaryInjection:
|
|||||||
else:
|
else:
|
||||||
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
||||||
if index < 0:
|
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
|
# The analyzed range ends outside the loaded window
|
||||||
# (an offset, or history scrolled away). Have the chat
|
# (an offset, or history scrolled away). Have the chat
|
||||||
# load and show it, then place once it is in the list.
|
# 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()
|
self._load_anchor()
|
||||||
if retries_left > 0:
|
if retries_left > 0:
|
||||||
_on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS)
|
_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)
|
messages_list.set(index, self.message_object)
|
||||||
self.placed = True
|
self.placed = True
|
||||||
self._placing = False
|
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)
|
adapter = get_chat_adapter(self.chat_activity)
|
||||||
if adapter is not None:
|
if adapter is not None:
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
# Once, on placement only -- scrolling on every streaming update
|
# On placement only -- scrolling on every streaming update would
|
||||||
# would fight the user for control of the list.
|
# fight the user for control of the list. The settle checks right
|
||||||
self.scroll_into_view()
|
# after only undo scrolls the app itself makes in that window.
|
||||||
|
if self.scroll_into_view():
|
||||||
|
self._schedule_settle(0)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._placing = False
|
self._placing = False
|
||||||
diagnostics.record_error("injector.place", exc)
|
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:
|
def _load_anchor(self) -> None:
|
||||||
"""Asks the chat to load the anchor and scroll to it.
|
"""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
|
# bottom=False: offset counts from the top edge, as in
|
||||||
# ChatActivity.scrollToMessageId's own scrollToPosition call.
|
# ChatActivity.scrollToMessageId's own scrollToPosition call.
|
||||||
layout_manager.scrollToPositionWithOffset(position, offset, False)
|
layout_manager.scrollToPositionWithOffset(position, offset, False)
|
||||||
|
diagnostics.note(f"injector: jump to row {position} offset {offset}")
|
||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("injector.jump_to_card", exc)
|
diagnostics.record_error("injector.jump_to_card", exc)
|
||||||
@@ -583,6 +669,21 @@ def _chat_list_view(chat_activity: Any) -> Optional[Any]:
|
|||||||
return None
|
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:
|
def remove_cards(chat_activity: Any, virtual_ids) -> None:
|
||||||
"""Drops injected cards from an open chat's list.
|
"""Drops injected cards from an open chat's list.
|
||||||
|
|
||||||
|
|||||||
+88
-2
@@ -1845,6 +1845,10 @@ def main(argv=None) -> int:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.cells = []
|
self.cells = []
|
||||||
self.stopped = 0
|
self.stopped = 0
|
||||||
|
self.scroll_state = 0
|
||||||
|
|
||||||
|
def getScrollState(self):
|
||||||
|
return self.scroll_state
|
||||||
|
|
||||||
def getMeasuredHeight(self):
|
def getMeasuredHeight(self):
|
||||||
return 900
|
return 900
|
||||||
@@ -1859,11 +1863,30 @@ def main(argv=None) -> int:
|
|||||||
return self.cells[index]
|
return self.cells[index]
|
||||||
|
|
||||||
class _FakeLayoutManager:
|
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):
|
def __init__(self):
|
||||||
self.jumps = []
|
self.jumps = []
|
||||||
|
self.visible = (-1, -1)
|
||||||
|
self.hijack = 0
|
||||||
|
|
||||||
def scrollToPositionWithOffset(self, position, offset, bottom):
|
def scrollToPositionWithOffset(self, position, offset, bottom):
|
||||||
self.jumps.append((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:
|
class _FakeChatActivity:
|
||||||
def __init__(self, ids):
|
def __init__(self, ids):
|
||||||
@@ -2088,6 +2111,64 @@ def main(argv=None) -> int:
|
|||||||
f"ids={far_ids}",
|
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
|
# An anchor that never loads: no card anywhere, rather than a card
|
||||||
# attached to messages it did not analyze.
|
# attached to messages it did not analyze.
|
||||||
never = _FakeChatActivity([900, 800])
|
never = _FakeChatActivity([900, 800])
|
||||||
@@ -2097,8 +2178,13 @@ def main(argv=None) -> int:
|
|||||||
never_inj.update(answer_markdown="more", streaming=True)
|
never_inj.update(answer_markdown="more", streaming=True)
|
||||||
check(
|
check(
|
||||||
"an anchor that never loads places no card at a wrong position",
|
"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],
|
never_card not in never.ids() and not never_inj.placed,
|
||||||
f"ids={never.ids()} scrolled_to={never.scrolled_to}",
|
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.
|
# ...and the card shows up once the user scrolls the anchor in.
|
||||||
for mid in (310, 300):
|
for mid in (310, 300):
|
||||||
|
|||||||
Reference in new issue
Block a user