/** * GramIO LLM toolkit — the Telegram side of an LLM chatbot. The model side * (providers, failover, usage accounting, tools) lives in * `@adriangalilea/utils/llm`; this module renders its event stream into a * chat and remembers conversations: * * `streamChatReply(ctx, events)` — OUTPUT. Consumes an * `AsyncIterable` and paints it with Telegram's native * message-draft streaming (`sendMessageDraft`: ephemeral ~30s previews, * animated in place per draft_id), then persists the finished text via * `ctx.send`, entity-split across the 4096 limit by `@gramio/split`. * Reasoning models get a "thinking" phase: reasoning streams into the * draft preview and evaporates by default, or persists as an expandable * blockquote. A `reset` event (provider failover upstream) repaints the * draft from scratch — full-frame previews make that one cheap frame. * Drafts are a PRIVATE-chat capability (and the bot needs forum topic * mode enabled in BotFather); elsewhere the preview phase is skipped and * only the final send happens. * * The draft LIFECYCLE (throttle, anti-expiry keepalive, serialized * full-frame sends, reset, quiesce, forensics) is `bot/draft`'s * `createDraftPreview` — dependency-light on purpose; import it from * there when your producer pushes deltas or your rendering / persist * paths are your own. This module supplies the markdown render and the * split-send persist on top. * * `ctx.llm.add / .get / …` — HISTORY. Per-(user, thread) conversation * buffer in OpenAI `ChatMessage` shape, persisted in the shared * `@gramio/session` record under the `llm` field, so the `botMenu` * 🗑 Forget button wipes it together with everything else. * * Peer deps: `gramio`, `@gramio/session`, `@gramio/format`, `@gramio/split`. * * @example chatbot turn: history → llm.stream → streamed reply * import { createLlm } from '@adriangalilea/utils/llm' * import { streamChatReply, llmHistory, toModelMessages } from '@adriangalilea/utils/bot/llm' * * const llm = createLlm({ providers }) * const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 }) * * bot.extend(userSession).extend(chat.plugin).on('message', async (ctx) => { * ctx.llm.add({ role: 'user', content: ctx.text ?? '' }) * const { content } = await streamChatReply(ctx, llm.stream({ * instructions: 'You are helpful.', * messages: toModelMessages(ctx.llm.get()), * })) * ctx.llm.add({ role: 'assistant', content }) * }) */ import type { session } from "@gramio/session"; import { type Bot, type DeriveDefinitions, type MessageContext, Plugin } from "gramio"; import type { LlmStreamEvent, LlmToolCall, LlmUsage, ModelMessage } from "../llm/index.js"; import type { Polyglot } from "../say/index.js"; import type { MenuItem } from "./menu.js"; export interface StreamChatReplyOptions { /** * What happens to a reasoning model's thinking text. * `preview` (default) — streams into the ephemeral draft, evaporates when * the answer starts. `message` — additionally persists as an expandable * blockquote message before the answer. `hidden` — never rendered, not * even in the draft (the answer just takes longer to start). */ reasoning?: "preview" | "message" | "hidden"; /** Ms between draft repaints. Default 1000. */ throttleMs?: number; /** * Extra params for the finalizing `ctx.send` calls (e.g. `reply_markup`). * Applied to the LAST part when the reply splits across messages, so a * keyboard lands at the end. */ messageParams?: Parameters["send"]>[1]; } export interface ChatReplyResult { /** Full assistant markdown, concatenated from `delta` events. */ content: string; /** Full reasoning text. Empty for non-thinking models. */ reasoning: string; /** Tool calls the model made (the caller executes them; nothing is rendered). */ toolCalls: LlmToolCall[]; /** End-of-stream usage accounting, when the provider reported it. */ usage: LlmUsage | null; /** The persisted message(s), in order. Empty when the model produced no text (tool-call-only turns). */ messages: MessageContext[]; } /** * Render an LLM event stream into the chat: live draft previews while * generating (via {@link createDraftPreview}), entity-split persisted * message(s) when done. See the module doc for the full contract. Returns the * transcript pieces plus the sent messages. */ export declare function streamChatReply(ctx: MessageContext, events: AsyncIterable, opts?: StreamChatReplyOptions): Promise; /** * Multimodal content shape from OpenAI's chat-completions spec. Either * a plain string or an ordered array of typed parts. Image URLs cover * both http(s) and Telegram `getFile` resolved paths. */ export type ChatContent = string | Array<{ type: "text"; text: string; } | { type: "image_url"; image_url: { url: string; }; }>; /** * One turn in the conversation. The library does NOT filter by role — * if you persist `system` turns, they ride along on every `get()`. * Most callers prepend their system prompt fresh each request and only * persist `user` / `assistant`. */ export type ChatMessage = { role: "system" | "user" | "assistant" | "tool"; content: ChatContent; /** Unix seconds when added — used for retention pruning. */ date: number; }; /** * Convert stored history into the AI SDK's `ModelMessage` shape for * `llm.stream({ messages })`. Text and image parts map 1:1; `tool` turns are * dropped (tool plumbing belongs to the current request, not replayed * history); an assistant turn's parts flatten to text. */ export declare function toModelMessages(history: ReadonlyArray): ModelMessage[]; /** Per-thread shards of `ChatMessage`s, persisted in the session. */ type ChatRecord = { shards: { [threadKey: string]: ChatMessage[]; }; }; /** Loose session shape — this plugin only touches the `llm` field. */ type LLMSessionLike = { llm?: ChatRecord; }; /** @internal — kept unexported so it doesn't clash with peers' refs. */ type LLMSessionPluginRef = ReturnType>; export type LLMHistoryOptions = { /** * Shared session plugin. This plugin extends it for type flow; * gramio's runtime dedup ensures the session derive runs once. */ session: LLMSessionPluginRef; /** Ring buffer cap **per thread**. Oldest entries dropped past this. */ maxTurns: number; /** Entries older than this (in days) are dropped on read. */ retentionDays: number; /** * Override the labels of the `menuItem` (the "🗑 Delete this thread" * button rendered inside a `botMenu`). Defaults are polyglot * literals covering en + es. */ menuLabels?: { /** Button label. Default: `{ en: '🗑 Delete this thread', es: '🗑 Borrar este hilo' }`. */ item?: Polyglot; /** * Toast shown when `deleteForumTopic` succeeded — the Telegram * thread (with all its messages) is actually gone. * Default: `{ en: '🗑 Thread deleted.', es: '🗑 Hilo borrado.' }`. */ deleted?: Polyglot; /** * Toast shown when the thread COULD NOT be deleted (no `threadId` * available, the API rejected, etc.) but the LLM history was * still wiped. Tells the user what really happened — the thread * stays visible but the bot has forgotten the conversation. * Default: `{ en: '🧹 History cleared — couldn't delete the thread.', * es: '🧹 Historial limpio — no se pudo borrar el hilo.' }`. */ historyOnly?: Polyglot; /** Confirmation overlay text. Default explains the full-delete scope. */ confirmPrompt?: Polyglot; }; }; export type LLMHistoryFeature = { plugin: ReturnType; /** * Drop-in `MenuItem` for `botMenu({ items: [...] })`: a "delete this * thread" button that BOTH wipes `ctx.llm` for the current * (user, thread) shard AND calls `deleteForumTopic` to remove the * Telegram thread (and all its messages) from the chat. Sibling * threads stay intact. Falls back to history-only clear when there is * no `threadId` (general/non-threaded chats) or Telegram rejects * the deletion (e.g. forum supergroup without admin rights). */ menuItem: MenuItem; }; /** * Methods decorated onto `ctx.llm`. All synchronous — reads/writes the * session record via `@gramio/session`'s Proxy, which auto-persists. * * Thread isolation is automatic: every method operates on the shard * for `ctx.threadId` (or `'general'` when no thread). Different threads * = different conversations, no leakage. */ export type LLMHistoryApi = { /** Append one message to the CURRENT thread's shard. */ add: (message: Omit & { date?: number; }) => void; /** Pruned snapshot of the CURRENT thread, oldest-first. */ get: () => ReadonlyArray; /** Wipe the CURRENT thread's shard. */ clear: () => void; /** * Full sharded map, pruned. Use for /export or admin views. Keys are * thread ids (or `'general'`) → ordered messages. */ all: () => Readonly<{ [threadKey: string]: ReadonlyArray; }>; /** Wipe ALL threads for this user. */ clearAll: () => void; }; type LLMHistoryDerives = { llm: LLMHistoryApi; }; /** * Per-(user, thread) LLM conversation history. Opt-in. Persists in the * shared `@gramio/session` record under `llm`, so 🗑 Forget from * `botMenu` wipes it together with everything else — one record, one * delete, no per-plugin registry. * * @example * const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 }) * bot.extend(chat.plugin) * .on('message', (ctx) => { * ctx.llm.add({ role: 'user', content: ctx.text ?? '' }) * const messages = ctx.llm.get() // ChatMessage[] for current thread * // ... call LLM with messages, then: * ctx.llm.add({ role: 'assistant', content: reply }) * }) */ export declare const llmHistory: (opts: LLMHistoryOptions) => LLMHistoryFeature; declare const buildHistoryPlugin: (args: { sessionPlugin: LLMSessionPluginRef; maxTurns: number; retentionDays: number; }) => Plugin, DeriveDefinitions & { global: LLMHistoryDerives; } & { message: { session: LLMSessionLike & { $clear: () => Promise; }; }; channel_post: { session: LLMSessionLike & { $clear: () => Promise; }; }; inline_query: { session: LLMSessionLike & { $clear: () => Promise; }; }; chosen_inline_result: { session: LLMSessionLike & { $clear: () => Promise; }; }; callback_query: { session: LLMSessionLike & { $clear: () => Promise; }; }; shipping_query: { session: LLMSessionLike & { $clear: () => Promise; }; }; pre_checkout_query: { session: LLMSessionLike & { $clear: () => Promise; }; }; poll_answer: { session: LLMSessionLike & { $clear: () => Promise; }; }; chat_join_request: { session: LLMSessionLike & { $clear: () => Promise; }; }; new_chat_members: { session: LLMSessionLike & { $clear: () => Promise; }; }; new_chat_title: { session: LLMSessionLike & { $clear: () => Promise; }; }; new_chat_photo: { session: LLMSessionLike & { $clear: () => Promise; }; }; delete_chat_photo: { session: LLMSessionLike & { $clear: () => Promise; }; }; group_chat_created: { session: LLMSessionLike & { $clear: () => Promise; }; }; message_auto_delete_timer_changed: { session: LLMSessionLike & { $clear: () => Promise; }; }; migrate_to_chat_id: { session: LLMSessionLike & { $clear: () => Promise; }; }; migrate_from_chat_id: { session: LLMSessionLike & { $clear: () => Promise; }; }; pinned_message: { session: LLMSessionLike & { $clear: () => Promise; }; }; invoice: { session: LLMSessionLike & { $clear: () => Promise; }; }; successful_payment: { session: LLMSessionLike & { $clear: () => Promise; }; }; chat_shared: { session: LLMSessionLike & { $clear: () => Promise; }; }; proximity_alert_triggered: { session: LLMSessionLike & { $clear: () => Promise; }; }; video_chat_scheduled: { session: LLMSessionLike & { $clear: () => Promise; }; }; video_chat_started: { session: LLMSessionLike & { $clear: () => Promise; }; }; video_chat_ended: { session: LLMSessionLike & { $clear: () => Promise; }; }; video_chat_participants_invited: { session: LLMSessionLike & { $clear: () => Promise; }; }; web_app_data: { session: LLMSessionLike & { $clear: () => Promise; }; }; location: { session: LLMSessionLike & { $clear: () => Promise; }; }; passport_data: { session: LLMSessionLike & { $clear: () => Promise; }; }; } & { message: LLMHistoryDerives; callback_query: LLMHistoryDerives; }, {}>; export {}; //# sourceMappingURL=llm.d.ts.map