Spaces:
Running
Running
SilverElixir commited on
Commit ·
a46f21b
1
Parent(s): 8da7b15
Match Claude's activity timeline: sparkle spinner, thought-for block with sources
Browse files- docs/ARCHITECTURE.md +1 -1
- lumen_lang.py +27 -0
- lumen_message_core.py +14 -3
- lumen_routes.py +21 -4
- lumen_status.py +57 -10
- lumen_streaming.py +32 -5
- tests/test_bot_search.py +4 -0
- tests/test_bot_streaming.py +38 -6
- tests/test_lumen_status.py +10 -2
docs/ARCHITECTURE.md
CHANGED
|
@@ -40,7 +40,7 @@ This speed estimate is measured, not hardcoded. There is no table of "model X do
|
|
| 40 |
|
| 41 |
If the streaming attempt for the head-of-route model fails before showing any text, the bot falls back to a normal (non-streaming) call further down the chain, reusing the same placeholder message rather than sending a new one.
|
| 42 |
|
| 43 |
-
In private chats every request with a real early stage (web search, media, a link or YouTube) first gets a live status bubble (`lumen_status.py`): "Searching the web
|
| 44 |
|
| 45 |
Final answers go out via `sendRichMessage` (Bot API 10.1: real tables, headings, math) with automatic fallback to plain HTML; streaming edits always use HTML. Kill-switch: `RICH_MESSAGES_ENABLED=0` (plus restart) forces legacy HTML.
|
| 46 |
|
|
|
|
| 40 |
|
| 41 |
If the streaming attempt for the head-of-route model fails before showing any text, the bot falls back to a normal (non-streaming) call further down the chain, reusing the same placeholder message rather than sending a new one.
|
| 42 |
|
| 43 |
+
In private chats every request with a real early stage (web search, media, a link or YouTube) first gets a live status bubble (`lumen_status.py`) styled after Claude: an animated sparkle (Claude Code's spinner frames) plus "Searching the web", then a checkmarked "Searched the web · found N" and a "Thinking… Ns" timer while the model call runs. The same bubble is reused for the answer. At the final edit a collapsed `Thought for Ns` blockquote (with the completed steps and search source domains) is prepended above the answer, mirroring Claude's collapsible reasoning/activity block; Telegram collapses it by default. Plain text without an early stage skips the early bubble and gets the same collapsed `Thought for Ns` block once streaming finishes. Long answers split across several Telegram messages skip the block: it would not fit the per-message limit. Status phrases are fixed pipeline facts (never model reasoning or model identity) and are localized in `lumen_lang.py`.
|
| 44 |
|
| 45 |
Final answers go out via `sendRichMessage` (Bot API 10.1: real tables, headings, math) with automatic fallback to plain HTML; streaming edits always use HTML. Kill-switch: `RICH_MESSAGES_ENABLED=0` (plus restart) forces legacy HTML.
|
| 46 |
|
lumen_lang.py
CHANGED
|
@@ -866,6 +866,14 @@ STRINGS: dict[str, dict[str, str]] = {
|
|
| 866 |
"ru": "Пробую ещё раз",
|
| 867 |
"uk": "Пробую ще раз",
|
| 868 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
# ── Кнопки-уточнения: служебные реплики ──
|
| 870 |
"pick_suffix": {
|
| 871 |
"be": "\n\n(або проста напішы тэкстам)",
|
|
@@ -1003,6 +1011,7 @@ PACK_PT: dict[str, str] = {
|
|
| 1003 |
"status_listening": "Ouvindo.",
|
| 1004 |
"progress_searching": "Pesquisando na internet",
|
| 1005 |
"progress_search_done": "Pesquisa na internet · encontrei {count}",
|
|
|
|
| 1006 |
"progress_search_empty": "A pesquisa não encontrou nada",
|
| 1007 |
"progress_thinking": "Pensando… {sec}s",
|
| 1008 |
"progress_reading_link": "Lendo o link",
|
|
@@ -1111,6 +1120,7 @@ PACK_AR: dict[str, str] = {
|
|
| 1111 |
"status_listening": "أستمع.",
|
| 1112 |
"progress_searching": "أبحث في الإنترنت",
|
| 1113 |
"progress_search_done": "بحث في الإنترنت · وجدت {count}",
|
|
|
|
| 1114 |
"progress_search_empty": "لم يجد البحث شيئًا",
|
| 1115 |
"progress_thinking": "أفكر… {sec} ث",
|
| 1116 |
"progress_reading_link": "أقرأ الرابط",
|
|
@@ -1219,6 +1229,7 @@ PACK_TR: dict[str, str] = {
|
|
| 1219 |
"status_listening": "Dinliyorum.",
|
| 1220 |
"progress_searching": "İnternette arıyorum",
|
| 1221 |
"progress_search_done": "İnternet araması · {count} bulundu",
|
|
|
|
| 1222 |
"progress_search_empty": "Arama hiçbir şey bulamadı",
|
| 1223 |
"progress_thinking": "Düşünüyorum… {sec} sn",
|
| 1224 |
"progress_reading_link": "Bağlantıyı okuyorum",
|
|
@@ -1327,6 +1338,7 @@ PACK_DE: dict[str, str] = {
|
|
| 1327 |
"status_listening": "Ich höre zu.",
|
| 1328 |
"progress_searching": "Ich suche im Internet",
|
| 1329 |
"progress_search_done": "Internetsuche · {count} gefunden",
|
|
|
|
| 1330 |
"progress_search_empty": "Die Suche ergab nichts",
|
| 1331 |
"progress_thinking": "Ich denke nach… {sec} s",
|
| 1332 |
"progress_reading_link": "Ich lese den Link",
|
|
@@ -1435,6 +1447,7 @@ PACK_FR: dict[str, str] = {
|
|
| 1435 |
"status_listening": "J'écoute.",
|
| 1436 |
"progress_searching": "Je cherche sur internet",
|
| 1437 |
"progress_search_done": "Recherche web · {count} trouvés",
|
|
|
|
| 1438 |
"progress_search_empty": "La recherche n'a rien donné",
|
| 1439 |
"progress_thinking": "Je réfléchis… {sec} s",
|
| 1440 |
"progress_reading_link": "Je lis le lien",
|
|
@@ -1543,6 +1556,7 @@ PACK_IT: dict[str, str] = {
|
|
| 1543 |
"status_listening": "Ti ascolto.",
|
| 1544 |
"progress_searching": "Cerco su internet",
|
| 1545 |
"progress_search_done": "Ricerca web · {count} trovati",
|
|
|
|
| 1546 |
"progress_search_empty": "La ricerca non ha trovato nulla",
|
| 1547 |
"progress_thinking": "Sto pensando… {sec} s",
|
| 1548 |
"progress_reading_link": "Sto leggendo il link",
|
|
@@ -1651,6 +1665,7 @@ PACK_HI: dict[str, str] = {
|
|
| 1651 |
"status_listening": "सुन रहा हूं।",
|
| 1652 |
"progress_searching": "इंटरनेट पर खोज रहा हूँ",
|
| 1653 |
"progress_search_done": "वेब खोज · {count} मिले",
|
|
|
|
| 1654 |
"progress_search_empty": "खोज में कुछ नहीं मिला",
|
| 1655 |
"progress_thinking": "सोच रहा हूँ… {sec} से",
|
| 1656 |
"progress_reading_link": "लिंक पढ़ रहा हूँ",
|
|
@@ -1759,6 +1774,7 @@ PACK_ID: dict[str, str] = {
|
|
| 1759 |
"status_listening": "Mendengarkan.",
|
| 1760 |
"progress_searching": "Mencari di internet",
|
| 1761 |
"progress_search_done": "Pencarian web · {count} ditemukan",
|
|
|
|
| 1762 |
"progress_search_empty": "Pencarian tidak menemukan apa pun",
|
| 1763 |
"progress_thinking": "Berpikir… {sec} dtk",
|
| 1764 |
"progress_reading_link": "Membaca tautan",
|
|
@@ -1867,6 +1883,7 @@ PACK_MS: dict[str, str] = {
|
|
| 1867 |
"status_listening": "Mendengar.",
|
| 1868 |
"progress_searching": "Mencari di internet",
|
| 1869 |
"progress_search_done": "Carian web · {count} ditemui",
|
|
|
|
| 1870 |
"progress_search_empty": "Carian tidak menemui apa-apa",
|
| 1871 |
"progress_thinking": "Berfikir… {sec} saat",
|
| 1872 |
"progress_reading_link": "Membaca pautan",
|
|
@@ -1975,6 +1992,7 @@ PACK_VI: dict[str, str] = {
|
|
| 1975 |
"status_listening": "Đang nghe.",
|
| 1976 |
"progress_searching": "Đang tìm trên internet",
|
| 1977 |
"progress_search_done": "Tìm kiếm web · tìm thấy {count}",
|
|
|
|
| 1978 |
"progress_search_empty": "Tìm kiếm không có kết quả",
|
| 1979 |
"progress_thinking": "Đang suy nghĩ… {sec} giây",
|
| 1980 |
"progress_reading_link": "Đang đọc liên kết",
|
|
@@ -2083,6 +2101,7 @@ PACK_TH: dict[str, str] = {
|
|
| 2083 |
"status_listening": "กำลังฟัง",
|
| 2084 |
"progress_searching": "กำลังค้นหาในอินเทอร์เน็ต",
|
| 2085 |
"progress_search_done": "ค้นหาเว็บ · พบ {count}",
|
|
|
|
| 2086 |
"progress_search_empty": "ค้นหาไม่พบอะไร",
|
| 2087 |
"progress_thinking": "กำลังคิด… {sec} วิ",
|
| 2088 |
"progress_reading_link": "กำลังอ่านลิงก์",
|
|
@@ -2191,6 +2210,7 @@ PACK_FA: dict[str, str] = {
|
|
| 2191 |
"status_listening": "گوش میدهم.",
|
| 2192 |
"progress_searching": "در اینترنت جستجو میکنم",
|
| 2193 |
"progress_search_done": "جستجوی وب · {count} یافت شد",
|
|
|
|
| 2194 |
"progress_search_empty": "جستجو چیزی پیدا نکرد",
|
| 2195 |
"progress_thinking": "در حال فکر کردن… {sec} ث",
|
| 2196 |
"progress_reading_link": "در حال خواندن پیوند",
|
|
@@ -2299,6 +2319,7 @@ PACK_UR: dict[str, str] = {
|
|
| 2299 |
"status_listening": "سن رہا ہوں۔",
|
| 2300 |
"progress_searching": "انٹرنیٹ پر تلاش کر رہا ہوں",
|
| 2301 |
"progress_search_done": "ویب تلاش · {count} ملے",
|
|
|
|
| 2302 |
"progress_search_empty": "تلاش میں کچھ نہیں ملا",
|
| 2303 |
"progress_thinking": "سوچ رہا ہوں… {sec} س",
|
| 2304 |
"progress_reading_link": "لنک پڑھ رہا ہوں",
|
|
@@ -2407,6 +2428,7 @@ PACK_UZ: dict[str, str] = {
|
|
| 2407 |
"status_listening": "Eshityapman.",
|
| 2408 |
"progress_searching": "Internetda qidiryapman",
|
| 2409 |
"progress_search_done": "Veb qidiruv · {count} topildi",
|
|
|
|
| 2410 |
"progress_search_empty": "Qidiruv hech narsa topmadi",
|
| 2411 |
"progress_thinking": "O'ylayapman… {sec} s",
|
| 2412 |
"progress_reading_link": "Havolani o'qiyapman",
|
|
@@ -2515,6 +2537,7 @@ PACK_BN: dict[str, str] = {
|
|
| 2515 |
"status_listening": "শুনছি।",
|
| 2516 |
"progress_searching": "ইন্টারনেটে খুঁজছি",
|
| 2517 |
"progress_search_done": "ওয়েব অনুসন্ধান · {count}টি পাওয়া গেছে",
|
|
|
|
| 2518 |
"progress_search_empty": "অনুসন্ধানে কিছু পাওয়া যায়নি",
|
| 2519 |
"progress_thinking": "ভাবছি… {sec} সেকেন্ড",
|
| 2520 |
"progress_reading_link": "লিংক পড়ছি",
|
|
@@ -2623,6 +2646,7 @@ PACK_FIL: dict[str, str] = {
|
|
| 2623 |
"status_listening": "Nakikinig.",
|
| 2624 |
"progress_searching": "Naghahanap sa internet",
|
| 2625 |
"progress_search_done": "Web search · {count} ang nakita",
|
|
|
|
| 2626 |
"progress_search_empty": "Walang nahanap ang paghahanap",
|
| 2627 |
"progress_thinking": "Nag-iisip… {sec} seg",
|
| 2628 |
"progress_reading_link": "Binabasa ang link",
|
|
@@ -2731,6 +2755,7 @@ PACK_PL: dict[str, str] = {
|
|
| 2731 |
"status_listening": "Słucham.",
|
| 2732 |
"progress_searching": "Szukam w internecie",
|
| 2733 |
"progress_search_done": "Wyszukiwanie w sieci · znaleziono {count}",
|
|
|
|
| 2734 |
"progress_search_empty": "Wyszukiwanie nic nie dało",
|
| 2735 |
"progress_thinking": "Myślę… {sec} s",
|
| 2736 |
"progress_reading_link": "Czytam link",
|
|
@@ -2839,6 +2864,7 @@ PACK_NL: dict[str, str] = {
|
|
| 2839 |
"status_listening": "Ik luister.",
|
| 2840 |
"progress_searching": "Ik zoek op internet",
|
| 2841 |
"progress_search_done": "Webzoekopdracht · {count} gevonden",
|
|
|
|
| 2842 |
"progress_search_empty": "De zoekopdracht leverde niets op",
|
| 2843 |
"progress_thinking": "Ik denk na… {sec} s",
|
| 2844 |
"progress_reading_link": "Ik lees de link",
|
|
@@ -2947,6 +2973,7 @@ PACK_ZU: dict[str, str] = {
|
|
| 2947 |
"status_listening": "Ngilalele.",
|
| 2948 |
"progress_searching": "Ngisesha kuyi-inthanethi",
|
| 2949 |
"progress_search_done": "Ukusesha kuwebhu · kutholwe {count}",
|
|
|
|
| 2950 |
"progress_search_empty": "Ukusesha akutholanga lutho",
|
| 2951 |
"progress_thinking": "Ngicabanga… {sec} s",
|
| 2952 |
"progress_reading_link": "Ngifunda isixhumanisi",
|
|
|
|
| 866 |
"ru": "Пробую ещё раз",
|
| 867 |
"uk": "Пробую ще раз",
|
| 868 |
},
|
| 869 |
+
"progress_thought_for": {
|
| 870 |
+
"be": "Думаў {sec} с",
|
| 871 |
+
"en": "Thought for {sec}s",
|
| 872 |
+
"es": "Pensé durante {sec} s",
|
| 873 |
+
"kk": "Ойлауға {sec} с кетті",
|
| 874 |
+
"ru": "Думал {sec} с",
|
| 875 |
+
"uk": "Думав {sec} с",
|
| 876 |
+
},
|
| 877 |
# ── Кнопки-уточнения: служебные реплики ──
|
| 878 |
"pick_suffix": {
|
| 879 |
"be": "\n\n(або проста напішы тэкстам)",
|
|
|
|
| 1011 |
"status_listening": "Ouvindo.",
|
| 1012 |
"progress_searching": "Pesquisando na internet",
|
| 1013 |
"progress_search_done": "Pesquisa na internet · encontrei {count}",
|
| 1014 |
+
"progress_thought_for": "Pensei por {sec}s",
|
| 1015 |
"progress_search_empty": "A pesquisa não encontrou nada",
|
| 1016 |
"progress_thinking": "Pensando… {sec}s",
|
| 1017 |
"progress_reading_link": "Lendo o link",
|
|
|
|
| 1120 |
"status_listening": "أستمع.",
|
| 1121 |
"progress_searching": "أبحث في الإنترنت",
|
| 1122 |
"progress_search_done": "بحث في الإنترنت · وجدت {count}",
|
| 1123 |
+
"progress_thought_for": "فكرت لمدة {sec} ث",
|
| 1124 |
"progress_search_empty": "لم يجد البحث شيئًا",
|
| 1125 |
"progress_thinking": "أفكر… {sec} ث",
|
| 1126 |
"progress_reading_link": "أقرأ الرابط",
|
|
|
|
| 1229 |
"status_listening": "Dinliyorum.",
|
| 1230 |
"progress_searching": "İnternette arıyorum",
|
| 1231 |
"progress_search_done": "İnternet araması · {count} bulundu",
|
| 1232 |
+
"progress_thought_for": "{sec} sn düşündüm",
|
| 1233 |
"progress_search_empty": "Arama hiçbir şey bulamadı",
|
| 1234 |
"progress_thinking": "Düşünüyorum… {sec} sn",
|
| 1235 |
"progress_reading_link": "Bağlantıyı okuyorum",
|
|
|
|
| 1338 |
"status_listening": "Ich höre zu.",
|
| 1339 |
"progress_searching": "Ich suche im Internet",
|
| 1340 |
"progress_search_done": "Internetsuche · {count} gefunden",
|
| 1341 |
+
"progress_thought_for": "{sec} s nachgedacht",
|
| 1342 |
"progress_search_empty": "Die Suche ergab nichts",
|
| 1343 |
"progress_thinking": "Ich denke nach… {sec} s",
|
| 1344 |
"progress_reading_link": "Ich lese den Link",
|
|
|
|
| 1447 |
"status_listening": "J'écoute.",
|
| 1448 |
"progress_searching": "Je cherche sur internet",
|
| 1449 |
"progress_search_done": "Recherche web · {count} trouvés",
|
| 1450 |
+
"progress_thought_for": "Réflexion pendant {sec} s",
|
| 1451 |
"progress_search_empty": "La recherche n'a rien donné",
|
| 1452 |
"progress_thinking": "Je réfléchis… {sec} s",
|
| 1453 |
"progress_reading_link": "Je lis le lien",
|
|
|
|
| 1556 |
"status_listening": "Ti ascolto.",
|
| 1557 |
"progress_searching": "Cerco su internet",
|
| 1558 |
"progress_search_done": "Ricerca web · {count} trovati",
|
| 1559 |
+
"progress_thought_for": "Ho pensato per {sec} s",
|
| 1560 |
"progress_search_empty": "La ricerca non ha trovato nulla",
|
| 1561 |
"progress_thinking": "Sto pensando… {sec} s",
|
| 1562 |
"progress_reading_link": "Sto leggendo il link",
|
|
|
|
| 1665 |
"status_listening": "सुन रहा हूं।",
|
| 1666 |
"progress_searching": "इंटरनेट पर खोज रहा हूँ",
|
| 1667 |
"progress_search_done": "वेब खोज · {count} मिले",
|
| 1668 |
+
"progress_thought_for": "{sec} सेकंड सोचा",
|
| 1669 |
"progress_search_empty": "खोज में कुछ नहीं मिला",
|
| 1670 |
"progress_thinking": "सोच रहा हूँ… {sec} से",
|
| 1671 |
"progress_reading_link": "लिंक पढ़ रहा हूँ",
|
|
|
|
| 1774 |
"status_listening": "Mendengarkan.",
|
| 1775 |
"progress_searching": "Mencari di internet",
|
| 1776 |
"progress_search_done": "Pencarian web · {count} ditemukan",
|
| 1777 |
+
"progress_thought_for": "Berpikir {sec} dtk",
|
| 1778 |
"progress_search_empty": "Pencarian tidak menemukan apa pun",
|
| 1779 |
"progress_thinking": "Berpikir… {sec} dtk",
|
| 1780 |
"progress_reading_link": "Membaca tautan",
|
|
|
|
| 1883 |
"status_listening": "Mendengar.",
|
| 1884 |
"progress_searching": "Mencari di internet",
|
| 1885 |
"progress_search_done": "Carian web · {count} ditemui",
|
| 1886 |
+
"progress_thought_for": "Berfikir {sec} saat",
|
| 1887 |
"progress_search_empty": "Carian tidak menemui apa-apa",
|
| 1888 |
"progress_thinking": "Berfikir… {sec} saat",
|
| 1889 |
"progress_reading_link": "Membaca pautan",
|
|
|
|
| 1992 |
"status_listening": "Đang nghe.",
|
| 1993 |
"progress_searching": "Đang tìm trên internet",
|
| 1994 |
"progress_search_done": "Tìm kiếm web · tìm thấy {count}",
|
| 1995 |
+
"progress_thought_for": "Đã suy nghĩ {sec} giây",
|
| 1996 |
"progress_search_empty": "Tìm kiếm không có kết quả",
|
| 1997 |
"progress_thinking": "Đang suy nghĩ… {sec} giây",
|
| 1998 |
"progress_reading_link": "Đang đọc liên kết",
|
|
|
|
| 2101 |
"status_listening": "กำลังฟัง",
|
| 2102 |
"progress_searching": "กำลังค้นหาในอินเทอร์เน็ต",
|
| 2103 |
"progress_search_done": "ค้นหาเว็บ · พบ {count}",
|
| 2104 |
+
"progress_thought_for": "คิดเป็นเวลา {sec} วิ",
|
| 2105 |
"progress_search_empty": "ค้นหาไม่พบอะไร",
|
| 2106 |
"progress_thinking": "กำลังคิด… {sec} วิ",
|
| 2107 |
"progress_reading_link": "กำลังอ่านลิงก์",
|
|
|
|
| 2210 |
"status_listening": "گوش میدهم.",
|
| 2211 |
"progress_searching": "در اینترنت جستجو میکنم",
|
| 2212 |
"progress_search_done": "جستجوی وب · {count} یافت شد",
|
| 2213 |
+
"progress_thought_for": "{sec} ثانیه فکر کردم",
|
| 2214 |
"progress_search_empty": "جستجو چیزی پیدا نکرد",
|
| 2215 |
"progress_thinking": "در حال فکر کردن… {sec} ث",
|
| 2216 |
"progress_reading_link": "در حال خواندن پیوند",
|
|
|
|
| 2319 |
"status_listening": "سن رہا ہوں۔",
|
| 2320 |
"progress_searching": "انٹرنیٹ پر تلاش کر رہا ہوں",
|
| 2321 |
"progress_search_done": "ویب تلاش · {count} ملے",
|
| 2322 |
+
"progress_thought_for": "{sec} سیکنڈ سوچا",
|
| 2323 |
"progress_search_empty": "تلاش میں کچھ نہیں ملا",
|
| 2324 |
"progress_thinking": "سوچ رہا ہوں… {sec} س",
|
| 2325 |
"progress_reading_link": "لنک پڑھ رہا ہوں",
|
|
|
|
| 2428 |
"status_listening": "Eshityapman.",
|
| 2429 |
"progress_searching": "Internetda qidiryapman",
|
| 2430 |
"progress_search_done": "Veb qidiruv · {count} topildi",
|
| 2431 |
+
"progress_thought_for": "{sec} soniya o'yladim",
|
| 2432 |
"progress_search_empty": "Qidiruv hech narsa topmadi",
|
| 2433 |
"progress_thinking": "O'ylayapman… {sec} s",
|
| 2434 |
"progress_reading_link": "Havolani o'qiyapman",
|
|
|
|
| 2537 |
"status_listening": "শুনছি।",
|
| 2538 |
"progress_searching": "ইন্টারনেটে খুঁজছি",
|
| 2539 |
"progress_search_done": "ওয়েব অনুসন্ধান · {count}টি পাওয়া গেছে",
|
| 2540 |
+
"progress_thought_for": "{sec} সেকেন্ড ভেবেছি",
|
| 2541 |
"progress_search_empty": "অনুসন্ধানে কিছু পাওয়া যায়নি",
|
| 2542 |
"progress_thinking": "ভাবছি… {sec} সেকেন্ড",
|
| 2543 |
"progress_reading_link": "লিংক পড়ছি",
|
|
|
|
| 2646 |
"status_listening": "Nakikinig.",
|
| 2647 |
"progress_searching": "Naghahanap sa internet",
|
| 2648 |
"progress_search_done": "Web search · {count} ang nakita",
|
| 2649 |
+
"progress_thought_for": "Nag-isip ng {sec} seg",
|
| 2650 |
"progress_search_empty": "Walang nahanap ang paghahanap",
|
| 2651 |
"progress_thinking": "Nag-iisip… {sec} seg",
|
| 2652 |
"progress_reading_link": "Binabasa ang link",
|
|
|
|
| 2755 |
"status_listening": "Słucham.",
|
| 2756 |
"progress_searching": "Szukam w internecie",
|
| 2757 |
"progress_search_done": "Wyszukiwanie w sieci · znaleziono {count}",
|
| 2758 |
+
"progress_thought_for": "Myślałem przez {sec} s",
|
| 2759 |
"progress_search_empty": "Wyszukiwanie nic nie dało",
|
| 2760 |
"progress_thinking": "Myślę… {sec} s",
|
| 2761 |
"progress_reading_link": "Czytam link",
|
|
|
|
| 2864 |
"status_listening": "Ik luister.",
|
| 2865 |
"progress_searching": "Ik zoek op internet",
|
| 2866 |
"progress_search_done": "Webzoekopdracht · {count} gevonden",
|
| 2867 |
+
"progress_thought_for": "Ik dacht {sec} s na",
|
| 2868 |
"progress_search_empty": "De zoekopdracht leverde niets op",
|
| 2869 |
"progress_thinking": "Ik denk na… {sec} s",
|
| 2870 |
"progress_reading_link": "Ik lees de link",
|
|
|
|
| 2973 |
"status_listening": "Ngilalele.",
|
| 2974 |
"progress_searching": "Ngisesha kuyi-inthanethi",
|
| 2975 |
"progress_search_done": "Ukusesha kuwebhu · kutholwe {count}",
|
| 2976 |
+
"progress_thought_for": "Ngicabange imizuzwana engu-{sec}",
|
| 2977 |
"progress_search_empty": "Ukusesha akutholanga lutho",
|
| 2978 |
"progress_thinking": "Ngicabanga… {sec} s",
|
| 2979 |
"progress_reading_link": "Ngifunda isixhumanisi",
|
lumen_message_core.py
CHANGED
|
@@ -14,8 +14,9 @@ from datetime import datetime
|
|
| 14 |
from typing import Any
|
| 15 |
from zoneinfo import ZoneInfo
|
| 16 |
|
| 17 |
-
from aiogram.enums import ChatType
|
| 18 |
from aiogram.types import Message, Update
|
|
|
|
| 19 |
|
| 20 |
from lumen_message_parse import (
|
| 21 |
DRAW_TRIGGER_PREFIXES,
|
|
@@ -782,6 +783,7 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
|
|
| 782 |
if status is not None:
|
| 783 |
if snippets:
|
| 784 |
status.add_done("progress_search_done", count=len(snippets))
|
|
|
|
| 785 |
else:
|
| 786 |
status.add_done("progress_search_empty")
|
| 787 |
await status.set_phase("progress_thinking", timed=True)
|
|
@@ -875,8 +877,17 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
|
|
| 875 |
ans = note_text + "\n\n" + ans
|
| 876 |
if reply_already_sent and status is not None:
|
| 877 |
# Ответ уже в статус-пузыре (стрим или переиспользование) —
|
| 878 |
-
# пометку дописываем туда же
|
| 879 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
if not reply_already_sent:
|
| 881 |
await bot._safe_reply(message, ans)
|
| 882 |
# Частичный стрим тоже «успешен» для вызывающего, но его пометка
|
|
|
|
| 14 |
from typing import Any
|
| 15 |
from zoneinfo import ZoneInfo
|
| 16 |
|
| 17 |
+
from aiogram.enums import ChatType, ParseMode
|
| 18 |
from aiogram.types import Message, Update
|
| 19 |
+
from urllib.parse import urlparse
|
| 20 |
|
| 21 |
from lumen_message_parse import (
|
| 22 |
DRAW_TRIGGER_PREFIXES,
|
|
|
|
| 783 |
if status is not None:
|
| 784 |
if snippets:
|
| 785 |
status.add_done("progress_search_done", count=len(snippets))
|
| 786 |
+
status.set_sources([urlparse(item.get("url") or "").netloc for item in snippets])
|
| 787 |
else:
|
| 788 |
status.add_done("progress_search_empty")
|
| 789 |
await status.set_phase("progress_thinking", timed=True)
|
|
|
|
| 877 |
ans = note_text + "\n\n" + ans
|
| 878 |
if reply_already_sent and status is not None:
|
| 879 |
# Ответ уже в статус-пузыре (стрим или переиспользование) —
|
| 880 |
+
# пометку дописываем туда же вместе со свёрнутым блоком активности.
|
| 881 |
+
html_text, plain_text = status.compose_answer(ans)
|
| 882 |
+
if html_text is not None:
|
| 883 |
+
edited = await bot._tg_call(
|
| 884 |
+
status.message.edit_text, html_text,
|
| 885 |
+
parse_mode=ParseMode.HTML, call_timeout=15.0,
|
| 886 |
+
)
|
| 887 |
+
if edited is None:
|
| 888 |
+
await bot._tg_call(status.message.edit_text, plain_text, parse_mode=None, call_timeout=15.0)
|
| 889 |
+
else:
|
| 890 |
+
await bot._edit_message_quietly(status.message, ans)
|
| 891 |
if not reply_already_sent:
|
| 892 |
await bot._safe_reply(message, ans)
|
| 893 |
# Частичный стрим тоже «успешен» для вызывающего, но его пометка
|
lumen_routes.py
CHANGED
|
@@ -16,6 +16,7 @@ from datetime import date, datetime
|
|
| 16 |
from typing import Any, Callable
|
| 17 |
|
| 18 |
import aiohttp
|
|
|
|
| 19 |
from aiogram.types import Message
|
| 20 |
from google.genai import types
|
| 21 |
|
|
@@ -1101,13 +1102,29 @@ async def _run_route(
|
|
| 1101 |
_note_answered("openrouter", None)
|
| 1102 |
|
| 1103 |
if status is not None:
|
| 1104 |
-
#
|
| 1105 |
-
# перезапишет готовый текст.
|
| 1106 |
-
await status.
|
| 1107 |
if reusable_placeholder is not None:
|
| 1108 |
# Готовый ответ — прямо в плейсхолдер (если влезает в одно сообщение), иначе обычным путём с разбиением.
|
| 1109 |
fits_one_message = len(_split_text_chunks(ans, bot.TG_MAX_LEN)) == 1
|
| 1110 |
-
reused =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1111 |
if not reused:
|
| 1112 |
await bot._delete_message_quietly(reusable_placeholder)
|
| 1113 |
reusable_placeholder = None
|
|
|
|
| 16 |
from typing import Any, Callable
|
| 17 |
|
| 18 |
import aiohttp
|
| 19 |
+
from aiogram.enums import ParseMode
|
| 20 |
from aiogram.types import Message
|
| 21 |
from google.genai import types
|
| 22 |
|
|
|
|
| 1102 |
_note_answered("openrouter", None)
|
| 1103 |
|
| 1104 |
if status is not None:
|
| 1105 |
+
# Фиксируем длительность ожидания и останавливаем анимацию до
|
| 1106 |
+
# записи ответа, иначе тикер перезапишет готовый текст.
|
| 1107 |
+
await status.finish()
|
| 1108 |
if reusable_placeholder is not None:
|
| 1109 |
# Готовый ответ — прямо в плейсхолдер (если влезает в одно сообщение), иначе обычным путём с разбиением.
|
| 1110 |
fits_one_message = len(_split_text_chunks(ans, bot.TG_MAX_LEN)) == 1
|
| 1111 |
+
reused = False
|
| 1112 |
+
if fits_one_message:
|
| 1113 |
+
html_text, plain_text = status.compose_answer(ans) if status is not None else (None, ans)
|
| 1114 |
+
if html_text is not None:
|
| 1115 |
+
edited = await bot._tg_call(
|
| 1116 |
+
reusable_placeholder.edit_text, html_text,
|
| 1117 |
+
parse_mode=ParseMode.HTML, call_timeout=15.0,
|
| 1118 |
+
)
|
| 1119 |
+
reused = edited is not None
|
| 1120 |
+
if not reused:
|
| 1121 |
+
edited = await bot._tg_call(
|
| 1122 |
+
reusable_placeholder.edit_text, plain_text,
|
| 1123 |
+
parse_mode=None, call_timeout=15.0,
|
| 1124 |
+
)
|
| 1125 |
+
reused = edited is not None
|
| 1126 |
+
else:
|
| 1127 |
+
reused = await bot._edit_message_quietly(reusable_placeholder, ans)
|
| 1128 |
if not reused:
|
| 1129 |
await bot._delete_message_quietly(reusable_placeholder)
|
| 1130 |
reusable_placeholder = None
|
lumen_status.py
CHANGED
|
@@ -1,35 +1,47 @@
|
|
| 1 |
-
"""lumen_status.py — живой статус-плейсхолдер
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import asyncio
|
| 5 |
import contextlib
|
| 6 |
-
import
|
| 7 |
import time
|
| 8 |
|
| 9 |
from aiogram.types import Message
|
| 10 |
|
|
|
|
| 11 |
from lumen_lang import t as _lang_t
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
_PROGRESS_FRAMES = (".", "..", "…")
|
| 16 |
# Страховка от утечки тикера, если обработчик упал между созданием статуса и
|
| 17 |
# маршрутом: дольше этого статус сам себя останавливает.
|
| 18 |
_STATUS_MAX_LIFETIME_SEC = 300.0
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
class StatusMessage:
|
| 22 |
-
"""Сообщение-статус: завершённые этапы + текущая фраза с
|
| 23 |
Все правки глотают ошибки: удалённое сообщение не должно ломать ответ."""
|
| 24 |
|
| 25 |
def __init__(self, message: Message, lang: str):
|
| 26 |
self.message = message
|
| 27 |
self.lang = lang
|
| 28 |
self._done: list[str] = []
|
|
|
|
| 29 |
self._phrase_key = ""
|
| 30 |
self._timed = False
|
| 31 |
self._phase_started = time.monotonic()
|
| 32 |
self._created = time.monotonic()
|
|
|
|
| 33 |
self._task: asyncio.Task | None = None
|
| 34 |
self._stopped = False
|
| 35 |
|
|
@@ -50,8 +62,12 @@ class StatusMessage:
|
|
| 50 |
return status
|
| 51 |
|
| 52 |
def add_done(self, key: str, **kwargs: object) -> None:
|
| 53 |
-
"""Завершённый этап отдельной строкой
|
| 54 |
-
self._done.append(_lang_t(self.lang, key, **kwargs))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
async def set_phase(self, key: str, *, timed: bool = False) -> None:
|
| 57 |
"""Смена текущей фразы с немедленной правкой, не дожидаясь тика."""
|
|
@@ -60,18 +76,49 @@ class StatusMessage:
|
|
| 60 |
with contextlib.suppress(Exception):
|
| 61 |
await bot._tg_call(self.message.edit_text, self._render(0), parse_mode=None, call_timeout=15.0)
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
def _set_phrase(self, key: str, *, timed: bool) -> None:
|
| 64 |
self._phrase_key = key
|
| 65 |
self._timed = timed
|
| 66 |
self._phase_started = time.monotonic()
|
| 67 |
|
| 68 |
def _render(self, frame_idx: int) -> str:
|
|
|
|
| 69 |
lines = list(self._done)
|
| 70 |
if self._timed:
|
| 71 |
elapsed = max(0, int(time.monotonic() - self._phase_started))
|
| 72 |
-
lines.append(_lang_t(self.lang, self._phrase_key, sec=elapsed))
|
| 73 |
else:
|
| 74 |
-
lines.append(_lang_t(self.lang, self._phrase_key)
|
| 75 |
return "\n".join(lines)
|
| 76 |
|
| 77 |
async def _tick(self) -> None:
|
|
|
|
| 1 |
+
"""lumen_status.py — живой статус-плейсхолдер в духе Claude: анимированная искра, этапы, «Думал N с» и свёрнутый блок активности."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import asyncio
|
| 5 |
import contextlib
|
| 6 |
+
import html
|
| 7 |
import time
|
| 8 |
|
| 9 |
from aiogram.types import Message
|
| 10 |
|
| 11 |
+
from lumen_formatting import _is_table_separator, _md_to_html
|
| 12 |
from lumen_lang import t as _lang_t
|
| 13 |
|
| 14 |
+
# Искры как в Claude Code: кадры спиннера, а не точки.
|
| 15 |
+
_SPINNER_FRAMES = ("·", "✢", "✳", "✶", "✻", "✽")
|
|
|
|
| 16 |
# Страховка от утечки тикера, если обработчик упал между созданием статуса и
|
| 17 |
# маршрутом: дольше этого статус сам себя останавливает.
|
| 18 |
_STATUS_MAX_LIFETIME_SEC = 300.0
|
| 19 |
|
| 20 |
|
| 21 |
+
def thought_block(lang: str, sec: int, answer_md: str) -> tuple[str | None, str]:
|
| 22 |
+
"""Свёрнутый блок «✻ Thought for Ns» над ответом: (HTML, простой текст).
|
| 23 |
+
Таблицы возвращают None — их показываем обычным rich-путём без блока."""
|
| 24 |
+
if any(_is_table_separator(line) for line in answer_md.splitlines()):
|
| 25 |
+
return None, answer_md
|
| 26 |
+
line = "✻ " + _lang_t(lang, "progress_thought_for", sec=max(0, int(sec)))
|
| 27 |
+
html_block = "<blockquote expandable>" + html.escape(line) + "</blockquote>\n\n" + _md_to_html(answer_md)
|
| 28 |
+
return html_block, line + "\n\n" + answer_md
|
| 29 |
+
|
| 30 |
+
|
| 31 |
class StatusMessage:
|
| 32 |
+
"""Сообщение-статус: завершённые этапы + текущая фраза с искрой и таймером.
|
| 33 |
Все правки глотают ошибки: удалённое сообщение не должно ломать ответ."""
|
| 34 |
|
| 35 |
def __init__(self, message: Message, lang: str):
|
| 36 |
self.message = message
|
| 37 |
self.lang = lang
|
| 38 |
self._done: list[str] = []
|
| 39 |
+
self._sources: list[str] = []
|
| 40 |
self._phrase_key = ""
|
| 41 |
self._timed = False
|
| 42 |
self._phase_started = time.monotonic()
|
| 43 |
self._created = time.monotonic()
|
| 44 |
+
self._thought_seconds: int | None = None
|
| 45 |
self._task: asyncio.Task | None = None
|
| 46 |
self._stopped = False
|
| 47 |
|
|
|
|
| 62 |
return status
|
| 63 |
|
| 64 |
def add_done(self, key: str, **kwargs: object) -> None:
|
| 65 |
+
"""Завершённый этап отдельной строкой с галочкой, как в трейсе Claude."""
|
| 66 |
+
self._done.append("✓ " + _lang_t(self.lang, key, **kwargs))
|
| 67 |
+
|
| 68 |
+
def set_sources(self, hosts: list[str]) -> None:
|
| 69 |
+
"""Домены найденных источников для раскрытого блока активности."""
|
| 70 |
+
self._sources = [h for h in hosts if h][:5]
|
| 71 |
|
| 72 |
async def set_phase(self, key: str, *, timed: bool = False) -> None:
|
| 73 |
"""Смена текущей фразы с немедленной правкой, не дожидаясь тика."""
|
|
|
|
| 76 |
with contextlib.suppress(Exception):
|
| 77 |
await bot._tg_call(self.message.edit_text, self._render(0), parse_mode=None, call_timeout=15.0)
|
| 78 |
|
| 79 |
+
async def finish(self) -> None:
|
| 80 |
+
"""Фиксирует длительность ожидания и останавливает анимацию."""
|
| 81 |
+
if self._thought_seconds is None:
|
| 82 |
+
self._thought_seconds = max(0, int(time.monotonic() - self._phase_started))
|
| 83 |
+
await self.stop()
|
| 84 |
+
|
| 85 |
+
def activity_prefix(self) -> tuple[str, str]:
|
| 86 |
+
"""(HTML-блок для ответа, тот же блок простым текстом); пусто — активности нет."""
|
| 87 |
+
thought = self._thought_seconds if self._thought_seconds is not None else max(
|
| 88 |
+
0, int(time.monotonic() - self._phase_started))
|
| 89 |
+
lines = ["✻ " + _lang_t(self.lang, "progress_thought_for", sec=thought)]
|
| 90 |
+
lines.extend(self._done)
|
| 91 |
+
if self._phrase_key and not self._timed and self._phrase_key != "progress_thinking":
|
| 92 |
+
lines.append("✓ " + _lang_t(self.lang, self._phrase_key))
|
| 93 |
+
if self._sources:
|
| 94 |
+
lines.extend("· " + host for host in self._sources)
|
| 95 |
+
plain = "\n".join(lines)
|
| 96 |
+
html_block = "<blockquote expandable>" + "\n".join(html.escape(line) for line in lines) + "</blockquote>"
|
| 97 |
+
return html_block, plain
|
| 98 |
+
|
| 99 |
+
def compose_answer(self, answer_md: str) -> tuple[str | None, str]:
|
| 100 |
+
"""Ответ с свёрнутым блоком активности: (HTML для parse_mode=HTML, простой текст).
|
| 101 |
+
Таблицы отдаём обычным путём (rich-сообщение): цитата сломала бы их разметку."""
|
| 102 |
+
if any(_is_table_separator(line) for line in answer_md.splitlines()):
|
| 103 |
+
return None, answer_md
|
| 104 |
+
html_block, plain_block = self.activity_prefix()
|
| 105 |
+
if not html_block:
|
| 106 |
+
return None, answer_md
|
| 107 |
+
return html_block + "\n\n" + _md_to_html(answer_md), plain_block + "\n\n" + answer_md
|
| 108 |
+
|
| 109 |
def _set_phrase(self, key: str, *, timed: bool) -> None:
|
| 110 |
self._phrase_key = key
|
| 111 |
self._timed = timed
|
| 112 |
self._phase_started = time.monotonic()
|
| 113 |
|
| 114 |
def _render(self, frame_idx: int) -> str:
|
| 115 |
+
spark = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
|
| 116 |
lines = list(self._done)
|
| 117 |
if self._timed:
|
| 118 |
elapsed = max(0, int(time.monotonic() - self._phase_started))
|
| 119 |
+
lines.append(spark + " " + _lang_t(self.lang, self._phrase_key, sec=elapsed))
|
| 120 |
else:
|
| 121 |
+
lines.append(spark + " " + _lang_t(self.lang, self._phrase_key))
|
| 122 |
return "\n".join(lines)
|
| 123 |
|
| 124 |
async def _tick(self) -> None:
|
lumen_streaming.py
CHANGED
|
@@ -10,11 +10,12 @@ import logging
|
|
| 10 |
import time
|
| 11 |
from typing import Any
|
| 12 |
import aiohttp
|
| 13 |
-
from aiogram.enums import ParseMode
|
| 14 |
from aiogram.types import Message
|
| 15 |
|
| 16 |
from lumen_formatting import _md_to_html, _split_text_chunks
|
| 17 |
from lumen_lang import t as _lang_t
|
|
|
|
| 18 |
from lumen_message_parse import _history_user_text
|
| 19 |
from lumen_model_speed import (
|
| 20 |
speed_key as _model_speed_key,
|
|
@@ -50,13 +51,16 @@ async def _tick_waiting_dots(placeholder: Message) -> None:
|
|
| 50 |
await bot._dots_sleep(bot._DOTS_START_AFTER_SEC)
|
| 51 |
chat = getattr(placeholder, "chat", None)
|
| 52 |
lang = bot._chat_lang(getattr(chat, "id", None))
|
|
|
|
| 53 |
while True:
|
| 54 |
elapsed = max(0, int(time.monotonic() - started))
|
|
|
|
| 55 |
with contextlib.suppress(Exception):
|
| 56 |
await bot._tg_call(
|
| 57 |
-
placeholder.edit_text, _lang_t(lang, "progress_thinking", sec=elapsed),
|
| 58 |
parse_mode=None, call_timeout=15.0,
|
| 59 |
)
|
|
|
|
| 60 |
await bot._dots_sleep(bot._DOTS_TICK_SEC)
|
| 61 |
except asyncio.CancelledError:
|
| 62 |
raise
|
|
@@ -84,8 +88,9 @@ async def _pieces_with_waiting_feedback(piece_agen, placeholder: Message, *, fir
|
|
| 84 |
with contextlib.suppress(asyncio.CancelledError):
|
| 85 |
await dots_task
|
| 86 |
if status is not None:
|
| 87 |
-
# Первый кусок пришёл:
|
| 88 |
-
|
|
|
|
| 89 |
yield first
|
| 90 |
async for piece in piece_agen:
|
| 91 |
yield piece
|
|
@@ -268,7 +273,7 @@ async def _run_streaming_reply(
|
|
| 268 |
placeholder = status.message
|
| 269 |
else:
|
| 270 |
placeholder = await bot._tg_call(
|
| 271 |
-
message.reply, bot._t(message.chat.id, "progress_thinking", sec=0),
|
| 272 |
parse_mode=None, call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT,
|
| 273 |
)
|
| 274 |
if placeholder is None:
|
|
@@ -417,6 +422,28 @@ async def _run_streaming_reply(
|
|
| 417 |
# досылаем новым сообщением (_send_text внутри режет на части сам),
|
| 418 |
# иначе пользователь его не увидит.
|
| 419 |
await bot._safe_reply(message, full_text.strip())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
else:
|
| 421 |
# HTML->plain fallback — внутри _edit_message_quietly (раньше дублировался здесь вручную).
|
| 422 |
await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
|
|
|
|
| 10 |
import time
|
| 11 |
from typing import Any
|
| 12 |
import aiohttp
|
| 13 |
+
from aiogram.enums import ChatType, ParseMode
|
| 14 |
from aiogram.types import Message
|
| 15 |
|
| 16 |
from lumen_formatting import _md_to_html, _split_text_chunks
|
| 17 |
from lumen_lang import t as _lang_t
|
| 18 |
+
from lumen_status import _SPINNER_FRAMES, thought_block
|
| 19 |
from lumen_message_parse import _history_user_text
|
| 20 |
from lumen_model_speed import (
|
| 21 |
speed_key as _model_speed_key,
|
|
|
|
| 51 |
await bot._dots_sleep(bot._DOTS_START_AFTER_SEC)
|
| 52 |
chat = getattr(placeholder, "chat", None)
|
| 53 |
lang = bot._chat_lang(getattr(chat, "id", None))
|
| 54 |
+
frame_idx = 0
|
| 55 |
while True:
|
| 56 |
elapsed = max(0, int(time.monotonic() - started))
|
| 57 |
+
spark = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
|
| 58 |
with contextlib.suppress(Exception):
|
| 59 |
await bot._tg_call(
|
| 60 |
+
placeholder.edit_text, spark + " " + _lang_t(lang, "progress_thinking", sec=elapsed),
|
| 61 |
parse_mode=None, call_timeout=15.0,
|
| 62 |
)
|
| 63 |
+
frame_idx += 1
|
| 64 |
await bot._dots_sleep(bot._DOTS_TICK_SEC)
|
| 65 |
except asyncio.CancelledError:
|
| 66 |
raise
|
|
|
|
| 88 |
with contextlib.suppress(asyncio.CancelledError):
|
| 89 |
await dots_task
|
| 90 |
if status is not None:
|
| 91 |
+
# Первый кусок пришёл: фиксируем «Думал N с» и гасим анимацию —
|
| 92 |
+
# текст сейчас заменит статус.
|
| 93 |
+
await status.finish()
|
| 94 |
yield first
|
| 95 |
async for piece in piece_agen:
|
| 96 |
yield piece
|
|
|
|
| 273 |
placeholder = status.message
|
| 274 |
else:
|
| 275 |
placeholder = await bot._tg_call(
|
| 276 |
+
message.reply, _SPINNER_FRAMES[0] + " " + bot._t(message.chat.id, "progress_thinking", sec=0),
|
| 277 |
parse_mode=None, call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT,
|
| 278 |
)
|
| 279 |
if placeholder is None:
|
|
|
|
| 422 |
# досылаем новым сообщением (_send_text внутри режет на части сам),
|
| 423 |
# иначе пользователь его не увидит.
|
| 424 |
await bot._safe_reply(message, full_text.strip())
|
| 425 |
+
elif len(final_chunks) == 1 and (
|
| 426 |
+
status is not None or getattr(getattr(message, "chat", None), "type", None) == ChatType.PRIVATE
|
| 427 |
+
):
|
| 428 |
+
# Одно сообщение: сверху добавляем свёрнутый блок активности,
|
| 429 |
+
# как «Thought for Ns» в Claude. Длинный ответ не трогаем: заголовок
|
| 430 |
+
# не влез бы в лимит первого сообщения. Без раннего статуса (обычный
|
| 431 |
+
# текст) строим минимальный блок по времени до первого куска.
|
| 432 |
+
if status is not None:
|
| 433 |
+
html_text, plain_text = status.compose_answer(final_text)
|
| 434 |
+
else:
|
| 435 |
+
thought_sec = int(max(0.0, (first_piece_ts or overall_start_ts) - overall_start_ts))
|
| 436 |
+
html_text, plain_text = thought_block(bot._chat_lang(message.chat.id), thought_sec, final_text)
|
| 437 |
+
if html_text is None:
|
| 438 |
+
# Таблицы и прочий rich-контент: обычный путь без блока активности.
|
| 439 |
+
await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
|
| 440 |
+
else:
|
| 441 |
+
edited = await bot._tg_call(
|
| 442 |
+
sent_messages[-1].edit_text, html_text,
|
| 443 |
+
parse_mode=ParseMode.HTML, call_timeout=15.0,
|
| 444 |
+
)
|
| 445 |
+
if edited is None:
|
| 446 |
+
await bot._tg_call(sent_messages[-1].edit_text, plain_text, parse_mode=None, call_timeout=15.0)
|
| 447 |
else:
|
| 448 |
# HTML->plain fallback — внутри _edit_message_quietly (раньше дублировался здесь вручную).
|
| 449 |
await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
|
tests/test_bot_search.py
CHANGED
|
@@ -117,12 +117,16 @@ class _FakeStatus:
|
|
| 117 |
self.message = object()
|
| 118 |
self.done = []
|
| 119 |
self.phases = []
|
|
|
|
| 120 |
self.stopped = False
|
| 121 |
self.deleted = False
|
| 122 |
|
| 123 |
def add_done(self, key, **kwargs):
|
| 124 |
self.done.append((key, kwargs))
|
| 125 |
|
|
|
|
|
|
|
|
|
|
| 126 |
async def set_phase(self, key, *, timed=False):
|
| 127 |
self.phases.append((key, timed))
|
| 128 |
|
|
|
|
| 117 |
self.message = object()
|
| 118 |
self.done = []
|
| 119 |
self.phases = []
|
| 120 |
+
self.sources = []
|
| 121 |
self.stopped = False
|
| 122 |
self.deleted = False
|
| 123 |
|
| 124 |
def add_done(self, key, **kwargs):
|
| 125 |
self.done.append((key, kwargs))
|
| 126 |
|
| 127 |
+
def set_sources(self, hosts):
|
| 128 |
+
self.sources = list(hosts)
|
| 129 |
+
|
| 130 |
async def set_phase(self, key, *, timed=False):
|
| 131 |
self.phases.append((key, timed))
|
| 132 |
|
tests/test_bot_streaming.py
CHANGED
|
@@ -712,7 +712,9 @@ def test_waiting_dots_cycles_frames_then_stops_on_cancel(monkeypatch):
|
|
| 712 |
assert calls[0] == bot._DOTS_START_AFTER_SEC
|
| 713 |
assert calls[1:] == [bot._DOTS_TICK_SEC] * 3
|
| 714 |
assert len(placeholder.edits) == 3
|
| 715 |
-
|
|
|
|
|
|
|
| 716 |
# Код языка не должен утечь в chat_state строковым ключом (прод 28.10.2026).
|
| 717 |
assert "en" not in bot.chat_state
|
| 718 |
|
|
@@ -738,6 +740,30 @@ def test_streaming_fast_path_shows_no_dots_frames():
|
|
| 738 |
bot.chat_state.pop(chat_id, None)
|
| 739 |
|
| 740 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
|
| 742 |
# Живой статус уже в чате: стрим пишет ответ в него, второго «…» нет.
|
| 743 |
chat_id = 999320
|
|
@@ -745,10 +771,14 @@ def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
|
|
| 745 |
class _FakeStatus:
|
| 746 |
def __init__(self):
|
| 747 |
self.message = _FakeSentMessage()
|
| 748 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 749 |
|
| 750 |
-
|
| 751 |
-
|
|
|
|
| 752 |
|
| 753 |
async def pieces():
|
| 754 |
yield "Привет"
|
|
@@ -762,8 +792,10 @@ def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
|
|
| 762 |
))
|
| 763 |
assert answer == "Привет"
|
| 764 |
assert incoming.sent == [] # второй пузырь не отправлялся
|
| 765 |
-
assert status.
|
| 766 |
-
|
|
|
|
|
|
|
| 767 |
finally:
|
| 768 |
bot.chat_state.pop(chat_id, None)
|
| 769 |
|
|
|
|
| 712 |
assert calls[0] == bot._DOTS_START_AFTER_SEC
|
| 713 |
assert calls[1:] == [bot._DOTS_TICK_SEC] * 3
|
| 714 |
assert len(placeholder.edits) == 3
|
| 715 |
+
import lumen_status
|
| 716 |
+
assert all(text[0] in lumen_status._SPINNER_FRAMES and "Thinking" in text
|
| 717 |
+
for text, _ in placeholder.edits)
|
| 718 |
# Код языка не должен утечь в chat_state строковым ключом (прод 28.10.2026).
|
| 719 |
assert "en" not in bot.chat_state
|
| 720 |
|
|
|
|
| 740 |
bot.chat_state.pop(chat_id, None)
|
| 741 |
|
| 742 |
|
| 743 |
+
def test_streaming_plain_text_adds_collapsed_thought_block():
|
| 744 |
+
# Даже без раннего статуса обычный ответ получает свёрнутый «Thought for Ns»,
|
| 745 |
+
# как в Claude: вопрос не ждал bubble, но финальный вид — с трейсом.
|
| 746 |
+
chat_id = 999321
|
| 747 |
+
|
| 748 |
+
async def pieces():
|
| 749 |
+
yield "Привет"
|
| 750 |
+
|
| 751 |
+
incoming = _FakeIncomingMessage(chat_id)
|
| 752 |
+
try:
|
| 753 |
+
answer, _ = asyncio.run(bot._run_streaming_reply(
|
| 754 |
+
chat_id, "Привет!", incoming, provider="openrouter", model_id="y:free",
|
| 755 |
+
piece_agen=pieces(),
|
| 756 |
+
))
|
| 757 |
+
assert answer == "Привет"
|
| 758 |
+
final_text, parse_mode = incoming.sent[0].edits[-1]
|
| 759 |
+
assert parse_mode == bot.ParseMode.HTML
|
| 760 |
+
assert final_text.startswith("<blockquote expandable>")
|
| 761 |
+
assert "Thought for" in final_text
|
| 762 |
+
assert "Привет" in final_text
|
| 763 |
+
finally:
|
| 764 |
+
bot.chat_state.pop(chat_id, None)
|
| 765 |
+
|
| 766 |
+
|
| 767 |
def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
|
| 768 |
# Живой статус уже в чате: стрим пишет ответ в него, второго «…» нет.
|
| 769 |
chat_id = 999320
|
|
|
|
| 771 |
class _FakeStatus:
|
| 772 |
def __init__(self):
|
| 773 |
self.message = _FakeSentMessage()
|
| 774 |
+
self.finished = False
|
| 775 |
+
|
| 776 |
+
async def finish(self):
|
| 777 |
+
self.finished = True
|
| 778 |
|
| 779 |
+
def compose_answer(self, answer_md):
|
| 780 |
+
return ("<blockquote expandable>✻ Thought for 1s</blockquote>\n\n" + answer_md,
|
| 781 |
+
"✻ Thought for 1s\n\n" + answer_md)
|
| 782 |
|
| 783 |
async def pieces():
|
| 784 |
yield "Привет"
|
|
|
|
| 792 |
))
|
| 793 |
assert answer == "Привет"
|
| 794 |
assert incoming.sent == [] # второй пузырь не отправлялся
|
| 795 |
+
assert status.finished is True
|
| 796 |
+
final_edit = status.message.edits[-1][0]
|
| 797 |
+
assert "Привет" in final_edit
|
| 798 |
+
assert "Thought for" in final_edit
|
| 799 |
finally:
|
| 800 |
bot.chat_state.pop(chat_id, None)
|
| 801 |
|
tests/test_lumen_status.py
CHANGED
|
@@ -27,16 +27,24 @@ def test_status_message_renders_done_lines_and_timer(monkeypatch):
|
|
| 27 |
status = await StatusMessage.start(incoming, "en", "progress_searching")
|
| 28 |
assert status is not None
|
| 29 |
status.add_done("progress_search_done", count=5)
|
|
|
|
| 30 |
await status.set_phase("progress_thinking", timed=True)
|
| 31 |
-
await status.
|
| 32 |
return status
|
| 33 |
|
| 34 |
status = asyncio.run(scenario())
|
| 35 |
sent = incoming.sent[0]
|
| 36 |
assert status.message is sent
|
| 37 |
text = sent.edits[-1][0]
|
| 38 |
-
assert "Searched the web · found 5" in text
|
| 39 |
assert "Thinking… 0s" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# Язык статуса — это код языка, а не chat_id: строка "en" не должна
|
| 41 |
# попасть в chat_state как ключ (прод 28.10.2026).
|
| 42 |
assert "en" not in bot.chat_state
|
|
|
|
| 27 |
status = await StatusMessage.start(incoming, "en", "progress_searching")
|
| 28 |
assert status is not None
|
| 29 |
status.add_done("progress_search_done", count=5)
|
| 30 |
+
status.set_sources(["example.com"])
|
| 31 |
await status.set_phase("progress_thinking", timed=True)
|
| 32 |
+
await status.finish()
|
| 33 |
return status
|
| 34 |
|
| 35 |
status = asyncio.run(scenario())
|
| 36 |
sent = incoming.sent[0]
|
| 37 |
assert status.message is sent
|
| 38 |
text = sent.edits[-1][0]
|
| 39 |
+
assert "✓ Searched the web · found 5" in text
|
| 40 |
assert "Thinking… 0s" in text
|
| 41 |
+
html_block, plain_block = status.activity_prefix()
|
| 42 |
+
assert html_block.startswith("<blockquote expandable>") and html_block.endswith("</blockquote>")
|
| 43 |
+
assert "Thought for 0s" in plain_block
|
| 44 |
+
assert "✓ Searched the web · found 5" in plain_block
|
| 45 |
+
assert "· example.com" in plain_block
|
| 46 |
+
# Таблицы показываются rich-сообщением: блок-цитата вернула бы None.
|
| 47 |
+
assert status.compose_answer("| a | b |\n|---|---|\n| 1 | 2 |")[0] is None
|
| 48 |
# Язык статуса — это код языка, а не chat_id: строка "en" не должна
|
| 49 |
# попасть в chat_state как ключ (прод 28.10.2026).
|
| 50 |
assert "en" not in bot.chat_state
|