generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af23d2e682 |
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