@adriangalilea/utils 1.15.0 → 2.0.1

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/dist/bot/llm.js CHANGED
@@ -1,512 +1,215 @@
1
1
  /**
2
- * GramIO LLM toolkit — three primitives that together form a complete
3
- * Telegram LLM-chatbot pipeline:
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
- * `streamChat(response)` — INPUT. Parses OpenAI-compatible SSE
6
- * (OpenAI, vllm, mlx-lm, llama.cpp,
7
- * Together, Groq, …) into a typed
8
- * `AsyncGenerator` of `{type, text}` with
9
- * `content` / `reasoning` separation.
10
- *
11
- * `ctx.startStream()` — OUTPUT (low-level). Debounced
12
- * `editMessageText` to Telegram, local
13
- * Markdown parse via `@gramio/format`,
14
- * 4000-char split on paragraph / line /
15
- * word boundary.
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
- * buffer in OpenAI `ChatMessage` shape.
29
- * Persisted in the shared `@gramio/session`
30
- * record under the `llm` field, so the
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
- * fetch(...) ──streamChat──> chunks ──ctx.startStream──> Telegram
39
- * ▲ │
40
- * └──── ctx.llm.get() ◀──── ctx.llm.add(assistant) ◀──┘
25
+ * Peer deps: `gramio`, `@gramio/session`, `@gramio/format`, `@gramio/split`,
26
+ * `marked`.
41
27
  *
42
- * Peer deps: `gramio`, `@gramio/session`, `@gramio/format`, `marked`.
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
- * @example high-level — thinking model in ~10 LOC
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
- * const bot = new Bot(process.env.BOT_TOKEN!)
53
- * .extend(userSession)
54
- * .extend(llmStream())
55
- * .extend(chat.plugin)
56
- * .on('message', async (ctx) => {
57
- * ctx.llm.add({ role: 'user', content: ctx.text ?? '' })
58
- * const response = await fetch(process.env.LLM_URL!, {
59
- * method: 'POST',
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 { Plugin } from "gramio";
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
- * Parse an OpenAI-compatible chat-completions SSE response into a
88
- * typed `AsyncGenerator<LLMChunk>`. Reads `response.body` once.
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* streamChat(response) {
115
- if (!response.body)
116
- throw new Error("streamChat: response.body is null");
117
- const reader = response.body.getReader();
118
- const decoder = new TextDecoder();
119
- let buffer = "";
120
- while (true) {
121
- const { done, value } = await reader.read();
122
- if (done)
123
- break;
124
- buffer += decoder.decode(value, { stream: true });
125
- const lines = buffer.split("\n");
126
- buffer = lines.pop() ?? "";
127
- for (const line of lines) {
128
- if (!line.startsWith("data: "))
129
- continue;
130
- const data = line.slice(6).trim();
131
- if (data === "[DONE]")
132
- continue;
133
- let parsed;
134
- try {
135
- parsed = JSON.parse(data);
136
- }
137
- catch {
138
- continue;
139
- }
140
- const delta = parsed.choices?.[0]?.delta;
141
- if (!delta)
142
- continue;
143
- const reasoning = delta.reasoning_content ?? delta.reasoning;
144
- if (typeof reasoning === "string" && reasoning.length > 0) {
145
- yield { type: "reasoning", text: reasoning };
146
- }
147
- if (typeof delta.content === "string" && delta.content.length > 0) {
148
- yield { type: "content", text: delta.content };
149
- }
150
- }
151
- }
152
- }
153
- // ─── OUTPUT: Telegram streamer ─────────────────────────────────────
154
- const MAX_LEN = 4000; // Telegram caps at 4096; leave headroom for entity offsets
155
- const DEFAULT_DEBOUNCE_MS = 800;
156
- export class MarkdownStreamer {
157
- buffer = "";
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
- /** Append a chunk. Schedules a debounced edit. */
202
- async append(text) {
203
- if (this.ended)
204
- throw new Error("stream already ended");
205
- if (!text)
206
- return;
207
- // first chunk: send the placeholder so we have a message_id to edit.
208
- // Serialized via firstSendPromise so concurrent appends don't double-send.
209
- if (this.currentMessageId === undefined && !this.firstSendPromise) {
210
- this.firstSendPromise = (async () => {
211
- const sent = await this.bot.api.sendMessage({
212
- chat_id: this.chatId,
213
- ...(this.threadId !== undefined && {
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 (tail)
241
- await this.append(tail);
242
- return;
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
- await this.flushNow();
278
- if (this.dirty && !this.ended)
279
- this.scheduleFlush();
280
- }, this.opts.debounceMs);
281
- }
282
- async flushNow() {
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 (msg.includes("message to edit not found")) {
310
- // message gone (deleted by user, etc.) — restart with a fresh send
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
- this.dirty = true;
317
- this.opts.onError(e);
140
+ usage = event.usage;
318
141
  }
319
142
  }
320
- finally {
321
- this.inFlight = false;
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
- * GramIO plugin. Adds `ctx.startStream(opts?)` on every message context.
327
- *
328
- * Defaults set here apply to every stream; per-call options in
329
- * `ctx.startStream({...})` override them.
330
- */
331
- export const llmStream = (defaults = {}) => new Plugin("@adriangalilea/utils/bot/llm/stream").derive("message", (ctx) => ({
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
- * One-shot helper that consumes an LLM stream and renders BOTH phases
356
- * (reasoning, content) to Telegram with the canonical pattern:
357
- *
358
- * - reasoning chunks → one message containing a single
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 const consumeChatStream = async (ctx, source, opts = {}) => {
382
- const chatId = ctx.chat.id;
383
- const threadId = ctx.threadId;
384
- const bot = ctx.bot;
385
- const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
386
- const gen = source instanceof Response ? streamChat(source) : source;
387
- // ─── reasoning state ────────────────────────────────────────────
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
- finally {
433
- reasoningInFlight = false;
434
- }
435
- };
436
- const scheduleReasoningFlush = () => {
437
- if (reasoningTimer)
438
- return;
439
- reasoningTimer = setTimeout(async () => {
440
- reasoningTimer = undefined;
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
- // Trailing reasoning (model emitted reasoning but no content yet).
501
- if (reasoningTimer) {
502
- clearTimeout(reasoningTimer);
503
- reasoningTimer = undefined;
504
- }
505
- if (reasoningDirty)
506
- await flushReasoning();
507
- await contentStreamer?.end();
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 Redis first — always succeeds and is the source of truth
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. Redis is already
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);