@llblab/pi-telegram 0.8.1 → 0.9.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/lib/menu-model.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
8
8
  import {
9
+ getCanonicalModelId,
9
10
  type MenuModel,
10
11
  modelsMatch,
11
12
  parseTelegramCliScopedModelPatterns,
@@ -29,7 +30,18 @@ export interface TelegramModelMenuState<TModel extends MenuModel = MenuModel> {
29
30
  scopedModels: ScopedTelegramModel<TModel>[];
30
31
  allModels: ScopedTelegramModel<TModel>[];
31
32
  note?: string;
32
- mode: "status" | "model" | "model-pages" | "thinking" | "queue";
33
+ selectedModelIndex?: number;
34
+ selectedModelKey?: string;
35
+ scopedModelPatterns?: string[];
36
+ canMutateScope?: boolean;
37
+ mode:
38
+ | "status"
39
+ | "model"
40
+ | "model-pages"
41
+ | "model-detail"
42
+ | "thinking"
43
+ | "queue"
44
+ | "settings";
33
45
  }
34
46
 
35
47
  export interface StoredTelegramModelMenuState<
@@ -90,7 +102,9 @@ export interface TelegramModelMenuRuntimeOptions<
90
102
 
91
103
  export interface MenuSettingsManager {
92
104
  reload: () => Promise<void>;
105
+ flush?: () => Promise<void>;
93
106
  getEnabledModels: () => string[] | undefined;
107
+ setEnabledModels?: (patterns: string[] | undefined) => void;
94
108
  }
95
109
 
96
110
  export type TelegramModelMenuStateBuilderContext<
@@ -134,6 +148,7 @@ export type TelegramModelMenuCallbackDeps<
134
148
  ) => Promise<void>;
135
149
  updateModelMenuMessage: () => Promise<void>;
136
150
  updateStatusMessage: () => Promise<void>;
151
+ persistScopedModelPatterns?: (patterns: string[]) => Promise<void>;
137
152
  setModel: (model: TModel) => Promise<boolean>;
138
153
  setCurrentModel: (model: TModel) => void;
139
154
  setThinkingLevel: (level: ThinkingLevel) => void;
@@ -193,6 +208,7 @@ export type TelegramModelCallbackPlan<TModel extends MenuModel = MenuModel> =
193
208
  | { kind: "ignore" }
194
209
  | { kind: "answer"; text?: string }
195
210
  | { kind: "update-menu"; text?: string }
211
+ | { kind: "persist-scope"; patterns: string[]; text: string }
196
212
  | {
197
213
  kind: "refresh-status";
198
214
  selection: ScopedTelegramModel<TModel>;
@@ -226,6 +242,7 @@ export interface TelegramModelMenuRuntime<
226
242
  messageId: number | undefined,
227
243
  ) => TelegramModelMenuState<TModel> | undefined;
228
244
  clear: () => void;
