This commit is contained in:
lyuksovannyy
2026-09-10 12:47:54 +02:00
parent e20dfa3afb
commit 90678506c6
7 changed files with 4154 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
dist/
references/
__pycache__/
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
+65 -1
View File
@@ -1,2 +1,66 @@
# chat-summaries # AI Chat Summaries Plugin for exteraGram
Advanced AI-powered Telegram chat summarization plugin for [exteraGram](https://exteragram.app).
## Features
- **ChatGPT OAuth Integration**: Secure browser authorization with automatic tier detection (Free: `luna`, `gpt-4o-mini`; Plus/Pro/Business: `terra`, `sol`, `gpt-4o`, `o1`, `o3-mini`).
- **Custom AI Provider**: OpenAI-compatible custom endpoints (OpenRouter, DeepSeek, Together AI, local vLLM/Ollama) with **Multi-Stage Verification**:
- Stage 1: URL reachability test.
- Stage 2: Lightweight probe test (`max_tokens: 10`, thinking disabled).
- Stage 3: Error logs with one-click full sanitized request log copier.
- Stage 4: Context window token budgeting (8k to 1M) preserving up to 8,000 characters per message.
- **Built-in Connectors**: Direct API key connectors for OpenAI, Anthropic Claude (with Claude 3.7 reasoning tokens), Google Gemini, and Ollama.
- **Dual Triggers**:
1. **Pinned Header AI Button**: Top-right AI action button next to the pinned message bar.
2. **Unread Badge Long-Press**: Long-press on floating unread badge or unread divider in chat with auto-filled message count.
3. **Chat Action Bar Menu**: Fallback action item for universal accessibility.
- **Animated Progress Widget**: Dynamic in-header pulsing progress indicator that shrinks `PinnedMessageView` to prevent text overlap.
- **Live Thinking Stream Preview**: Real-time streaming inspector for reasoning and thought process tokens (`reasoning_content`, `<think>...</think>`, `delta.thought`). Can be toggled off in settings to save mobile bandwidth.
- **Per-Chat Customization**: Pre-request bottom sheet with message count slider, style chips (Brief, Detailed, Key Highlights, Custom), and per-chat prompt override.
- **Rich Result Dialog**: Formatted Markdown rendering with Share, Copy Summary, Copy Full Prompt & Data, Regenerate, and Insert into Chat Composer.
- **Localization**: Full English (`en`) and Russian (`ru`) translations.
## Project Structure
```
exteragram-chat-summaries/
├── ai_chat_summary.plugin # Main entrypoint & metadata header
├── build.py # Standalone bundler & syntax validator
├── src/
│ ├── config.py # Configuration constants & model tiers
│ ├── localization.py # LocalizationManager (EN/RU dictionaries)
│ ├── providers/
│ │ ├── base.py # Base provider interface, debug logger, prompt builder
│ │ ├── oauth.py # ChatGPT OAuth handler & tier mapper
│ │ ├── custom.py # Custom provider with multi-stage verification
│ │ ├── builtin.py # OpenAI, Anthropic, Gemini, Ollama direct handlers
│ │ └── dispatcher.py # Unified dispatcher & live streaming state manager
│ ├── services/
│ │ └── message_fetcher.py # TLRPC history pagination & media formatting
│ ├── ui/
│ │ ├── settings.py # Dynamic settings layout & verification dialogs
│ │ ├── pre_request.py # Pre-request bottom sheet & prompt customizer
│ │ ├── progress_widget.py # In-header progress widget & pinned view shrinker
│ │ ├── thinking_sheet.py # Live streaming reasoning viewer
│ │ └── summary_dialog.py # Markdown result presentation & error log viewer
│ └── hooks/
│ ├── pinned_hook.py # PinnedMessageView top bar hook
│ └── unread_hook.py # ChatUnreadCell & floating badge long-press hook
├── tests/
│ └── test_plugin.py # Unit & simulation test suite
└── dist/
└── ai_chat_summary.plugin # Bundled single-file distributable artifact
```
## Bundling & Testing
To bundle into a single-file distributable artifact:
```bash
python3 build.py
```
To run the unit and simulation test suite:
```bash
python3 -m unittest discover -s tests -v
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

+884
View File
@@ -0,0 +1,884 @@
from typing import Any, List
from base_plugin import BasePlugin, MenuItemData, MenuItemType
from client_utils import (
get_last_fragment, get_messages_controller,
run_on_queue, send_request, PLUGINS_QUEUE,
)
from android_utils import run_on_ui_thread, log, OnClickListener, copy_to_clipboard
from ui.settings import Header, Divider, Selector, Input, Text
from ui.bulletin import BulletinHelper
from org.telegram.messenger import AndroidUtilities
from org.telegram.ui.ActionBar import Theme
__id__ = "chat_summary"
__name__ = "Chat Summary"
__description__ = "Саммари любого чата через AI — OpenAI, Anthropic, Gemini, Ollama или свой endpoint."
__author__ = "@aaxnet"
__version__ = "2.2.0" # Обновили версию в связи с добавлением дебага и ретраев
__icon__ = "KOSHAKIEBANIYE/44"
__app_version__ = ">=12.5.1"
__sdk_version__ = ">=1.4.3.3"
__requirements__ = ["requests"]
PROVIDER_OPENAI = 0
PROVIDER_ANTHROPIC = 1
PROVIDER_GEMINI = 2
PROVIDER_OLLAMA = 3
PROVIDER_CUSTOM = 4
PROVIDERS = [
"OpenAI (GPT-4o)",
"Anthropic (Claude)",
"Gemini",
"Ollama (local)",
"Custom endpoint",
]
DEFAULT_MODELS = [
"gpt-4o",
"claude-sonnet-4-5",
"gemini-2.0-flash",
"llama3",
"",
]
STYLE_BRIEF = 0
STYLE_DETAILED = 1
STYLE_BULLETS = 2
STYLES = ["Краткий", "Подробный", "По пунктам"]
STYLE_HINTS = [
"23 sentences о главном",
"Все темы и детали",
"Список ключевых моментов",
]
LANG_AUTO = 0
LANG_RU = 1
LANG_EN = 2
LANGS = ["Авто", "Русский", "English"]
MSG_COUNTS = [50, 100, 200, 500]
MAX_CONTENT_CHARS = 48000
KEY_PREFIXES = {
PROVIDER_OPENAI: "sk-",
PROVIDER_ANTHROPIC: "sk-ant-",
PROVIDER_GEMINI: "AIza",
PROVIDER_CUSTOM: "sk-",
}
PROVIDER_MODEL_LABELS = {
PROVIDER_OPENAI: "OpenAI",
PROVIDER_ANTHROPIC: "Anthropic",
PROVIDER_GEMINI: "Gemini",
PROVIDER_OLLAMA: "Ollama",
PROVIDER_CUSTOM: "Custom",
}
def check_key_format(provider, api_key):
"""Returns (is_valid, hint_text) for a quick format check, not a real auth check."""
api_key = (api_key or "").strip()
if provider == PROVIDER_OLLAMA:
return True, "Ключ не требуется"
if not api_key:
return False, "Ключ не указан"
prefix = KEY_PREFIXES.get(provider)
if prefix and not api_key.startswith(prefix):
return False, "Похоже на неверный формат (ожидается " + prefix + "...)"
if len(api_key) < 16:
return False, "Слишком короткий ключ"
return True, "Формат похож на правильный"
def get_theme_color(key, fallback):
try:
return Theme.getColor(getattr(Theme, key))
except Exception:
return fallback
def build_system_prompt(style, lang):
lang_instructions = {
LANG_AUTO: "Respond in the same language as the conversation.",
LANG_RU: "Respond in Russian.",
LANG_EN: "Respond in English.",
}
style_instructions = {
STYLE_BRIEF: "Write a concise 2-3 sentence summary of the key points.",
STYLE_DETAILED: "Write a detailed summary covering all important topics discussed.",
STYLE_BULLETS: "Write a bullet-point list of the main topics and decisions.",
}
lang_part = lang_instructions.get(lang, "")
style_part = style_instructions.get(style, "")
return (
"You are a helpful assistant that summarizes Telegram chat conversations. "
+ style_part + " " + lang_part + " "
"Focus only on content. Do not include meta-commentary."
)
def parse_java_list(java_list):
result = {}
if not java_list:
return result
try:
size = java_list.size()
for i in range(size):
item = java_list.get(i)
try:
result[int(item.id)] = item
except Exception:
pass
except Exception:
pass
return result
def get_sender_name(raw_msg, user_map, chat_map):
fid = getattr(raw_msg, "from_id", None)
if fid is None:
fid = getattr(raw_msg, "peer_id", None)
if fid is None:
return "Unknown"
uid = getattr(fid, "user_id", None)
channel_id = getattr(fid, "channel_id", None)
chat_id = getattr(fid, "chat_id", None)
if uid:
uid = int(uid)
user = user_map.get(uid)
if user:
first = getattr(user, "first_name", "") or ""
last = getattr(user, "last_name", "") or ""
name = (first + " " + last).strip()
return name if name else "User" + str(uid)
return "User" + str(uid)
if channel_id:
channel_id = int(channel_id)
chat = chat_map.get(channel_id)
if chat:
return getattr(chat, "title", None) or "Channel" + str(channel_id)
return "Channel" + str(channel_id)
if chat_id:
chat_id = int(chat_id)
chat = chat_map.get(chat_id)
if chat:
return getattr(chat, "title", None) or "Chat" + str(chat_id)
return "Chat" + str(chat_id)
return "Unknown"
def get_message_text(msg, raw_msg):
mt = getattr(msg, "messageText", None)
if mt:
return str(mt)
raw_text = getattr(raw_msg, "message", None)
if raw_text:
return str(raw_text)
return ""
def build_transcript(messages, users=None, chats=None):
user_map = parse_java_list(users)
chat_map = parse_java_list(chats)
size = messages.size() if hasattr(messages, "size") else len(messages)
lines = []
used = 0
for i in range(size - 1, -1, -1):
try:
msg = messages.get(i) if hasattr(messages, "get") else messages[i]
raw = getattr(msg, "messageOwner", msg)
cls = type(raw).__name__
if "Service" in cls or "Empty" in cls:
continue
sender = get_sender_name(raw, user_map, chat_map)
text = get_message_text(msg, raw)
if text.strip():
lines.append(sender + ": " + text)
used += 1
except Exception:
continue
full_text = "\n".join(lines)
if len(full_text) > MAX_CONTENT_CHARS:
full_text = "...[обрезано]\n" + full_text[-MAX_CONTENT_CHARS:]
return full_text, used
PROVIDER_EMOJIS = ["🤖", "🧠", "✨", "💻", "🔗"]
LONG_SUMMARY_THRESHOLD = 600 # chars, above this we add a scroll + "via model" footer is still shown either way
def show_result(act, summary, msg_count, provider, model):
try:
from ui.alert import AlertDialogBuilder
from android.widget import LinearLayout, TextView, ScrollView
from android.view import Gravity
from android.util import TypedValue
dp = AndroidUtilities.dp
provider_label = PROVIDER_MODEL_LABELS.get(provider, "AI")
model_label = model.strip() if model and model.strip() else (
DEFAULT_MODELS[provider] if 0 <= provider < len(DEFAULT_MODELS) else ""
)
footer_text = "via " + provider_label + (" · " + model_label if model_label else "")
def on_copy(b, w):
copy_to_clipboard(summary)
BulletinHelper.show_info("Скопировано ✓")
b.dismiss()
def on_share(b, w):
try:
from android.content import Intent
intent = Intent(Intent.ACTION_SEND)
intent.setType("text/plain")
intent.putExtra(Intent.EXTRA_TEXT, summary)
act.startActivity(Intent.createChooser(intent, "Поделиться саммари"))
except Exception as ex:
log("chat_summary: share error: " + str(ex))
b.dismiss()
root = LinearLayout(act)
root.setOrientation(LinearLayout.VERTICAL)
root.setPadding(dp(24), dp(8), dp(24), dp(4))
scroll = ScrollView(act)
max_height_px = dp(360)
body = TextView(act)
body.setText(summary)
body.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15)
body.setTextColor(get_theme_color("key_dialogTextBlack", 0xFF212121))
body.setLineSpacing(dp(2), 1.0)
scroll.addView(body)
lp_scroll = LinearLayout.LayoutParams(-1, -2)
if len(summary) > LONG_SUMMARY_THRESHOLD:
lp_scroll = LinearLayout.LayoutParams(-1, max_height_px)
root.addView(scroll, lp_scroll)
if len(summary) > LONG_SUMMARY_THRESHOLD:
hint = TextView(act)
hint.setText("↕ прокрути для остального текста")
hint.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
hint.setGravity(Gravity.CENTER)
hint.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888))
lp_hint = LinearLayout.LayoutParams(-1, -2)
lp_hint.topMargin = dp(6)
root.addView(hint, lp_hint)
footer = TextView(act)
footer.setText(footer_text)
footer.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
footer.setGravity(Gravity.CENTER)
footer.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF999999))
lp_footer = LinearLayout.LayoutParams(-1, -2)
lp_footer.topMargin = dp(14)
lp_footer.bottomMargin = dp(4)
root.addView(footer, lp_footer)
builder = AlertDialogBuilder(act)
builder.set_title("✨ Саммари · " + str(msg_count) + " сообщений")
builder.set_view(root)
builder.set_positive_button("Закрыть", lambda b, w: b.dismiss())
builder.set_negative_button("Копировать", on_copy)
builder.set_neutral_button("Поделиться", on_share)
builder.show()
except Exception as e:
log("chat_summary: show_result error: " + str(e))
try:
from ui.alert import AlertDialogBuilder
builder = AlertDialogBuilder(act)
builder.set_title("✨ Саммари · " + str(msg_count) + " сообщений")
builder.set_message(summary)
builder.set_positive_button("Закрыть", lambda b, w: b.dismiss())
builder.set_negative_button("Копировать", lambda b, w: (copy_to_clipboard(summary), b.dismiss()))
builder.show()
except Exception as e2:
log("chat_summary: fallback dialog error: " + str(e2))
def show_spinner(act, title="Анализирую"):
try:
from ui.alert import AlertDialogBuilder
from android.os import Handler, Looper
d = AlertDialogBuilder(act, AlertDialogBuilder.ALERT_TYPE_SPINNER)
d.set_title(title + "…")
d.set_cancelable(False)
d.show()
dots = ["", ".", "..", "..."]
state = [0, True]
handler = Handler(Looper.getMainLooper())
def tick():
if not state[1]:
return
state[0] = (state[0] + 1) % len(dots)
try:
d.set_title(title + dots[state[0]])
except Exception:
state[1] = False
return
handler.postDelayed(tick, 500)
handler.postDelayed(tick, 500)
original_dismiss = d.dismiss
def dismiss_and_stop():
state[1] = False
try:
original_dismiss()
except Exception:
pass
d.dismiss = dismiss_and_stop
return d
except Exception as e:
log("chat_summary: spinner error: " + str(e))
return None
def dismiss_dialog(dlg):
if dlg is None:
return
try:
dlg.dismiss()
except Exception:
pass
def call_openai(api_key, model, system, content, url=None):
import requests
model = model or "gpt-4o"
url = url or "https://api.openai.com/v1/chat/completions"
resp = requests.post(
url,
headers={
"Authorization": "Bearer " + api_key,
"Content-Type": "application/json",
},
json={
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": content},
],
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def call_anthropic(api_key, model, system, content):
import requests
model = model or "claude-sonnet-4-5"
resp = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json={
"model": model,
"max_tokens": 1024,
"system": system,
"messages": [{"role": "user", "content": content}],
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["content"][0]["text"]
def call_gemini(api_key, model, system, content):
import requests
model = model or "gemini-2.0-flash"
url = (
"https://generativelanguage.googleapis.com/v1beta/models/"
+ model
+ ":generateContent?key="
+ api_key
)
resp = requests.post(
url,
json={"contents": [{"parts": [{"text": system + "\n\n" + content}]}]},
timeout=60,
)
resp.raise_for_status()
return resp.json()["candidates"][0]["content"]["parts"][0]["text"]
def call_ollama(model, system, content):
import requests
model = model or "llama3"
resp = requests.post(
"http://localhost:11434/api/chat",
json={
"model": model,
"stream": False,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": content},
],
},
timeout=120,
)
resp.raise_for_status()
return resp.json()["message"]["content"]
# ОБНОВЛЕНО: Добавлен алгоритм Exponential Backoff для обработки ошибки 429
def call_ai(provider, api_key, model, system, content, custom_url):
import time
import random
max_retries = 3 # Количество попыток автоматического перезапуска
delay = 2 # Начальная задержка в секундах
for attempt in range(max_retries):
try:
if provider == PROVIDER_OPENAI:
return call_openai(api_key, model, system, content)
if provider == PROVIDER_ANTHROPIC:
return call_anthropic(api_key, model, system, content)
if provider == PROVIDER_GEMINI:
return call_gemini(api_key, model, system, content)
if provider == PROVIDER_OLLAMA:
return call_ollama(model, system, content)
if provider == PROVIDER_CUSTOM:
return call_openai(api_key, model, system, content, url=custom_url)
raise ValueError("Unknown provider: " + str(provider))
except Exception as e:
err_msg = str(e)
# Если словили 429 (Превышение лимитов) и лимит попыток не исчерпан
if "429" in err_msg and attempt < max_retries - 1:
sleep_time = delay + random.uniform(0, 1)
log("chat_summary: Hit 429 Rate Limit. Retrying in " + str(round(sleep_time, 2)) + "s...")
time.sleep(sleep_time)
delay *= 2 # Удваиваем время ожидания для следующего шага
continue
# Во всех остальных случаях (или если попытки кончились) выбрасываем ошибку дальше
raise e
# ОБНОВЛЕНО: Добавлено явное и красивое описание ошибки 429 при тесте связи
def test_connection(provider, api_key, model, custom_url):
"""Sends a minimal ping request, returns (ok, message)."""
try:
system = "Reply with exactly one word: OK."
content = "ping"
result = call_ai(provider, api_key, model, system, content, custom_url)
if result and result.strip():
return True, "Подключение работает ✓"
return False, "Пустой ответ от сервера"
except Exception as e:
msg = str(e)
if "429" in msg:
return False, "Превышен лимит запросов (ошибка 429). Подожди немного."
if "401" in msg or "Unauthorized" in msg:
return False, "Неверный API ключ"
if "404" in msg:
return False, "Модель не найдена"
if "timeout" in msg.lower() or "timed out" in msg.lower():
return False, "Таймаут — сервер не отвечает"
return False, "Ошибка: " + msg[:80]
class ChatSummaryPlugin(BasePlugin):
def on_plugin_load(self):
self.last_error = "Ошибок пока не зафиксировано" # Трекер для Debug-кнопки
self.add_menu_item(MenuItemData(
menu_type=MenuItemType.CHAT_ACTION_MENU,
text="Summarize Chat",
subtext="Краткое AI-саммари",
icon="msg_info",
on_click=self.on_menu_click,
))
def on_plugin_unload(self):
pass
def on_menu_click(self, context):
dialog_id = context.get("dialog_id")
if not dialog_id:
return
fragment = context.get("fragment") or get_last_fragment()
if not fragment:
return
act = fragment.getParentActivity()
if not act:
return
provider = self.get_setting("provider", PROVIDER_OPENAI)
api_key = self.get_setting("api_key", "") or ""
if provider != PROVIDER_OLLAMA and not api_key.strip():
BulletinHelper.show_error("Сначала укажи API ключ в настройках плагина")
return
run_on_ui_thread(lambda: self.show_count_picker(act, dialog_id))
def show_count_picker(self, act, dialog_id):
try:
from android.widget import LinearLayout, TextView
from android.view import Gravity
from android.util import TypedValue
from android.graphics.drawable import GradientDrawable
from org.telegram.ui.ActionBar import BottomSheet
dp = AndroidUtilities.dp
sheet_holder = [None]
provider = self.get_setting("provider", PROVIDER_OPENAI)
emoji = PROVIDER_EMOJIS[provider] if 0 <= provider < len(PROVIDER_EMOJIS) else "🤖"
provider_name = PROVIDERS[provider] if 0 <= provider < len(PROVIDERS) else "AI"
def pick(count):
if sheet_holder[0]:
sheet_holder[0].dismiss()
BulletinHelper.show_info("Загружаю " + str(count) + " сообщений…")
run_on_queue(
lambda: self.fetch_and_summarize(act, dialog_id, count),
PLUGINS_QUEUE, 0,
)
root = LinearLayout(act)
root.setOrientation(LinearLayout.VERTICAL)
root.setPadding(dp(20), dp(20), dp(20), dp(28))
try:
root.setBackgroundColor(Theme.getColor(Theme.key_dialogBackground))
except Exception:
pass
icon_tv = TextView(act)
icon_tv.setText(emoji)
icon_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 36)
icon_tv.setGravity(Gravity.CENTER)
lp = LinearLayout.LayoutParams(-1, -2)
lp.bottomMargin = dp(8)
root.addView(icon_tv, lp)
title = TextView(act)
title.setText("Сколько сообщений?")
title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 18)
title.setGravity(Gravity.CENTER)
title.setTextColor(get_theme_color("key_dialogTextBlack", 0xFF212121))
try:
title.setTypeface(AndroidUtilities.bold())
except Exception:
pass
lp = LinearLayout.LayoutParams(-1, -2)
lp.bottomMargin = dp(4)
root.addView(title, lp)
sub = TextView(act)
sub.setText("Провайдер: " + provider_name)
sub.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13)
sub.setGravity(Gravity.CENTER)
sub.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888))
lp = LinearLayout.LayoutParams(-1, -2)
lp.bottomMargin = dp(20)
root.addView(sub, lp)
count_hints = ["быстро", "оптимально", "подробно", "полный анализ"]
for i, count in enumerate(MSG_COUNTS):
row = LinearLayout(act)
row.setOrientation(LinearLayout.HORIZONTAL)
row.setGravity(Gravity.CENTER_VERTICAL)
row.setPadding(dp(16), dp(13), dp(16), dp(13))
row.setClickable(True)
row.setFocusable(True)
bg = GradientDrawable()
bg.setCornerRadius(dp(14))
try:
accent = Theme.getColor(Theme.key_windowBackgroundWhiteBlueHeader)
if i == 1:
bg.setColor(accent & 0x22FFFFFF | 0x15000000)
else:
bg.setColor(get_theme_color("key_windowBackgroundWhite", 0xFFF5F5F5))
except Exception:
bg.setColor(0xFFF5F5F5)
row.setBackground(bg)
label = TextView(act)
label.setText(str(count) + " сообщений")
label.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15)
label.setTextColor(get_theme_color("key_windowBackgroundWhiteBlackText", 0xFF212121))
try:
if i == 1:
label.setTypeface(AndroidUtilities.bold())
except Exception:
pass
lp_label = LinearLayout.LayoutParams(0, -2, 1.0)
row.addView(label, lp_label)
hint = TextView(act)
hint_text = count_hints[i] if i < len(count_hints) else ""
if i == 1:
hint_text = "⭐ " + hint_text
hint.setText(hint_text)
hint.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12)
hint.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888))
hint.setGravity(Gravity.END)
row.addView(hint)
n = count
row.setOnClickListener(OnClickListener(lambda v, c=n: pick(c)))
lp = LinearLayout.LayoutParams(-1, -2)
lp.bottomMargin = dp(8)
root.addView(row, lp)
sheet = BottomSheet.Builder(act).setCustomView(root).create()
sheet_holder[0] = sheet
sheet.show()
except Exception as e:
log("chat_summary: count picker error: " + str(e))
def fetch_and_summarize(self, act, dialog_id, count):
try:
from org.telegram.tgnet import TLRPC
req = TLRPC.TL_messages_getHistory()
req.peer = get_messages_controller().getInputPeer(int(dialog_id))
req.offset_id = 0
req.offset_date = 0
req.add_offset = 0
req.limit = count
req.max_id = 0
req.min_id = 0
req.hash = 0
self_ref = [self]
act_ref = [act]
def on_response(response, error):
if error:
err = getattr(error, "text", "unknown")
run_on_ui_thread(lambda: BulletinHelper.show_error("Ошибка загрузки: " + err))
return
try:
msgs = getattr(response, "messages", None)
if not msgs:
run_on_ui_thread(lambda: BulletinHelper.show_error("Нет сообщений."))
return
users = getattr(response, "users", None)
chats = getattr(response, "chats", None)
text, used = build_transcript(msgs, users, chats)
if not text.strip():
run_on_ui_thread(lambda: BulletinHelper.show_error("Текстовых сообщений не найдено."))
return
spinner = [None]
def start_spinner():
spinner[0] = show_spinner(act_ref[0])
run_on_ui_thread(start_spinner)
run_on_queue(
lambda: self_ref[0].run_ai(act_ref[0], text, used, spinner),
PLUGINS_QUEUE, 0,
)
except Exception as ex:
log("chat_summary: parse error: " + str(ex))
run_on_ui_thread(lambda: BulletinHelper.show_error("Ошибка парсинга сообщений."))
send_request(req, on_response)
except Exception as e:
log("chat_summary: fetch error: " + str(e))
run_on_ui_thread(lambda: BulletinHelper.show_error("Не удалось начать загрузку."))
def run_ai(self, act, text, used, spinner):
try:
provider = self.get_setting("provider", PROVIDER_OPENAI)
api_key = self.get_setting("api_key", "") or ""
model = self.get_setting("model", "") or ""
style = self.get_setting("style", STYLE_BRIEF)
lang = self.get_setting("lang", LANG_AUTO)
custom_url = self.get_setting("custom_endpoint", "") or ""
system = build_system_prompt(style, lang)
content = "Chat conversation to summarize:\n\n" + text
result = call_ai(provider, api_key, model, system, content, custom_url)
def on_done():
dismiss_dialog(spinner[0])
show_result(act, result, used, provider, model)
run_on_ui_thread(on_done)
except Exception as e:
err = str(e)
self.last_error = "Ошибка в run_ai: " + err # ОБНОВЛЕНО: запись лога
log("chat_summary: ai error: " + err)
def on_error():
dismiss_dialog(spinner[0])
BulletinHelper.show_error("Ошибка AI: " + err[:120])
run_on_ui_thread(on_error)
def create_settings(self):
provider = self.get_setting("provider", PROVIDER_OPENAI)
style = self.get_setting("style", STYLE_BRIEF)
api_key = self.get_setting("api_key", "") or ""
default_model = DEFAULT_MODELS[provider] if 0 <= provider < len(DEFAULT_MODELS) else ""
style_hint = STYLE_HINTS[style] if 0 <= style < len(STYLE_HINTS) else ""
key_valid, key_hint = check_key_format(provider, api_key)
key_icon = "msg_invite" if key_valid else "msg_secret"
key_subtext = ("✓ " if key_valid else "✗ ") + key_hint
items = [
Header(text="AI провайдер"),
Selector(
key="provider",
text="Провайдер",
default=PROVIDER_OPENAI,
items=PROVIDERS,
icon="msg_robot",
on_change=lambda idx: self.set_setting("provider", idx, reload_settings=True),
),
Input(
key="api_key",
text="API ключ",
subtext=key_subtext,
icon=key_icon,
default="",
on_change=lambda val: self.set_setting("api_key", val, reload_settings=True),
),
Input(
key="model",
text="Модель",
subtext="По умолчанию: " + default_model if default_model else "Укажи название модели",
icon="msg_topic",
default="",
),
]
if provider == PROVIDER_CUSTOM:
items.append(Input(
key="custom_endpoint",
text="URL endpoint",
subtext="OpenAI-совместимый адрес",
icon="msg_link",
default="",
))
items.append(Text(
text="Проверить подключение",
subtext="Отправит тестовый запрос к API",
icon="msg_pin_code",
on_click=lambda v: self.run_connection_test(),
))
# ОБНОВЛЕНО: Добавлена кнопка сохранения отладочного лога
def action_copy_debug():
provider_str = PROVIDERS[provider] if 0 <= provider < len(PROVIDERS) else str(provider)
key_valid, key_hint = check_key_format(provider, api_key)
debug_text = (
"=== CHAT SUMMARY DEBUG LOG ===\n"
"Plugin Version: " + str(__version__) + "\n"
"SDK Version: " + str(__sdk_version__) + "\n"
"Provider: " + str(provider_str) + "\n"
"Model: " + str(self.get_setting("model", "")) + "\n"
"Has Custom Endpoint: " + str(bool(self.get_setting("custom_endpoint", ""))) + "\n"
"API Key Valid Format: " + str(key_valid) + " (" + str(key_hint) + ")\n"
"Style: " + str(self.get_setting("style", 0)) + "\n"
"Lang: " + str(self.get_setting("lang", 0)) + "\n"
"Last Recorded Error: " + str(getattr(self, "last_error", "None")) + "\n"
"=============================="
)
copy_to_clipboard(debug_text)
BulletinHelper.show_success("Дебаг-лог скопирован в буфер!")
items.append(Text(
text="Скопировать дебаг-лог",
subtext="Скопирует статус плагина и последнюю ошибку для отправки разработчику",
icon="msg_edit",
on_click=lambda v: action_copy_debug(),
))
items += [
Divider(),
Header(text="Саммари"),
Selector(
key="style",
text="Стиль",
default=STYLE_BRIEF,
items=STYLES,
icon="msg_list",
on_change=lambda idx: self.set_setting("style", idx, reload_settings=True),
),
Text(
text=style_hint,
icon="msg_info",
),
Selector(
key="lang",
text="Язык ответа",
default=LANG_AUTO,
items=LANGS,
icon="msg_translate",
),
]
return items
def run_connection_test(self):
provider = self.get_setting("provider", PROVIDER_OPENAI)
api_key = self.get_setting("api_key", "") or ""
model = self.get_setting("model", "") or ""
custom_url = self.get_setting("custom_endpoint", "") or ""
if provider != PROVIDER_OLLAMA and not api_key.strip():
BulletinHelper.show_error("Сначала укажи API ключ")
return
BulletinHelper.show_info("Проверяю подключение…")
def worker():
ok, message = test_connection(provider, api_key, model, custom_url)
# ОБНОВЛЕНО: Если тест провалился, записываем сообщение в лог
if not ok:
self.last_error = "Ошибка теста связи: " + message
def show():
if ok:
BulletinHelper.show_success(message)
else:
BulletinHelper.show_error(message)
run_on_ui_thread(show)
run_on_queue(worker, PLINS_QUEUE if 'PLINS_QUEUE' in globals() else PLUGINS_QUEUE, 0)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff