@adriangalilea/utils 1.14.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -17
- package/dist/bot/admin.d.ts +3 -0
- package/dist/bot/admin.d.ts.map +1 -1
- package/dist/bot/admin.js +8 -0
- package/dist/bot/admin.js.map +1 -1
- package/dist/bot/index.d.ts +1 -1
- package/dist/bot/index.js +1 -1
- package/dist/bot/llm.d.ts +71 -226
- package/dist/bot/llm.d.ts.map +1 -1
- package/dist/bot/llm.js +183 -480
- package/dist/bot/llm.js.map +1 -1
- package/dist/llm/index.d.ts +215 -0
- package/dist/llm/index.d.ts.map +1 -0
- package/dist/llm/index.js +504 -0
- package/dist/llm/index.js.map +1 -0
- package/package.json +32 -3
package/dist/bot/llm.js
CHANGED
|
@@ -1,512 +1,215 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GramIO LLM toolkit —
|
|
3
|
-
*
|
|
2
|
+
* GramIO LLM toolkit — the Telegram side of an LLM chatbot. The model side
|
|
3
|
+
* (providers, failover, usage accounting, tools) lives in
|
|
4
|
+
* `@adriangalilea/utils/llm`; this module renders its event stream into a
|
|
5
|
+
* chat and remembers conversations:
|
|
4
6
|
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `ctx.startChatStream()` — OUTPUT (high-level). Consumes a fetch
|
|
18
|
-
* `Response` (or any `AsyncIterable<LLMChunk>`)
|
|
19
|
-
* and renders BOTH phases of a thinking
|
|
20
|
-
* model: `reasoning` chunks go into one
|
|
21
|
-
* `<blockquote expandable>` message;
|
|
22
|
-
* `content` chunks go into a streamed
|
|
23
|
-
* markdown message below it. Returns
|
|
24
|
-
* `{ content, reasoning }` once the
|
|
25
|
-
* source generator ends.
|
|
7
|
+
* `streamChatReply(ctx, events)` — OUTPUT. Consumes an
|
|
8
|
+
* `AsyncIterable<LlmStreamEvent>` and paints it with Telegram's native
|
|
9
|
+
* message-draft streaming (`sendMessageDraft`: ephemeral ~30s previews,
|
|
10
|
+
* animated in place per draft_id), then persists the finished text via
|
|
11
|
+
* `ctx.send`, entity-split across the 4096 limit by `@gramio/split`.
|
|
12
|
+
* Reasoning models get a "thinking" phase: reasoning streams into the
|
|
13
|
+
* draft preview and evaporates by default, or persists as an expandable
|
|
14
|
+
* blockquote. A `reset` event (provider failover upstream) repaints the
|
|
15
|
+
* draft from scratch — full-frame previews make that one cheap frame.
|
|
16
|
+
* Drafts are a PRIVATE-chat capability (and the bot needs forum topic
|
|
17
|
+
* mode enabled in BotFather); elsewhere the preview phase is skipped and
|
|
18
|
+
* only the final send happens.
|
|
26
19
|
*
|
|
27
20
|
* `ctx.llm.add / .get / …` — HISTORY. Per-(user, thread) conversation
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `botMenu` 🗑 Forget button wipes it
|
|
32
|
-
* together with everything else (one
|
|
33
|
-
* record, one delete, no per-plugin
|
|
34
|
-
* registry).
|
|
35
|
-
*
|
|
36
|
-
* The trio composes:
|
|
21
|
+
* buffer in OpenAI `ChatMessage` shape, persisted in the shared
|
|
22
|
+
* `@gramio/session` record under the `llm` field, so the `botMenu`
|
|
23
|
+
* 🗑 Forget button wipes it together with everything else.
|
|
37
24
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* └──── ctx.llm.get() ◀──── ctx.llm.add(assistant) ◀──┘
|
|
25
|
+
* Peer deps: `gramio`, `@gramio/session`, `@gramio/format`, `@gramio/split`,
|
|
26
|
+
* `marked`.
|
|
41
27
|
*
|
|
42
|
-
*
|
|
28
|
+
* @example chatbot turn: history → llm.stream → streamed reply
|
|
29
|
+
* import { createLlm } from '@adriangalilea/utils/llm'
|
|
30
|
+
* import { streamChatReply, llmHistory, toModelMessages } from '@adriangalilea/utils/bot/llm'
|
|
43
31
|
*
|
|
44
|
-
*
|
|
45
|
-
* import { Bot } from 'gramio'
|
|
46
|
-
* import { session } from '@gramio/session'
|
|
47
|
-
* import { llmStream, llmHistory } from '@adriangalilea/utils/bot/llm'
|
|
48
|
-
*
|
|
49
|
-
* const userSession = session({ storage, key: 'session', initial: () => ({}) })
|
|
32
|
+
* const llm = createLlm({ providers })
|
|
50
33
|
* const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 })
|
|
51
34
|
*
|
|
52
|
-
*
|
|
53
|
-
* .
|
|
54
|
-
* .
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* headers: { 'Content-Type': 'application/json' },
|
|
61
|
-
* body: JSON.stringify({
|
|
62
|
-
* model: process.env.LLM_MODEL,
|
|
63
|
-
* messages: [{ role: 'system', content: 'You are helpful.' }, ...ctx.llm.get()],
|
|
64
|
-
* stream: true,
|
|
65
|
-
* }),
|
|
66
|
-
* })
|
|
67
|
-
* const { content } = await ctx.startChatStream(response)
|
|
68
|
-
* ctx.llm.add({ role: 'assistant', content })
|
|
69
|
-
* })
|
|
70
|
-
*
|
|
71
|
-
* bot.start()
|
|
72
|
-
*
|
|
73
|
-
* @example low-level — manual loop when you need to map/filter chunks
|
|
74
|
-
* import { streamChat } from '@adriangalilea/utils/bot/llm'
|
|
75
|
-
*
|
|
76
|
-
* const stream = ctx.startStream()
|
|
77
|
-
* for await (const chunk of streamChat(response)) {
|
|
78
|
-
* if (chunk.type === 'content') await stream.append(chunk.text)
|
|
79
|
-
* // reasoning chunks dropped here
|
|
80
|
-
* }
|
|
81
|
-
* await stream.end()
|
|
35
|
+
* bot.extend(userSession).extend(chat.plugin).on('message', async (ctx) => {
|
|
36
|
+
* ctx.llm.add({ role: 'user', content: ctx.text ?? '' })
|
|
37
|
+
* const { content } = await streamChatReply(ctx, llm.stream({
|
|
38
|
+
* instructions: 'You are helpful.',
|
|
39
|
+
* messages: toModelMessages(ctx.llm.get()),
|
|
40
|
+
* }))
|
|
41
|
+
* ctx.llm.add({ role: 'assistant', content })
|
|
42
|
+
* })
|
|
82
43
|
*/
|
|
83
44
|
import { expandableBlockquote } from "@gramio/format";
|
|
84
45
|
import { markdownToFormattable } from "@gramio/format/markdown";
|
|
85
|
-
import {
|
|
46
|
+
import { splitMessage } from "@gramio/split";
|
|
47
|
+
import { Plugin, } from "gramio";
|
|
48
|
+
// ─── OUTPUT: draft-streamed reply ──────────────────────────────────
|
|
49
|
+
// Telegram animates draft repaints; one frame per second is smooth and stays
|
|
50
|
+
// clear of rate limits.
|
|
51
|
+
const THROTTLE_MS = 1000;
|
|
52
|
+
// A streamed draft is ephemeral: Telegram drops it after ~30s. A quiet stretch
|
|
53
|
+
// (LLM latency, an upstream failover) sends no frames, so the preview would
|
|
54
|
+
// age out mid-generation. Re-emit the current frame comfortably under the TTL.
|
|
55
|
+
const KEEPALIVE_MS = 20_000;
|
|
56
|
+
// Non-zero, ever-incrementing draft id per stream so a chat's concurrent /
|
|
57
|
+
// sequential previews don't share an animation timeline. Per-isolate;
|
|
58
|
+
// collisions across isolates are harmless (draft ids are chat-scoped).
|
|
59
|
+
let draftSeq = 0;
|
|
86
60
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* Recognised reasoning aliases (as of 2026): `reasoning_content`
|
|
91
|
-
* (vllm, qwen3, DeepSeek-R1, gpt-oss harmony) and `reasoning` (some
|
|
92
|
-
* mlx-lm forks, gemma builds). If a model surfaces a new key, add it
|
|
93
|
-
* here — single source of truth for the field.
|
|
94
|
-
*
|
|
95
|
-
* Constrained-SSE assumption: lines are `\n`-delimited and each event
|
|
96
|
-
* is `data: <json>` or `data: [DONE]`. This matches every OpenAI-compat
|
|
97
|
-
* server in the wild but is NOT the full SSE spec (no comments, no
|
|
98
|
-
* multi-line `data:`, no `retry`/`id` fields). Swap to
|
|
99
|
-
* `eventsource-parser` if you hit a producer that needs them.
|
|
100
|
-
*
|
|
101
|
-
* Malformed JSON lines are silently skipped; the generator ends when
|
|
102
|
-
* the stream closes.
|
|
103
|
-
*
|
|
104
|
-
* @param response the `fetch` `Response` from a `stream: true` chat
|
|
105
|
-
* completion call. Must not be already consumed.
|
|
106
|
-
* @throws if `response.body` is null (non-streaming response).
|
|
107
|
-
*
|
|
108
|
-
* @example framework-agnostic — parser doesn't know about Telegram
|
|
109
|
-
* const res = await fetch(url, { method: 'POST', body })
|
|
110
|
-
* for await (const chunk of streamChat(res)) {
|
|
111
|
-
* if (chunk.type === 'content') process.stdout.write(chunk.text)
|
|
112
|
-
* }
|
|
61
|
+
* Render an LLM event stream into the chat: live draft previews while
|
|
62
|
+
* generating, entity-split persisted message(s) when done. See the module doc
|
|
63
|
+
* for the full contract. Returns the transcript pieces plus the sent messages.
|
|
113
64
|
*/
|
|
114
|
-
export async function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
currentMessageId;
|
|
159
|
-
firstSendPromise;
|
|
160
|
-
debounceTimer;
|
|
161
|
-
inFlight = false;
|
|
162
|
-
dirty = false;
|
|
163
|
-
ended = false;
|
|
164
|
-
/**
|
|
165
|
-
* `true` after `.end()` returns if the stream finished with un-flushed
|
|
166
|
-
* buffer or an outstanding error that the streamer couldn't recover
|
|
167
|
-
* from. Useful for partial-response diagnostics in catch blocks:
|
|
168
|
-
*
|
|
169
|
-
* try { for await (...) await stream.append(...) }
|
|
170
|
-
* finally {
|
|
171
|
-
* await stream.end()
|
|
172
|
-
* if (stream.wasPartial) logger.warn('LLM stream cut off')
|
|
173
|
-
* }
|
|
174
|
-
*/
|
|
175
|
-
wasPartial = false;
|
|
176
|
-
chatId;
|
|
177
|
-
threadId;
|
|
178
|
-
// Match gramio's `bot.api` shape structurally (we don't import gramio's
|
|
179
|
-
// full Bot type to keep the streamer testable in isolation). `text` is
|
|
180
|
-
// `string` to mirror gramio's declared API param types — a wider param
|
|
181
|
-
// here would make the real bot.api unassignable. Formattable payloads
|
|
182
|
-
// go through the same `as unknown as` bridge the reasoning path uses
|
|
183
|
-
// (gramio's api proxy converts FormattableString to text+entities at
|
|
184
|
-
// runtime; its declarations just don't say so).
|
|
185
|
-
bot;
|
|
186
|
-
opts;
|
|
187
|
-
constructor(ctx, opts) {
|
|
188
|
-
this.chatId = ctx.chat.id;
|
|
189
|
-
// Captured so the streamed reply stays in the same thread.
|
|
190
|
-
// We call bot.api.sendMessage directly (no ctx.send), so the
|
|
191
|
-
// SendMixin's auto-thread doesn't help us here.
|
|
192
|
-
this.threadId = ctx.threadId;
|
|
193
|
-
this.bot = ctx.bot;
|
|
194
|
-
this.opts = {
|
|
195
|
-
debounceMs: opts.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
|
196
|
-
placeholder: opts.placeholder ?? "…",
|
|
197
|
-
markdown: opts.markdown ?? true,
|
|
198
|
-
onError: opts.onError ?? ((e) => console.error("[bot/llm:stream]", e)),
|
|
199
|
-
};
|
|
65
|
+
export async function streamChatReply(ctx, events, opts = {}) {
|
|
66
|
+
const reasoningMode = opts.reasoning ?? "preview";
|
|
67
|
+
const throttle = opts.throttleMs ?? THROTTLE_MS;
|
|
68
|
+
// Drafts exist in private chats only; elsewhere skip the preview phase and
|
|
69
|
+
// deliver the finished reply in one shot.
|
|
70
|
+
const drafts = ctx.chat.type === "private";
|
|
71
|
+
const draftId = ++draftSeq;
|
|
72
|
+
let content = "";
|
|
73
|
+
let reasoning = "";
|
|
74
|
+
const toolCalls = [];
|
|
75
|
+
let usage = null;
|
|
76
|
+
let lastFrameAt = 0;
|
|
77
|
+
let keepalive;
|
|
78
|
+
let painting = Promise.resolve();
|
|
79
|
+
// Full-frame repaint: drafts replace, never append — which is what makes
|
|
80
|
+
// resets and phase switches (thinking → answer) one cheap frame.
|
|
81
|
+
const paint = (formattable) => {
|
|
82
|
+
lastFrameAt = Date.now();
|
|
83
|
+
// Serialize frames; a rejected send must not kill the stream loop, and
|
|
84
|
+
// the next frame repaints fully anyway.
|
|
85
|
+
painting = painting.then(() => ctx.bot.api
|
|
86
|
+
.sendMessageDraft({
|
|
87
|
+
chat_id: ctx.chat.id,
|
|
88
|
+
draft_id: draftId,
|
|
89
|
+
...(ctx.threadId !== undefined
|
|
90
|
+
? { message_thread_id: ctx.threadId }
|
|
91
|
+
: {}),
|
|
92
|
+
text: formattable,
|
|
93
|
+
})
|
|
94
|
+
.catch(() => { }));
|
|
95
|
+
};
|
|
96
|
+
const frame = () => {
|
|
97
|
+
// The answer owns the preview as soon as it exists; before that, the
|
|
98
|
+
// thinking phase does (unless hidden).
|
|
99
|
+
if (content)
|
|
100
|
+
paint(markdownToFormattable(content));
|
|
101
|
+
else if (reasoning && reasoningMode !== "hidden")
|
|
102
|
+
paint(markdownToFormattable(reasoning));
|
|
103
|
+
};
|
|
104
|
+
if (drafts) {
|
|
105
|
+
keepalive = setInterval(() => {
|
|
106
|
+
if (Date.now() - lastFrameAt >= KEEPALIVE_MS)
|
|
107
|
+
frame();
|
|
108
|
+
}, KEEPALIVE_MS);
|
|
200
109
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
message_thread_id: this.threadId,
|
|
215
|
-
}),
|
|
216
|
-
text: this.opts.placeholder,
|
|
217
|
-
});
|
|
218
|
-
this.currentMessageId = sent.message_id;
|
|
219
|
-
})();
|
|
220
|
-
}
|
|
221
|
-
if (this.firstSendPromise)
|
|
222
|
-
await this.firstSendPromise;
|
|
223
|
-
// overflow: freeze current message at last good split, start a new one.
|
|
224
|
-
const next = this.buffer + text;
|
|
225
|
-
if (next.length > MAX_LEN) {
|
|
226
|
-
const splitAt = findSplit(next, MAX_LEN);
|
|
227
|
-
const head = next.slice(0, splitAt);
|
|
228
|
-
const tail = next.slice(splitAt).trimStart();
|
|
229
|
-
this.buffer = head;
|
|
230
|
-
this.dirty = true;
|
|
231
|
-
await this.flushNow(); // commits head into current message
|
|
232
|
-
this.buffer = "";
|
|
233
|
-
this.currentMessageId = undefined;
|
|
234
|
-
this.firstSendPromise = undefined;
|
|
235
|
-
this.dirty = false;
|
|
236
|
-
if (this.debounceTimer) {
|
|
237
|
-
clearTimeout(this.debounceTimer);
|
|
238
|
-
this.debounceTimer = undefined;
|
|
110
|
+
try {
|
|
111
|
+
for await (const event of events) {
|
|
112
|
+
if (event.kind === "delta") {
|
|
113
|
+
const first = content === "";
|
|
114
|
+
content += event.text;
|
|
115
|
+
if (drafts && (first || Date.now() - lastFrameAt >= throttle)) {
|
|
116
|
+
// Answer taking over from thinking: persist the blockquote first
|
|
117
|
+
// so it lands ABOVE the final answer message.
|
|
118
|
+
if (first && reasoning && reasoningMode === "message") {
|
|
119
|
+
await persistReasoning(ctx, reasoning);
|
|
120
|
+
}
|
|
121
|
+
frame();
|
|
122
|
+
}
|
|
239
123
|
}
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
this.buffer = next;
|
|
245
|
-
this.dirty = true;
|
|
246
|
-
this.scheduleFlush();
|
|
247
|
-
}
|
|
248
|
-
/** Flush any pending edit and close the stream. Idempotent. */
|
|
249
|
-
async end() {
|
|
250
|
-
if (this.ended)
|
|
251
|
-
return;
|
|
252
|
-
this.ended = true;
|
|
253
|
-
if (this.debounceTimer) {
|
|
254
|
-
clearTimeout(this.debounceTimer);
|
|
255
|
-
this.debounceTimer = undefined;
|
|
256
|
-
}
|
|
257
|
-
while (this.inFlight)
|
|
258
|
-
await sleep(50);
|
|
259
|
-
if (this.dirty) {
|
|
260
|
-
await this.flushNow();
|
|
261
|
-
// If `dirty` is still set after the final flush, an edit error
|
|
262
|
-
// re-armed it and there's nothing more we'll do. Mark partial
|
|
263
|
-
// so the caller can log / fall back.
|
|
264
|
-
if (this.dirty)
|
|
265
|
-
this.wasPartial = true;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
scheduleFlush() {
|
|
269
|
-
if (this.debounceTimer)
|
|
270
|
-
return;
|
|
271
|
-
this.debounceTimer = setTimeout(async () => {
|
|
272
|
-
this.debounceTimer = undefined;
|
|
273
|
-
if (this.inFlight) {
|
|
274
|
-
this.scheduleFlush();
|
|
275
|
-
return;
|
|
124
|
+
else if (event.kind === "reasoning") {
|
|
125
|
+
reasoning += event.text;
|
|
126
|
+
if (drafts && !content && Date.now() - lastFrameAt >= throttle)
|
|
127
|
+
frame();
|
|
276
128
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (!this.dirty)
|
|
284
|
-
return;
|
|
285
|
-
if (this.currentMessageId === undefined) {
|
|
286
|
-
// No active message yet — likely the first send is still in flight
|
|
287
|
-
// or the recovery path cleared it. Caller's next append() will
|
|
288
|
-
// re-create one.
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
this.inFlight = true;
|
|
292
|
-
this.dirty = false;
|
|
293
|
-
const snapshot = this.buffer;
|
|
294
|
-
try {
|
|
295
|
-
const payload = this.opts.markdown
|
|
296
|
-
? markdownToFormattable(snapshot)
|
|
297
|
-
: snapshot;
|
|
298
|
-
await this.bot.api.editMessageText({
|
|
299
|
-
chat_id: this.chatId,
|
|
300
|
-
message_id: this.currentMessageId,
|
|
301
|
-
text: payload,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
catch (e) {
|
|
305
|
-
const msg = String(e?.message ?? e);
|
|
306
|
-
if (msg.includes("message is not modified")) {
|
|
307
|
-
// identical content — fine
|
|
129
|
+
else if (event.kind === "reset") {
|
|
130
|
+
content = "";
|
|
131
|
+
reasoning = "";
|
|
132
|
+
toolCalls.length = 0;
|
|
133
|
+
if (drafts)
|
|
134
|
+
paint(""); // empty text = Telegram's "Thinking…" placeholder
|
|
308
135
|
}
|
|
309
|
-
else if (
|
|
310
|
-
|
|
311
|
-
this.currentMessageId = undefined;
|
|
312
|
-
this.firstSendPromise = undefined;
|
|
313
|
-
this.dirty = true;
|
|
136
|
+
else if (event.kind === "tool-call") {
|
|
137
|
+
toolCalls.push({ toolName: event.toolName, input: event.input });
|
|
314
138
|
}
|
|
315
139
|
else {
|
|
316
|
-
|
|
317
|
-
this.opts.onError(e);
|
|
140
|
+
usage = event.usage;
|
|
318
141
|
}
|
|
319
142
|
}
|
|
320
|
-
|
|
321
|
-
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
if (keepalive)
|
|
146
|
+
clearInterval(keepalive);
|
|
147
|
+
await painting.catch(() => { });
|
|
148
|
+
}
|
|
149
|
+
content = content.trim();
|
|
150
|
+
reasoning = reasoning.trim();
|
|
151
|
+
// A reasoning-only or tool-call-only turn persists nothing; the ephemeral
|
|
152
|
+
// draft expires on its own.
|
|
153
|
+
if (reasoning && reasoningMode === "message" && content === "")
|
|
154
|
+
await persistReasoning(ctx, reasoning);
|
|
155
|
+
let messages = [];
|
|
156
|
+
if (content) {
|
|
157
|
+
// Collect the entity-correct parts first, then send with params on the
|
|
158
|
+
// last one — splitMessage's sequential action callback stays the sender
|
|
159
|
+
// so ordering is preserved.
|
|
160
|
+
const parts = [];
|
|
161
|
+
await splitMessage(markdownToFormattable(content), (part) => {
|
|
162
|
+
parts.push(part);
|
|
163
|
+
});
|
|
164
|
+
messages = [];
|
|
165
|
+
for (let i = 0; i < parts.length; i++) {
|
|
166
|
+
const isLast = i === parts.length - 1;
|
|
167
|
+
messages.push(await ctx.send(parts[i], isLast ? opts.messageParams : undefined));
|
|
322
168
|
}
|
|
323
169
|
}
|
|
170
|
+
return { content, reasoning, toolCalls, usage, messages };
|
|
324
171
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// gramio's `message` scope guarantees `ctx.chat`. `ctx.bot.api` is on
|
|
333
|
-
// every Context. Structural compat → no cast needed.
|
|
334
|
-
startStream: (opts = {}) => new MarkdownStreamer(ctx, { ...defaults, ...opts }),
|
|
335
|
-
/**
|
|
336
|
-
* One-call helper for chat completions: consumes a `Response`
|
|
337
|
-
* (or any `AsyncIterable<LLMChunk>`), renders reasoning to an
|
|
338
|
-
* expandable blockquote message + content to a streamed markdown
|
|
339
|
-
* message, returns `{ content, reasoning }` when the stream ends.
|
|
340
|
-
*/
|
|
341
|
-
startChatStream: (source, opts = {}) => consumeChatStream(ctx, source, { ...defaults, ...opts }),
|
|
342
|
-
}));
|
|
343
|
-
function findSplit(text, maxLen) {
|
|
344
|
-
// Prefer paragraph break, then line, then space. Reject splits in the
|
|
345
|
-
// first half — better to truncate at maxLen than to leave a stub.
|
|
346
|
-
for (const sep of ["\n\n", "\n", " "]) {
|
|
347
|
-
const idx = text.lastIndexOf(sep, maxLen);
|
|
348
|
-
if (idx > maxLen / 2)
|
|
349
|
-
return idx;
|
|
350
|
-
}
|
|
351
|
-
return maxLen;
|
|
172
|
+
// Reasoning markdown → one expandable-blockquote message. Malformed mid-stream
|
|
173
|
+
// markdown degrades to plain text inside the quote instead of failing the send.
|
|
174
|
+
async function persistReasoning(ctx, reasoning) {
|
|
175
|
+
const rendered = expandableBlockquote(markdownToFormattable(reasoning));
|
|
176
|
+
if (rendered.text.trim().length === 0)
|
|
177
|
+
return;
|
|
178
|
+
await ctx.send(rendered).catch(() => { });
|
|
352
179
|
}
|
|
353
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
354
180
|
/**
|
|
355
|
-
*
|
|
356
|
-
* (
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
* `<blockquote expandable>…</blockquote>` (HTML), debounced edits,
|
|
360
|
-
* stays expanded after the stream ends so the user can review it
|
|
361
|
-
* collapsed/uncollapsed at will
|
|
362
|
-
* - content chunks → a fresh `MarkdownStreamer` message below the
|
|
363
|
-
* reasoning one (auto-threaded via SendMixin)
|
|
364
|
-
*
|
|
365
|
-
* Returns when the source generator ends. Equivalent to (and replaces)
|
|
366
|
-
* the ~50 LOC of bookkeeping every thinking-model bot would otherwise
|
|
367
|
-
* write by hand around `streamChat` + `ctx.startStream`.
|
|
368
|
-
*
|
|
369
|
-
* Accepts either a raw `fetch` `Response` (wrapped with `streamChat`
|
|
370
|
-
* internally) or an `AsyncIterable<LLMChunk>` (when you want to map /
|
|
371
|
-
* filter chunks before the stream hits Telegram).
|
|
372
|
-
*
|
|
373
|
-
* @example via ctx.startChatStream from the llmStream plugin
|
|
374
|
-
* const response = await fetch(LLM_URL, { method: 'POST', body })
|
|
375
|
-
* const { content } = await ctx.startChatStream(response)
|
|
376
|
-
* ctx.llm.add({ role: 'assistant', content })
|
|
377
|
-
*
|
|
378
|
-
* @example standalone, framework-agnostic
|
|
379
|
-
* const { content, reasoning } = await consumeChatStream(ctx, response)
|
|
181
|
+
* Convert stored history into the AI SDK's `ModelMessage` shape for
|
|
182
|
+
* `llm.stream({ messages })`. Text and image parts map 1:1; `tool` turns are
|
|
183
|
+
* dropped (tool plumbing belongs to the current request, not replayed
|
|
184
|
+
* history); an assistant turn's parts flatten to text.
|
|
380
185
|
*/
|
|
381
|
-
export
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
let reasoning = "";
|
|
389
|
-
let reasoningMessageId;
|
|
390
|
-
let reasoningTimer;
|
|
391
|
-
let reasoningDirty = false;
|
|
392
|
-
let reasoningInFlight = false;
|
|
393
|
-
// Same graceful-degradation pipeline as the content phase:
|
|
394
|
-
// markdownToFormattable parses the reasoning (lists, code blocks,
|
|
395
|
-
// bold — thinking models do emit markdown) into a FormattableString
|
|
396
|
-
// with native Telegram entities, and `expandableBlockquote` wraps
|
|
397
|
-
// the whole thing in a collapsed-by-default block quotation by
|
|
398
|
-
// adding the `expandable_blockquote` entity over the full text.
|
|
399
|
-
// Malformed mid-stream markdown degrades to plain text inside the
|
|
400
|
-
// blockquote instead of failing the message.
|
|
401
|
-
const renderReasoning = () => expandableBlockquote(markdownToFormattable(reasoning));
|
|
402
|
-
const sendReasoningMessage = async () => {
|
|
403
|
-
const params = {
|
|
404
|
-
chat_id: chatId,
|
|
405
|
-
...(threadId !== undefined && { message_thread_id: threadId }),
|
|
406
|
-
text: renderReasoning(),
|
|
407
|
-
};
|
|
408
|
-
const sent = await bot.api.sendMessage(params);
|
|
409
|
-
reasoningMessageId = sent.message_id;
|
|
410
|
-
};
|
|
411
|
-
const flushReasoning = async () => {
|
|
412
|
-
if (!reasoningDirty || reasoningMessageId === undefined)
|
|
413
|
-
return;
|
|
414
|
-
reasoningInFlight = true;
|
|
415
|
-
reasoningDirty = false;
|
|
416
|
-
try {
|
|
417
|
-
await bot.api.editMessageText({
|
|
418
|
-
chat_id: chatId,
|
|
419
|
-
message_id: reasoningMessageId,
|
|
420
|
-
text: renderReasoning(),
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
catch (e) {
|
|
424
|
-
const msg = String(e?.message ?? e);
|
|
425
|
-
// "message is not modified" is benign when reasoning chunks
|
|
426
|
-
// arrive faster than the debounce can render.
|
|
427
|
-
if (!msg.includes("message is not modified")) {
|
|
428
|
-
reasoningDirty = true;
|
|
429
|
-
opts.onError?.(e);
|
|
430
|
-
}
|
|
186
|
+
export function toModelMessages(history) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const m of history) {
|
|
189
|
+
if (m.role === "tool")
|
|
190
|
+
continue;
|
|
191
|
+
if (m.role === "assistant" || m.role === "system") {
|
|
192
|
+
out.push({ role: m.role, content: flattenText(m.content) });
|
|
431
193
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (reasoningInFlight) {
|
|
442
|
-
scheduleReasoningFlush();
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
await flushReasoning();
|
|
446
|
-
if (reasoningDirty)
|
|
447
|
-
scheduleReasoningFlush();
|
|
448
|
-
}, debounceMs);
|
|
449
|
-
};
|
|
450
|
-
// ─── content state ──────────────────────────────────────────────
|
|
451
|
-
let contentStreamer;
|
|
452
|
-
let content = "";
|
|
453
|
-
// ─── main loop ──────────────────────────────────────────────────
|
|
454
|
-
for await (const chunk of gen) {
|
|
455
|
-
if (chunk.type === "reasoning") {
|
|
456
|
-
reasoning += chunk.text;
|
|
457
|
-
if (reasoningMessageId === undefined) {
|
|
458
|
-
// Defer the first send until the RENDERED markdown has
|
|
459
|
-
// visible content. Two distinct failure modes here:
|
|
460
|
-
//
|
|
461
|
-
// 1. Leading whitespace-only chunks (e.g. Gemma 4 via
|
|
462
|
-
// mlx-vlm's gemma4 reasoning parser opens with '\n').
|
|
463
|
-
// `reasoning.trim()` filters those out.
|
|
464
|
-
//
|
|
465
|
-
// 2. Markdown that COLLAPSES to empty even though the
|
|
466
|
-
// raw text isn't whitespace: '\n*', '#', '```', etc.
|
|
467
|
-
// `markdownToFormattable` parses '\n*' as an empty
|
|
468
|
-
// list marker → FormattableString.text = ''. Telegram
|
|
469
|
-
// then rejects sendMessage with "text must be non-empty".
|
|
470
|
-
//
|
|
471
|
-
// Checking the rendered text catches both. Accumulated
|
|
472
|
-
// reasoning keeps its full content; we just hold the
|
|
473
|
-
// message open until the first chunk that renders.
|
|
474
|
-
if (renderReasoning().text.trim().length > 0) {
|
|
475
|
-
await sendReasoningMessage();
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
else {
|
|
479
|
-
reasoningDirty = true;
|
|
480
|
-
scheduleReasoningFlush();
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
else if (chunk.type === "content") {
|
|
484
|
-
if (!contentStreamer) {
|
|
485
|
-
// Close out reasoning cleanly before content starts: flush any
|
|
486
|
-
// pending edit, drop the debounce. The blockquote stays in
|
|
487
|
-
// the chat as a finished message.
|
|
488
|
-
if (reasoningTimer) {
|
|
489
|
-
clearTimeout(reasoningTimer);
|
|
490
|
-
reasoningTimer = undefined;
|
|
491
|
-
}
|
|
492
|
-
if (reasoningDirty)
|
|
493
|
-
await flushReasoning();
|
|
494
|
-
contentStreamer = new MarkdownStreamer({ chat: { id: chatId }, threadId, bot }, opts);
|
|
495
|
-
}
|
|
496
|
-
content += chunk.text;
|
|
497
|
-
await contentStreamer.append(chunk.text);
|
|
194
|
+
else {
|
|
195
|
+
out.push({
|
|
196
|
+
role: "user",
|
|
197
|
+
content: typeof m.content === "string"
|
|
198
|
+
? m.content
|
|
199
|
+
: m.content.map((part) => part.type === "text"
|
|
200
|
+
? { type: "text", text: part.text }
|
|
201
|
+
: { type: "image", image: part.image_url.url }),
|
|
202
|
+
});
|
|
498
203
|
}
|
|
499
204
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
return { content, reasoning };
|
|
509
|
-
};
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
const flattenText = (content) => typeof content === "string"
|
|
208
|
+
? content
|
|
209
|
+
: content
|
|
210
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
211
|
+
.join("")
|
|
212
|
+
.trim();
|
|
510
213
|
const GENERAL_THREAD = "general";
|
|
511
214
|
const threadKey = (ctx) => {
|
|
512
215
|
// Message events: ctx.threadId. Callback events: ctx.message.threadId.
|
|
@@ -590,7 +293,7 @@ export const llmHistory = (opts) => {
|
|
|
590
293
|
// this tap (calling ctx.answer here would be a double-answer
|
|
591
294
|
// → rejected → action throws).
|
|
592
295
|
const c = ctx;
|
|
593
|
-
// Wipe
|
|
296
|
+
// Wipe the store first — always succeeds and is the source of truth
|
|
594
297
|
// the LLM consults. If the Telegram-side deletion below fails
|
|
595
298
|
// (no thread, missing permissions, etc.), the next message in
|
|
596
299
|
// this thread still starts with a clean context.
|
|
@@ -616,7 +319,7 @@ export const llmHistory = (opts) => {
|
|
|
616
319
|
}
|
|
617
320
|
catch (e) {
|
|
618
321
|
// Thread already gone (404), supergroup-without-admin (400),
|
|
619
|
-
// chat type doesn't support it, etc.
|
|
322
|
+
// chat type doesn't support it, etc. History is already
|
|
620
323
|
// cleared so the user has a clean conversation, but we
|
|
621
324
|
// log so the failure isn't invisible.
|
|
622
325
|
console.error(`[llm-history] deleteForumTopic failed (chat=${chatId} thread=${tid} user=${userId ?? "?"}):`, e);
|