@llblab/pi-telegram 0.2.9 → 0.3.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 +40 -26
- package/docs/architecture.md +62 -35
- package/index.ts +388 -1936
- package/lib/api.ts +647 -76
- package/lib/attachments.ts +128 -16
- package/lib/commands.ts +721 -0
- package/lib/config.ts +157 -0
- package/lib/media.ts +211 -36
- package/lib/menu.ts +920 -338
- package/lib/model.ts +647 -0
- package/lib/pi.ts +80 -0
- package/lib/polling.ts +264 -18
- package/lib/preview.ts +451 -29
- package/lib/queue.ts +1134 -110
- package/lib/registration.ts +127 -28
- package/lib/rendering.ts +575 -281
- package/lib/replies.ts +198 -8
- package/lib/runtime.ts +475 -0
- package/lib/setup.ts +129 -1
- package/lib/status.ts +428 -13
- package/lib/turns.ts +207 -17
- package/lib/updates.ts +392 -99
- package/package.json +18 -3
- package/AGENTS.md +0 -91
- package/BACKLOG.md +0 -5
- package/CHANGELOG.md +0 -23
- package/lib/model-switch.ts +0 -62
- package/tests/api.test.ts +0 -89
- package/tests/attachments.test.ts +0 -132
- package/tests/config.test.ts +0 -80
- package/tests/media.test.ts +0 -77
- package/tests/menu.test.ts +0 -676
- package/tests/polling.test.ts +0 -129
- package/tests/preview.test.ts +0 -441
- package/tests/queue.test.ts +0 -3245
- package/tests/registration.test.ts +0 -268
- package/tests/rendering.test.ts +0 -475
- package/tests/replies.test.ts +0 -142
- package/tests/turns.test.ts +0 -132
- package/tests/updates.test.ts +0 -357
package/lib/model.ts
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram model control domain helpers
|
|
3
|
+
* Owns model identity, thinking levels, scoped resolution, current-model state, and in-flight model switching
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PendingTelegramTurn } from "./queue.ts";
|
|
7
|
+
import { TELEGRAM_PREFIX } from "./turns.ts";
|
|
8
|
+
|
|
9
|
+
export interface MenuModel {
|
|
10
|
+
provider: string;
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
reasoning?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ThinkingLevel =
|
|
17
|
+
| "off"
|
|
18
|
+
| "minimal"
|
|
19
|
+
| "low"
|
|
20
|
+
| "medium"
|
|
21
|
+
| "high"
|
|
22
|
+
| "xhigh";
|
|
23
|
+
|
|
24
|
+
export interface ScopedTelegramModel<TModel extends MenuModel = MenuModel> {
|
|
25
|
+
model: TModel;
|
|
26
|
+
thinkingLevel?: ThinkingLevel;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
30
|
+
"off",
|
|
31
|
+
"minimal",
|
|
32
|
+
"low",
|
|
33
|
+
"medium",
|
|
34
|
+
"high",
|
|
35
|
+
"xhigh",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export interface CurrentModelStore<
|
|
39
|
+
TContext,
|
|
40
|
+
TModel extends MenuModel = MenuModel,
|
|
41
|
+
> {
|
|
42
|
+
get: (ctx: TContext) => TModel | undefined;
|
|
43
|
+
getStored: () => TModel | undefined;
|
|
44
|
+
set: (model: TModel | undefined) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CurrentModelUpdateRuntime<
|
|
48
|
+
TContext,
|
|
49
|
+
TModel extends MenuModel = MenuModel,
|
|
50
|
+
> {
|
|
51
|
+
setCurrentModel: (model: TModel | undefined, ctx: TContext) => void;
|
|
52
|
+
onModelSelect: (event: { model: TModel | undefined }, ctx: TContext) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type CurrentModelRuntime<
|
|
56
|
+
TContext,
|
|
57
|
+
TModel extends MenuModel = MenuModel,
|
|
58
|
+
> = CurrentModelStore<TContext, TModel> &
|
|
59
|
+
CurrentModelUpdateRuntime<TContext, TModel>;
|
|
60
|
+
|
|
61
|
+
export function createCurrentModelStore<
|
|
62
|
+
TContext,
|
|
63
|
+
TModel extends MenuModel = MenuModel,
|
|
64
|
+
>(
|
|
65
|
+
getContextModel: (ctx: TContext) => TModel | undefined,
|
|
66
|
+
): CurrentModelStore<TContext, TModel> {
|
|
67
|
+
let currentModel: TModel | undefined;
|
|
68
|
+
return {
|
|
69
|
+
get: (ctx) => currentModel ?? getContextModel(ctx),
|
|
70
|
+
getStored: () => currentModel,
|
|
71
|
+
set: (model) => {
|
|
72
|
+
currentModel = model;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createCurrentModelUpdateRuntime<
|
|
78
|
+
TContext,
|
|
79
|
+
TModel extends MenuModel = MenuModel,
|
|
80
|
+
>(deps: {
|
|
81
|
+
setCurrentModel: (model: TModel | undefined) => void;
|
|
82
|
+
updateStatus: (ctx: TContext) => void;
|
|
83
|
+
}): CurrentModelUpdateRuntime<TContext, TModel> {
|
|
84
|
+
const setAndUpdate = (model: TModel | undefined, ctx: TContext): void => {
|
|
85
|
+
deps.setCurrentModel(model);
|
|
86
|
+
deps.updateStatus(ctx);
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
setCurrentModel: setAndUpdate,
|
|
90
|
+
onModelSelect: (event, ctx) => {
|
|
91
|
+
setAndUpdate(event.model, ctx);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createCurrentModelRuntime<
|
|
97
|
+
TContext,
|
|
98
|
+
TModel extends MenuModel = MenuModel,
|
|
99
|
+
>(deps: {
|
|
100
|
+
getContextModel: (ctx: TContext) => TModel | undefined;
|
|
101
|
+
updateStatus: (ctx: TContext) => void;
|
|
102
|
+
}): CurrentModelRuntime<TContext, TModel> {
|
|
103
|
+
const store = createCurrentModelStore(deps.getContextModel);
|
|
104
|
+
return {
|
|
105
|
+
...store,
|
|
106
|
+
...createCurrentModelUpdateRuntime({
|
|
107
|
+
setCurrentModel: store.set,
|
|
108
|
+
updateStatus: deps.updateStatus,
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function modelsMatch(
|
|
114
|
+
a: Pick<MenuModel, "provider" | "id"> | undefined,
|
|
115
|
+
b: Pick<MenuModel, "provider" | "id"> | undefined,
|
|
116
|
+
): boolean {
|
|
117
|
+
return !!a && !!b && a.provider === b.provider && a.id === b.id;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getCanonicalModelId(
|
|
121
|
+
model: Pick<MenuModel, "provider" | "id">,
|
|
122
|
+
): string {
|
|
123
|
+
return `${model.provider}/${model.id}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isThinkingLevel(value: string): value is ThinkingLevel {
|
|
127
|
+
return THINKING_LEVELS.includes(value as ThinkingLevel);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseTelegramScopedModelPatternList(value: string): string[] {
|
|
131
|
+
return value
|
|
132
|
+
.split(",")
|
|
133
|
+
.map((pattern) => pattern.trim())
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function parseTelegramCliScopedModelPatterns(
|
|
138
|
+
args: string[],
|
|
139
|
+
): string[] | undefined {
|
|
140
|
+
for (let i = 0; i < args.length; i++) {
|
|
141
|
+
const arg = args[i];
|
|
142
|
+
if (arg === "--models") {
|
|
143
|
+
const patterns = parseTelegramScopedModelPatternList(args[i + 1] ?? "");
|
|
144
|
+
return patterns.length > 0 ? patterns : undefined;
|
|
145
|
+
}
|
|
146
|
+
if (arg.startsWith("--models=")) {
|
|
147
|
+
const patterns = parseTelegramScopedModelPatternList(
|
|
148
|
+
arg.slice("--models=".length),
|
|
149
|
+
);
|
|
150
|
+
return patterns.length > 0 ? patterns : undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function escapeRegex(text: string): string {
|
|
157
|
+
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function globMatches(text: string, pattern: string): boolean {
|
|
161
|
+
let regex = "^";
|
|
162
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
163
|
+
const char = pattern[i];
|
|
164
|
+
if (char === "*") {
|
|
165
|
+
regex += ".*";
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (char === "?") {
|
|
169
|
+
regex += ".";
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (char === "[") {
|
|
173
|
+
const end = pattern.indexOf("]", i + 1);
|
|
174
|
+
if (end !== -1) {
|
|
175
|
+
const content = pattern.slice(i + 1, end);
|
|
176
|
+
regex += content.startsWith("!")
|
|
177
|
+
? `[^${content.slice(1)}]`
|
|
178
|
+
: `[${content}]`;
|
|
179
|
+
i = end;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
regex += escapeRegex(char);
|
|
184
|
+
}
|
|
185
|
+
regex += "$";
|
|
186
|
+
return new RegExp(regex, "i").test(text);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isAliasModelId(id: string): boolean {
|
|
190
|
+
if (id.endsWith("-latest")) return true;
|
|
191
|
+
return !/-\d{8}$/.test(id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function findExactModelReferenceMatch<TModel extends MenuModel = MenuModel>(
|
|
195
|
+
modelReference: string,
|
|
196
|
+
availableModels: TModel[],
|
|
197
|
+
): TModel | undefined {
|
|
198
|
+
const trimmedReference = modelReference.trim();
|
|
199
|
+
if (!trimmedReference) return undefined;
|
|
200
|
+
const normalizedReference = trimmedReference.toLowerCase();
|
|
201
|
+
const canonicalMatches = availableModels.filter(
|
|
202
|
+
(model) => getCanonicalModelId(model).toLowerCase() === normalizedReference,
|
|
203
|
+
);
|
|
204
|
+
if (canonicalMatches.length === 1) return canonicalMatches[0];
|
|
205
|
+
if (canonicalMatches.length > 1) return undefined;
|
|
206
|
+
const slashIndex = trimmedReference.indexOf("/");
|
|
207
|
+
if (slashIndex !== -1) {
|
|
208
|
+
const provider = trimmedReference.substring(0, slashIndex).trim();
|
|
209
|
+
const modelId = trimmedReference.substring(slashIndex + 1).trim();
|
|
210
|
+
if (provider && modelId) {
|
|
211
|
+
const providerMatches = availableModels.filter(
|
|
212
|
+
(model) =>
|
|
213
|
+
model.provider.toLowerCase() === provider.toLowerCase() &&
|
|
214
|
+
model.id.toLowerCase() === modelId.toLowerCase(),
|
|
215
|
+
);
|
|
216
|
+
if (providerMatches.length === 1) return providerMatches[0];
|
|
217
|
+
if (providerMatches.length > 1) return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const idMatches = availableModels.filter(
|
|
221
|
+
(model) => model.id.toLowerCase() === normalizedReference,
|
|
222
|
+
);
|
|
223
|
+
return idMatches.length === 1 ? idMatches[0] : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function tryMatchScopedModel<TModel extends MenuModel = MenuModel>(
|
|
227
|
+
modelPattern: string,
|
|
228
|
+
availableModels: TModel[],
|
|
229
|
+
): TModel | undefined {
|
|
230
|
+
const exactMatch = findExactModelReferenceMatch(
|
|
231
|
+
modelPattern,
|
|
232
|
+
availableModels,
|
|
233
|
+
);
|
|
234
|
+
if (exactMatch) return exactMatch;
|
|
235
|
+
const matches = availableModels.filter(
|
|
236
|
+
(model) =>
|
|
237
|
+
model.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
|
|
238
|
+
model.name?.toLowerCase().includes(modelPattern.toLowerCase()),
|
|
239
|
+
);
|
|
240
|
+
if (matches.length === 0) return undefined;
|
|
241
|
+
const aliases = matches.filter((model) => isAliasModelId(model.id));
|
|
242
|
+
const datedVersions = matches.filter((model) => !isAliasModelId(model.id));
|
|
243
|
+
if (aliases.length > 0) {
|
|
244
|
+
aliases.sort((a, b) => b.id.localeCompare(a.id));
|
|
245
|
+
return aliases[0];
|
|
246
|
+
}
|
|
247
|
+
datedVersions.sort((a, b) => b.id.localeCompare(a.id));
|
|
248
|
+
return datedVersions[0];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseScopedModelPattern<TModel extends MenuModel = MenuModel>(
|
|
252
|
+
pattern: string,
|
|
253
|
+
availableModels: TModel[],
|
|
254
|
+
): { model: TModel | undefined; thinkingLevel?: ThinkingLevel } {
|
|
255
|
+
const exactMatch = tryMatchScopedModel(pattern, availableModels);
|
|
256
|
+
if (exactMatch) {
|
|
257
|
+
return { model: exactMatch, thinkingLevel: undefined };
|
|
258
|
+
}
|
|
259
|
+
const lastColonIndex = pattern.lastIndexOf(":");
|
|
260
|
+
if (lastColonIndex === -1) {
|
|
261
|
+
return { model: undefined, thinkingLevel: undefined };
|
|
262
|
+
}
|
|
263
|
+
const prefix = pattern.substring(0, lastColonIndex);
|
|
264
|
+
const suffix = pattern.substring(lastColonIndex + 1);
|
|
265
|
+
if (isThinkingLevel(suffix)) {
|
|
266
|
+
const parsedPrefix = parseScopedModelPattern(prefix, availableModels);
|
|
267
|
+
if (parsedPrefix.model) {
|
|
268
|
+
return { model: parsedPrefix.model, thinkingLevel: suffix };
|
|
269
|
+
}
|
|
270
|
+
return parsedPrefix;
|
|
271
|
+
}
|
|
272
|
+
return parseScopedModelPattern(prefix, availableModels);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function resolveScopedModelPatterns<
|
|
276
|
+
TModel extends MenuModel = MenuModel,
|
|
277
|
+
>(
|
|
278
|
+
patterns: string[],
|
|
279
|
+
availableModels: TModel[],
|
|
280
|
+
): ScopedTelegramModel<TModel>[] {
|
|
281
|
+
const resolved: ScopedTelegramModel<TModel>[] = [];
|
|
282
|
+
const seen = new Set<string>();
|
|
283
|
+
for (const pattern of patterns) {
|
|
284
|
+
if (
|
|
285
|
+
pattern.includes("*") ||
|
|
286
|
+
pattern.includes("?") ||
|
|
287
|
+
pattern.includes("[")
|
|
288
|
+
) {
|
|
289
|
+
const colonIndex = pattern.lastIndexOf(":");
|
|
290
|
+
let globPattern = pattern;
|
|
291
|
+
let thinkingLevel: ThinkingLevel | undefined;
|
|
292
|
+
if (colonIndex !== -1) {
|
|
293
|
+
const suffix = pattern.substring(colonIndex + 1);
|
|
294
|
+
if (isThinkingLevel(suffix)) {
|
|
295
|
+
thinkingLevel = suffix;
|
|
296
|
+
globPattern = pattern.substring(0, colonIndex);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const matches = availableModels.filter(
|
|
300
|
+
(model) =>
|
|
301
|
+
globMatches(getCanonicalModelId(model), globPattern) ||
|
|
302
|
+
globMatches(model.id, globPattern),
|
|
303
|
+
);
|
|
304
|
+
for (const model of matches) {
|
|
305
|
+
const key = getCanonicalModelId(model);
|
|
306
|
+
if (seen.has(key)) continue;
|
|
307
|
+
seen.add(key);
|
|
308
|
+
resolved.push({ model, thinkingLevel });
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const matched = parseScopedModelPattern(pattern, availableModels);
|
|
313
|
+
if (!matched.model) continue;
|
|
314
|
+
const key = getCanonicalModelId(matched.model);
|
|
315
|
+
if (seen.has(key)) continue;
|
|
316
|
+
seen.add(key);
|
|
317
|
+
resolved.push({
|
|
318
|
+
model: matched.model,
|
|
319
|
+
thinkingLevel: matched.thinkingLevel,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return resolved;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function sortScopedModels<TModel extends MenuModel = MenuModel>(
|
|
326
|
+
models: ScopedTelegramModel<TModel>[],
|
|
327
|
+
currentModel: TModel | undefined,
|
|
328
|
+
): ScopedTelegramModel<TModel>[] {
|
|
329
|
+
const sorted = [...models];
|
|
330
|
+
sorted.sort((a, b) => {
|
|
331
|
+
const aIsCurrent = modelsMatch(a.model, currentModel);
|
|
332
|
+
const bIsCurrent = modelsMatch(b.model, currentModel);
|
|
333
|
+
if (aIsCurrent && !bIsCurrent) return -1;
|
|
334
|
+
if (!aIsCurrent && bIsCurrent) return 1;
|
|
335
|
+
const providerCompare = a.model.provider.localeCompare(b.model.provider);
|
|
336
|
+
if (providerCompare !== 0) return providerCompare;
|
|
337
|
+
return a.model.id.localeCompare(b.model.id);
|
|
338
|
+
});
|
|
339
|
+
return sorted;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- In-Flight Model Switching ---
|
|
343
|
+
|
|
344
|
+
export interface PendingModelSwitchStore<TSelection> {
|
|
345
|
+
get: () => TSelection | undefined;
|
|
346
|
+
set: (selection: TSelection | undefined) => void;
|
|
347
|
+
clear: () => void;
|
|
348
|
+
has: () => boolean;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface TelegramInFlightModelSwitchState {
|
|
352
|
+
isIdle: boolean;
|
|
353
|
+
hasActiveTelegramTurn: boolean;
|
|
354
|
+
hasAbortHandler: boolean;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function createPendingModelSwitchStore<
|
|
358
|
+
TSelection,
|
|
359
|
+
>(): PendingModelSwitchStore<TSelection> {
|
|
360
|
+
let selection: TSelection | undefined;
|
|
361
|
+
return {
|
|
362
|
+
get: () => selection,
|
|
363
|
+
set: (nextSelection) => {
|
|
364
|
+
selection = nextSelection;
|
|
365
|
+
},
|
|
366
|
+
clear: () => {
|
|
367
|
+
selection = undefined;
|
|
368
|
+
},
|
|
369
|
+
has: () => selection !== undefined,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function canRestartTelegramTurnForModelSwitch(
|
|
374
|
+
state: TelegramInFlightModelSwitchState,
|
|
375
|
+
): boolean {
|
|
376
|
+
return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function shouldTriggerPendingTelegramModelSwitchAbort(state: {
|
|
380
|
+
hasPendingModelSwitch: boolean;
|
|
381
|
+
hasActiveTelegramTurn: boolean;
|
|
382
|
+
hasAbortHandler: boolean;
|
|
383
|
+
activeToolExecutions: number;
|
|
384
|
+
}): boolean {
|
|
385
|
+
return (
|
|
386
|
+
state.hasPendingModelSwitch &&
|
|
387
|
+
state.hasActiveTelegramTurn &&
|
|
388
|
+
state.hasAbortHandler &&
|
|
389
|
+
state.activeToolExecutions === 0
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function restartTelegramModelSwitchContinuation<
|
|
394
|
+
TTurn,
|
|
395
|
+
TSelection,
|
|
396
|
+
>(state: {
|
|
397
|
+
activeTurn: TTurn | undefined;
|
|
398
|
+
abort: (() => void) | undefined;
|
|
399
|
+
selection: TSelection;
|
|
400
|
+
queueContinuation: (turn: TTurn, selection: TSelection) => void;
|
|
401
|
+
}): boolean {
|
|
402
|
+
if (!state.activeTurn || !state.abort) return false;
|
|
403
|
+
state.queueContinuation(state.activeTurn, state.selection);
|
|
404
|
+
state.abort();
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function truncateTelegramModelSwitchStatusSummary(
|
|
409
|
+
text: string,
|
|
410
|
+
maxWords = 4,
|
|
411
|
+
maxLength = 32,
|
|
412
|
+
): string {
|
|
413
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
414
|
+
if (!normalized) return "";
|
|
415
|
+
const words = normalized.split(" ");
|
|
416
|
+
let summary = words.slice(0, maxWords).join(" ");
|
|
417
|
+
if (summary.length === 0) summary = normalized;
|
|
418
|
+
if (summary.length > maxLength) {
|
|
419
|
+
summary = summary.slice(0, maxLength).trimEnd();
|
|
420
|
+
}
|
|
421
|
+
return summary.length < normalized.length || words.length > maxWords
|
|
422
|
+
? `${summary}…`
|
|
423
|
+
: summary;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function buildTelegramModelSwitchContinuationText<
|
|
427
|
+
TModel extends MenuModel,
|
|
428
|
+
>(
|
|
429
|
+
telegramPrefix: string,
|
|
430
|
+
model: TModel,
|
|
431
|
+
thinkingLevel?: ScopedTelegramModel<TModel>["thinkingLevel"],
|
|
432
|
+
): string {
|
|
433
|
+
const modelLabel = `${model.provider}/${model.id}`;
|
|
434
|
+
const thinkingSuffix = thinkingLevel
|
|
435
|
+
? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.`
|
|
436
|
+
: "";
|
|
437
|
+
return `${telegramPrefix} Continue the interrupted previous Telegram request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function buildTelegramModelSwitchContinuationTurn<
|
|
441
|
+
TModel extends MenuModel,
|
|
442
|
+
>(options: {
|
|
443
|
+
turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId">;
|
|
444
|
+
selection: ScopedTelegramModel<TModel>;
|
|
445
|
+
telegramPrefix?: string;
|
|
446
|
+
queueOrder: number;
|
|
447
|
+
laneOrder: number;
|
|
448
|
+
}): PendingTelegramTurn {
|
|
449
|
+
const modelLabel = `${options.selection.model.provider}/${options.selection.model.id}`;
|
|
450
|
+
const statusLabel = truncateTelegramModelSwitchStatusSummary(
|
|
451
|
+
`continue on ${options.selection.model.id}`,
|
|
452
|
+
);
|
|
453
|
+
return {
|
|
454
|
+
kind: "prompt",
|
|
455
|
+
chatId: options.turn.chatId,
|
|
456
|
+
replyToMessageId: options.turn.replyToMessageId,
|
|
457
|
+
sourceMessageIds: [],
|
|
458
|
+
queueOrder: options.queueOrder,
|
|
459
|
+
queueLane: "control",
|
|
460
|
+
laneOrder: options.laneOrder,
|
|
461
|
+
queuedAttachments: [],
|
|
462
|
+
content: [
|
|
463
|
+
{
|
|
464
|
+
type: "text",
|
|
465
|
+
text: buildTelegramModelSwitchContinuationText(
|
|
466
|
+
options.telegramPrefix ?? TELEGRAM_PREFIX,
|
|
467
|
+
options.selection.model,
|
|
468
|
+
options.selection.thinkingLevel,
|
|
469
|
+
),
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
historyText: `Continue interrupted Telegram request on ${modelLabel}`,
|
|
473
|
+
statusSummary: `↻ ${statusLabel || "continue"}`,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function createTelegramModelSwitchContinuationTurnBuilder<
|
|
478
|
+
TModel extends MenuModel,
|
|
479
|
+
>(deps: {
|
|
480
|
+
telegramPrefix?: string;
|
|
481
|
+
allocateItemOrder: () => number;
|
|
482
|
+
allocateControlOrder: () => number;
|
|
483
|
+
}): (options: {
|
|
484
|
+
turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId">;
|
|
485
|
+
selection: ScopedTelegramModel<TModel>;
|
|
486
|
+
}) => PendingTelegramTurn {
|
|
487
|
+
return (options) =>
|
|
488
|
+
buildTelegramModelSwitchContinuationTurn({
|
|
489
|
+
...options,
|
|
490
|
+
telegramPrefix: deps.telegramPrefix,
|
|
491
|
+
queueOrder: deps.allocateItemOrder(),
|
|
492
|
+
laneOrder: deps.allocateControlOrder(),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function createTelegramModelSwitchContinuationQueue<
|
|
497
|
+
TContext,
|
|
498
|
+
TSelection extends ScopedTelegramModel,
|
|
499
|
+
>(deps: {
|
|
500
|
+
createContinuationTurn: (options: {
|
|
501
|
+
turn: Pick<PendingTelegramTurn, "chatId" | "replyToMessageId">;
|
|
502
|
+
selection: TSelection;
|
|
503
|
+
}) => PendingTelegramTurn;
|
|
504
|
+
appendQueuedItem: (item: PendingTelegramTurn, ctx: TContext) => void;
|
|
505
|
+
}): (turn: PendingTelegramTurn, selection: TSelection, ctx: TContext) => void {
|
|
506
|
+
return (turn, selection, ctx) => {
|
|
507
|
+
deps.appendQueuedItem(
|
|
508
|
+
deps.createContinuationTurn({ turn, selection }),
|
|
509
|
+
ctx,
|
|
510
|
+
);
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function createTelegramModelSwitchContinuationQueueRuntime<
|
|
515
|
+
TContext,
|
|
516
|
+
TSelection extends ScopedTelegramModel,
|
|
517
|
+
>(deps: {
|
|
518
|
+
telegramPrefix?: string;
|
|
519
|
+
allocateItemOrder: () => number;
|
|
520
|
+
allocateControlOrder: () => number;
|
|
521
|
+
appendQueuedItem: (item: PendingTelegramTurn, ctx: TContext) => void;
|
|
522
|
+
}): (turn: PendingTelegramTurn, selection: TSelection, ctx: TContext) => void {
|
|
523
|
+
return createTelegramModelSwitchContinuationQueue<TContext, TSelection>({
|
|
524
|
+
createContinuationTurn: createTelegramModelSwitchContinuationTurnBuilder({
|
|
525
|
+
telegramPrefix: deps.telegramPrefix,
|
|
526
|
+
allocateItemOrder: deps.allocateItemOrder,
|
|
527
|
+
allocateControlOrder: deps.allocateControlOrder,
|
|
528
|
+
}),
|
|
529
|
+
appendQueuedItem: deps.appendQueuedItem,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export interface TelegramModelSwitchControllerDeps<TContext, TSelection> {
|
|
534
|
+
isIdle: (ctx: TContext) => boolean;
|
|
535
|
+
getPendingModelSwitch: () => TSelection | undefined;
|
|
536
|
+
setPendingModelSwitch: (selection: TSelection | undefined) => void;
|
|
537
|
+
getActiveTurn: () => PendingTelegramTurn | undefined;
|
|
538
|
+
getAbortHandler: () => (() => void) | undefined;
|
|
539
|
+
hasAbortHandler: () => boolean;
|
|
540
|
+
getActiveToolExecutions: () => number;
|
|
541
|
+
queueContinuation: (
|
|
542
|
+
turn: PendingTelegramTurn,
|
|
543
|
+
selection: TSelection,
|
|
544
|
+
ctx: TContext,
|
|
545
|
+
) => void;
|
|
546
|
+
updateStatus: (ctx: TContext) => void;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export interface TelegramModelSwitchController<TContext, TSelection> {
|
|
550
|
+
canOfferInFlightSwitch: (ctx: TContext) => boolean;
|
|
551
|
+
stagePendingSwitch: (selection: TSelection, ctx: TContext) => void;
|
|
552
|
+
clearPendingSwitch: () => void;
|
|
553
|
+
queueContinuation: (
|
|
554
|
+
turn: PendingTelegramTurn,
|
|
555
|
+
selection: TSelection,
|
|
556
|
+
ctx: TContext,
|
|
557
|
+
) => void;
|
|
558
|
+
triggerPendingAbort: (ctx: TContext) => boolean;
|
|
559
|
+
restartInterruptedTurn: (selection: TSelection, ctx: TContext) => boolean;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export interface TelegramModelSwitchControllerRuntimeDeps<
|
|
563
|
+
TContext,
|
|
564
|
+
TSelection extends ScopedTelegramModel,
|
|
565
|
+
> extends Omit<
|
|
566
|
+
TelegramModelSwitchControllerDeps<TContext, TSelection>,
|
|
567
|
+
"queueContinuation"
|
|
568
|
+
> {
|
|
569
|
+
telegramPrefix?: string;
|
|
570
|
+
allocateItemOrder: () => number;
|
|
571
|
+
allocateControlOrder: () => number;
|
|
572
|
+
appendQueuedItem: (item: PendingTelegramTurn, ctx: TContext) => void;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function createTelegramModelSwitchControllerRuntime<
|
|
576
|
+
TContext,
|
|
577
|
+
TSelection extends ScopedTelegramModel,
|
|
578
|
+
>(
|
|
579
|
+
deps: TelegramModelSwitchControllerRuntimeDeps<TContext, TSelection>,
|
|
580
|
+
): TelegramModelSwitchController<TContext, TSelection> {
|
|
581
|
+
return createTelegramModelSwitchController({
|
|
582
|
+
isIdle: deps.isIdle,
|
|
583
|
+
getPendingModelSwitch: deps.getPendingModelSwitch,
|
|
584
|
+
setPendingModelSwitch: deps.setPendingModelSwitch,
|
|
585
|
+
getActiveTurn: deps.getActiveTurn,
|
|
586
|
+
getAbortHandler: deps.getAbortHandler,
|
|
587
|
+
hasAbortHandler: deps.hasAbortHandler,
|
|
588
|
+
getActiveToolExecutions: deps.getActiveToolExecutions,
|
|
589
|
+
queueContinuation: createTelegramModelSwitchContinuationQueueRuntime({
|
|
590
|
+
telegramPrefix: deps.telegramPrefix,
|
|
591
|
+
allocateItemOrder: deps.allocateItemOrder,
|
|
592
|
+
allocateControlOrder: deps.allocateControlOrder,
|
|
593
|
+
appendQueuedItem: deps.appendQueuedItem,
|
|
594
|
+
}),
|
|
595
|
+
updateStatus: deps.updateStatus,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function createTelegramModelSwitchController<TContext, TSelection>(
|
|
600
|
+
deps: TelegramModelSwitchControllerDeps<TContext, TSelection>,
|
|
601
|
+
): TelegramModelSwitchController<TContext, TSelection> {
|
|
602
|
+
return {
|
|
603
|
+
canOfferInFlightSwitch: (ctx) =>
|
|
604
|
+
canRestartTelegramTurnForModelSwitch({
|
|
605
|
+
isIdle: deps.isIdle(ctx),
|
|
606
|
+
hasActiveTelegramTurn: !!deps.getActiveTurn(),
|
|
607
|
+
hasAbortHandler: deps.hasAbortHandler(),
|
|
608
|
+
}),
|
|
609
|
+
stagePendingSwitch: (selection, ctx) => {
|
|
610
|
+
deps.setPendingModelSwitch(selection);
|
|
611
|
+
deps.updateStatus(ctx);
|
|
612
|
+
},
|
|
613
|
+
clearPendingSwitch: () => {
|
|
614
|
+
deps.setPendingModelSwitch(undefined);
|
|
615
|
+
},
|
|
616
|
+
queueContinuation: deps.queueContinuation,
|
|
617
|
+
triggerPendingAbort: (ctx) => {
|
|
618
|
+
if (
|
|
619
|
+
!shouldTriggerPendingTelegramModelSwitchAbort({
|
|
620
|
+
hasPendingModelSwitch: !!deps.getPendingModelSwitch(),
|
|
621
|
+
hasActiveTelegramTurn: !!deps.getActiveTurn(),
|
|
622
|
+
hasAbortHandler: deps.hasAbortHandler(),
|
|
623
|
+
activeToolExecutions: deps.getActiveToolExecutions(),
|
|
624
|
+
})
|
|
625
|
+
) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
const selection = deps.getPendingModelSwitch();
|
|
629
|
+
const turn = deps.getActiveTurn();
|
|
630
|
+
const abort = deps.getAbortHandler();
|
|
631
|
+
if (!selection || !turn || !abort) return false;
|
|
632
|
+
deps.setPendingModelSwitch(undefined);
|
|
633
|
+
deps.queueContinuation(turn, selection, ctx);
|
|
634
|
+
abort();
|
|
635
|
+
return true;
|
|
636
|
+
},
|
|
637
|
+
restartInterruptedTurn: (selection, ctx) =>
|
|
638
|
+
restartTelegramModelSwitchContinuation({
|
|
639
|
+
activeTurn: deps.getActiveTurn(),
|
|
640
|
+
abort: deps.getAbortHandler(),
|
|
641
|
+
selection,
|
|
642
|
+
queueContinuation: (turn, nextSelection) => {
|
|
643
|
+
deps.queueContinuation(turn, nextSelection, ctx);
|
|
644
|
+
},
|
|
645
|
+
}),
|
|
646
|
+
};
|
|
647
|
+
}
|