245
+ clearCachedInputs: () => void;
229
246
  buildState: <TContext extends TelegramModelMenuRuntimeContext<TModel>>(
230
247
  options: Omit<
231
248
  TelegramModelMenuRuntimeOptions<TContext, TModel>,
@@ -236,8 +253,9 @@ export interface TelegramModelMenuRuntime<
236
253
 
237
254
  export const TELEGRAM_MODEL_PAGE_SIZE = 6;
238
255
  const TELEGRAM_MODEL_PAGE_PICKER_ROW_SIZE = 4;
239
- export const MODEL_MENU_TITLE = "<b>Choose a model:</b>";
256
+ export const MODEL_MENU_TITLE = "<b>🤖 Choose a model:</b>";
240
257
  export const MODEL_PAGE_MENU_TITLE = "<b>Choose a page:</b>";
258
+ export const MODEL_DETAIL_MENU_TITLE = "<b>🤖 Model:</b>";
241
259
 
242
260
  function truncateTelegramButtonLabel(label: string, maxLength = 56): string {
243
261
  return label.length <= maxLength
@@ -249,10 +267,21 @@ function getTelegramCliScopedModelPatterns(): string[] | undefined {
249
267
  return parseTelegramCliScopedModelPatterns(process.argv.slice(2));
250
268
  }
251
269
 
252
- function parseTelegramModelMenuCallbackAction(
253
- data: string | undefined,
254
- ):
255
- | { action: "noop" | "scope" | "page" | "pages" | "pick"; value?: string }
270
+ function parseTelegramModelMenuCallbackAction(data: string | undefined):
271
+ | {
272
+ action:
273
+ | "noop"
274
+ | "scope"
275
+ | "page"
276
+ | "pages"
277
+ | "open"
278
+ | "pick"
279
+ | "pick-selected"
280
+ | "scope-enable"
281
+ | "scope-disable"
282
+ | "scope-toggle";
283
+ value?: string;
284
+ }
256
285
  | undefined {
257
286
  if (!data?.startsWith("model:")) return undefined;
258
287
  const [, action, value] = data.split(":");
@@ -261,7 +290,12 @@ function parseTelegramModelMenuCallbackAction(
261
290
  action === "scope" ||
262
291
  action === "page" ||
263
292
  action === "pages" ||
264
- action === "pick"
293
+ action === "open" ||
294
+ action === "pick" ||
295
+ action === "pick-selected" ||
296
+ action === "scope-enable" ||
297
+ action === "scope-disable" ||
298
+ action === "scope-toggle"
265
299
  ) {
266
300
  return { action, value };
267
301
  }
@@ -311,7 +345,7 @@ export function formatScopedModelButtonText<
311
345
  entry: ScopedTelegramModel<TModel>,
312
346
  currentModel: TModel | undefined,
313
347
  ): string {
314
- let label = `${modelsMatch(entry.model, currentModel) ? " " : ""}${entry.model.id} [${entry.model.provider}]`;
348
+ let label = `${modelsMatch(entry.model, currentModel) ? "🟢 " : ""}${entry.model.id} [${entry.model.provider}]`;
315
349
  if (entry.thinkingLevel) {
316
350
  label += ` · ${entry.thinkingLevel}`;
317
351
  }
@@ -401,6 +435,9 @@ export function createTelegramModelMenuRuntime<
401
435
  menus.clear();
402
436
  cachedInputs = undefined;
403
437
  },
438
+ clearCachedInputs: () => {
439
+ cachedInputs = undefined;
440
+ },
404
441
  buildState: async (stateOptions) => {
405
442
  const result = await buildTelegramModelMenuStateRuntime({
406
443
  ...stateOptions,
@@ -490,6 +527,9 @@ export function buildTelegramModelMenuState<
490
527
  scopedModels,
491
528
  allModels,
492
529
  note,
530
+ selectedModelIndex: undefined,
531
+ scopedModelPatterns: params.configuredScopedModelPatterns,
532
+ canMutateScope: !params.cliScopedModelPatterns,
493
533
  mode: "status",
494
534
  };
495
535
  }
@@ -563,6 +603,115 @@ export function getTelegramModelSelection<TModel extends MenuModel = MenuModel>(
563
603
  return { kind: "selected", selection };
564
604
  }
565
605
 
606
+ export function applyTelegramModelDetailSelection(
607
+ state: TelegramModelMenuState,
608
+ value: string | undefined,
609
+ ): TelegramMenuMutationResult {
610
+ const index = Number(value);
611
+ if (!Number.isFinite(index)) return "invalid";
612
+ const selection = getModelMenuItems(state)[index];
613
+ if (!selection) return "invalid";
614
+ state.selectedModelIndex = index;
615
+ state.selectedModelKey = getCanonicalModelId(selection.model);
616
+ state.mode = "model-detail";
617
+ return "changed";
618
+ }
619
+
620
+ export function getTelegramSelectedDetailModel<
621
+ TModel extends MenuModel = MenuModel,
622
+ >(state: TelegramModelMenuState<TModel>): TelegramMenuSelectionResult<TModel> {
623
+ if (state.selectedModelKey) {
624
+ const lowerKey = state.selectedModelKey.toLowerCase();
625
+ const selection = state.allModels.find(
626
+ (entry) => getCanonicalModelId(entry.model).toLowerCase() === lowerKey,
627
+ );
628
+ if (selection) return { kind: "selected", selection };
629
+ }
630
+ return getTelegramModelSelection(state, state.selectedModelIndex?.toString());
631
+ }
632
+
633
+ export function isTelegramModelScoped(
634
+ state: TelegramModelMenuState,
635
+ model: MenuModel,
636
+ ): boolean {
637
+ const key = getCanonicalModelId(model);
638
+ return state.scopedModels.some(
639
+ (entry) => getCanonicalModelId(entry.model) === key,
640
+ );
641
+ }
642
+
643
+ export function focusTelegramModelListPage(
644
+ state: TelegramModelMenuState,
645
+ model: MenuModel,
646
+ pageSize = TELEGRAM_MODEL_PAGE_SIZE,
647
+ ): void {
648
+ const key = getCanonicalModelId(model).toLowerCase();
649
+ const index = getModelMenuItems(state).findIndex(
650
+ (entry) => getCanonicalModelId(entry.model).toLowerCase() === key,
651
+ );
652
+ state.page = index < 0 ? 0 : Math.floor(index / pageSize);
653
+ state.mode = "model";
654
+ }
655
+
656
+ function formatScopedModelPattern(entry: ScopedTelegramModel): string {
657
+ const key = getCanonicalModelId(entry.model);
658
+ return entry.thinkingLevel ? `${key}:${entry.thinkingLevel}` : key;
659
+ }
660
+
661
+ export function setTelegramModelScope(
662
+ state: TelegramModelMenuState,
663
+ model: MenuModel,
664
+ enabled: boolean,
665
+ ): { patterns: string[]; enabled: boolean } {
666
+ const key = getCanonicalModelId(model);
667
+ const lowerKey = key.toLowerCase();
668
+ const scopedModelPatterns = state.scopedModelPatterns ?? [];
669
+ const allModels = state.allModels.map((entry) => entry.model);
670
+ const patterns: string[] = [];
671
+ for (const pattern of scopedModelPatterns) {
672
+ const resolved = resolveScopedModelPatterns([pattern], allModels);
673
+ const matchesModel = resolved.some(
674
+ (entry) => getCanonicalModelId(entry.model).toLowerCase() === lowerKey,
675
+ );
676
+ if (enabled || !matchesModel) {
677
+ if (pattern.toLowerCase() !== lowerKey) patterns.push(pattern);
678
+ continue;
679
+ }
680
+ for (const entry of resolved) {
681
+ if (getCanonicalModelId(entry.model).toLowerCase() === lowerKey) continue;
682
+ const expandedPattern = formatScopedModelPattern(entry);
683
+ const duplicated = patterns.some(
684
+ (item) => item.toLowerCase() === expandedPattern.toLowerCase(),
685
+ );
686
+ if (!duplicated) patterns.push(expandedPattern);
687
+ }
688
+ }
689
+ if (enabled) patterns.push(key);
690
+ state.scopedModelPatterns = patterns;
691
+ state.scopedModels = sortScopedModels(
692
+ resolveScopedModelPatterns(
693
+ patterns,
694
+ state.allModels.map((entry) => entry.model),
695
+ ),
696
+ model,
697
+ );
698
+ if (state.scope === "scoped" && state.scopedModels.length === 0) {
699
+ state.scope = "all";
700
+ }
701
+ return { patterns, enabled };
702
+ }
703
+
704
+ export function toggleTelegramModelScope(
705
+ state: TelegramModelMenuState,
706
+ model: MenuModel,
707
+ ): { patterns: string[]; enabled: boolean } {
708
+ return setTelegramModelScope(
709
+ state,
710
+ model,
711
+ !isTelegramModelScoped(state, model),
712
+ );
713
+ }
714
+
566
715
  export function buildTelegramModelCallbackPlan<
567
716
  TModel extends MenuModel = MenuModel,
568
717
  >(
@@ -609,10 +758,55 @@ export function buildTelegramModelCallbackPlan<
609
758
  }
610
759
  return { kind: "update-menu" };
611
760
  }
612
- if (action.action !== "pick") {
761
+ if (action.action === "open") {
762
+ const detailResult = applyTelegramModelDetailSelection(
763
+ params.state,
764
+ action.value,
765
+ );
766
+ if (detailResult === "invalid") {
767
+ return { kind: "answer", text: "Selected model is no longer available." };
768
+ }
769
+ return { kind: "update-menu" };
770
+ }
771
+ if (
772
+ action.action === "scope-toggle" ||
773
+ action.action === "scope-enable" ||
774
+ action.action === "scope-disable"
775
+ ) {
776
+ if (params.state.canMutateScope === false) {
777
+ return {
778
+ kind: "answer",
779
+ text: "Model scope is controlled by CLI --models.",
780
+ };
781
+ }
782
+ const selectionResult = getTelegramSelectedDetailModel(params.state);
783
+ if (selectionResult.kind !== "selected") {
784
+ return { kind: "answer", text: "Selected model is no longer available." };
785
+ }
786
+ const model = selectionResult.selection.model;
787
+ const enabled =
788
+ action.action === "scope-toggle"
789
+ ? !isTelegramModelScoped(params.state, model)
790
+ : action.action === "scope-enable";
791
+ if (enabled === isTelegramModelScoped(params.state, model)) {
792
+ return { kind: "answer" };
793
+ }
794
+ const result = setTelegramModelScope(params.state, model, enabled);
795
+ return {
796
+ kind: "persist-scope",
797
+ patterns: result.patterns,
798
+ text: result.enabled
799
+ ? "Added to scoped models"
800
+ : "Removed from scoped models",
801
+ };
802
+ }
803
+ if (action.action !== "pick" && action.action !== "pick-selected") {
613
804
  return { kind: "answer" };
614
805
  }
615
- const selectionResult = getTelegramModelSelection(params.state, action.value);
806
+ const selectionResult =
807
+ action.action === "pick-selected"
808
+ ? getTelegramSelectedDetailModel(params.state)
809
+ : getTelegramModelSelection(params.state, action.value);
616
810
  if (selectionResult.kind === "invalid") {
617
811
  return { kind: "answer", text: "Invalid model selection." };
618
812
  }
@@ -621,6 +815,10 @@ export function buildTelegramModelCallbackPlan<
621
815
  }
622
816
  const selection = selectionResult.selection;
623
817
  if (modelsMatch(selection.model, params.activeModel)) {
818
+ if (action.action === "pick-selected") {
819
+ focusTelegramModelListPage(params.state, selection.model);
820
+ return { kind: "update-menu", text: `Model: ${selection.model.id}` };
821
+ }
624
822
  return {
625
823
  kind: "refresh-status",
626
824
  selection,
@@ -693,11 +891,24 @@ export async function handleTelegramModelMenuCallbackAction<
693
891
  await deps.answerCallbackQuery(callbackQueryId, plan.text);
694
892
  return true;
695
893
  }
894
+ if (plan.kind === "persist-scope") {
895
+ if (!deps.persistScopedModelPatterns) {
896
+ await deps.answerCallbackQuery(
897
+ callbackQueryId,
898
+ "Scoped model persistence is unavailable.",
899
+ );
900
+ return true;
901
+ }
902
+ await deps.persistScopedModelPatterns(plan.patterns);
903
+ await deps.updateModelMenuMessage();
904
+ await deps.answerCallbackQuery(callbackQueryId, plan.text);
905
+ return true;
906
+ }
696
907
  if (plan.kind === "refresh-status") {
697
908
  if (plan.shouldApplyThinkingLevel && plan.selection.thinkingLevel) {
698
909
  deps.setThinkingLevel(plan.selection.thinkingLevel);
699
910
  }
700
- await deps.updateStatusMessage();
911
+ await deps.updateModelMenuMessage();
701
912
  await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
702
913
  return true;
703
914
  }
@@ -710,7 +921,7 @@ export async function handleTelegramModelMenuCallbackAction<
710
921
  if (plan.selection.thinkingLevel) {
711
922
  deps.setThinkingLevel(plan.selection.thinkingLevel);
712
923
  }
713
- await deps.updateStatusMessage();
924
+ await deps.updateModelMenuMessage();
714
925
  if (plan.mode === "restart-after-tool") {
715
926
  deps.stagePendingModelSwitch(plan.selection);
716
927
  await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
@@ -756,11 +967,11 @@ export function buildModelMenuReplyMarkup(
756
967
  if (state.scopedModels.length > 0) {
757
968
  rows.push([
758
969
  {
759
- text: state.scope === "scoped" ? " Scoped" : "Scoped",
970
+ text: state.scope === "scoped" ? "🟡 Scoped" : "⚫️ Scoped",
760
971
  callback_data: "model:scope:scoped",
761
972
  },
762
973
  {
763
- text: state.scope === "all" ? " All" : "All",
974
+ text: state.scope === "all" ? "🟡 All" : "⚫️ All",
764
975
  callback_data: "model:scope:all",
765
976
  },
766
977
  ]);
@@ -790,13 +1001,60 @@ export function buildModelMenuReplyMarkup(
790
1001
  ...menuPage.items.map((entry, index) => [
791
1002
  {
792
1003
  text: formatScopedModelButtonText(entry, currentModel),
793
- callback_data: `model:pick:${menuPage.start + index}`,
1004
+ callback_data: `model:open:${menuPage.start + index}`,
794
1005
  },
795
1006
  ]),
796
1007
  );
797
1008
  return { inline_keyboard: rows };
798
1009
  }
799
1010
 
1011
+ export function buildModelDetailMenuReplyMarkup(
1012
+ state: TelegramModelMenuState,
1013
+ currentModel: MenuModel | undefined,
1014
+ ): TelegramReplyMarkup {
1015
+ const selection = getTelegramSelectedDetailModel(state);
1016
+ if (selection.kind !== "selected") {
1017
+ return {
1018
+ inline_keyboard: [
1019
+ [{ text: "⬆️ Back", callback_data: "model:pages:back" }],
1020
+ ],
1021
+ };
1022
+ }
1023
+ const model = selection.selection.model;
1024
+ const active = modelsMatch(model, currentModel);
1025
+ const scoped = isTelegramModelScoped(state, model);
1026
+ return {
1027
+ inline_keyboard: [
1028
+ [{ text: "⬆️ Back", callback_data: "model:pages:back" }],
1029
+ [
1030
+ {
1031
+ text: active ? "🟢 Active" : "☑️ Activate",
1032
+ callback_data: "model:pick-selected",
1033
+ },
1034
+ ],
1035
+ [
1036
+ {
1037
+ text: scoped ? "🟡 Scoped" : "⚫️ Scoped",
1038
+ callback_data: "model:scope-enable",
1039
+ },
1040
+ {
1041
+ text: scoped ? "⚫️ All" : "🟡 All",
1042
+ callback_data: "model:scope-disable",
1043
+ },
1044
+ ],
1045
+ ],
1046
+ };
1047
+ }
1048
+
1049
+ export function buildModelDetailMenuText(
1050
+ state: TelegramModelMenuState,
1051
+ ): string {
1052
+ const selection = getTelegramSelectedDetailModel(state);
1053
+ if (selection.kind !== "selected") return MODEL_DETAIL_MENU_TITLE;
1054
+ const model = selection.selection.model;
1055
+ return `${MODEL_DETAIL_MENU_TITLE}\n${getCanonicalModelId(model)}`;
1056
+ }
1057
+
800
1058
  export function buildModelPageMenuReplyMarkup(
801
1059
  state: TelegramModelMenuState,
802
1060
  pageSize: number,
@@ -816,10 +1074,16 @@ export function buildModelPageMenuReplyMarkup(
816
1074
  menuPage.pageCount - page,
817
1075
  ),
818
1076
  },
819
- (_unused, offset) => ({
820
- text: String(page + offset + 1),
821
- callback_data: `model:page:${page + offset}`,
822
- }),
1077
+ (_unused, offset) => {
1078
+ const pageIndex = page + offset;
1079
+ return {
1080
+ text:
1081
+ pageIndex === menuPage.page
1082
+ ? `🟢 ${pageIndex + 1}`
1083
+ : String(pageIndex + 1),
1084
+ callback_data: `model:page:${pageIndex}`,
1085
+ };
1086
+ },
823
1087
  ),
824
1088
  );
825
1089
  }
@@ -844,6 +1108,14 @@ export function buildTelegramModelMenuRenderPayload(
844
1108
  if (state.mode === "model-pages") {
845
1109
  return buildTelegramModelPageMenuRenderPayload(state);
846
1110
  }
1111
+ if (state.mode === "model-detail") {
1112
+ return {
1113
+ nextMode: "model-detail",
1114
+ text: buildModelDetailMenuText(state),
1115
+ mode: "html",
1116
+ replyMarkup: buildModelDetailMenuReplyMarkup(state, activeModel),
1117
+ };
1118
+ }
847
1119
  return {
848
1120
  nextMode: "model",
849
1121
  text: MODEL_MENU_TITLE,