@llblab/pi-telegram 0.8.2 → 0.9.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/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>,
@@ -238,6 +255,7 @@ export const TELEGRAM_MODEL_PAGE_SIZE = 6;
238
255
  const TELEGRAM_MODEL_PAGE_PICKER_ROW_SIZE = 4;
239
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,128 @@ 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
+ const indexedSelection = getTelegramModelSelection(
624
+ state,
625
+ state.selectedModelIndex?.toString(),
626
+ );
627
+ if (indexedSelection.kind === "selected") {
628
+ if (!state.selectedModelKey) return indexedSelection;
629
+ const indexedKey = getCanonicalModelId(
630
+ indexedSelection.selection.model,
631
+ ).toLowerCase();
632
+ if (indexedKey === state.selectedModelKey.toLowerCase()) {
633
+ return indexedSelection;
634
+ }
635
+ }
636
+ if (state.selectedModelKey) {
637
+ const lowerKey = state.selectedModelKey.toLowerCase();
638
+ const selection = state.allModels.find(
639
+ (entry) => getCanonicalModelId(entry.model).toLowerCase() === lowerKey,
640
+ );
641
+ if (selection) return { kind: "selected", selection };
642
+ }
643
+ return indexedSelection;
644
+ }
645
+
646
+ export function isTelegramModelScoped(
647
+ state: TelegramModelMenuState,
648
+ model: MenuModel,
649
+ ): boolean {
650
+ const key = getCanonicalModelId(model);
651
+ return state.scopedModels.some(
652
+ (entry) => getCanonicalModelId(entry.model) === key,
653
+ );
654
+ }
655
+
656
+ export function focusTelegramModelListPage(
657
+ state: TelegramModelMenuState,
658
+ model: MenuModel,
659
+ pageSize = TELEGRAM_MODEL_PAGE_SIZE,
660
+ ): void {
661
+ const key = getCanonicalModelId(model).toLowerCase();
662
+ const index = getModelMenuItems(state).findIndex(
663
+ (entry) => getCanonicalModelId(entry.model).toLowerCase() === key,
664
+ );
665
+ state.page = index < 0 ? 0 : Math.floor(index / pageSize);
666
+ state.mode = "model";
667
+ }
668
+
669
+ function formatScopedModelPattern(entry: ScopedTelegramModel): string {
670
+ const key = getCanonicalModelId(entry.model);
671
+ return entry.thinkingLevel ? `${key}:${entry.thinkingLevel}` : key;
672
+ }
673
+
674
+ export function setTelegramModelScope(
675
+ state: TelegramModelMenuState,
676
+ model: MenuModel,
677
+ enabled: boolean,
678
+ ): { patterns: string[]; enabled: boolean } {
679
+ const key = getCanonicalModelId(model);
680
+ const lowerKey = key.toLowerCase();
681
+ const scopedModelPatterns = state.scopedModelPatterns ?? [];
682
+ const allModels = state.allModels.map((entry) => entry.model);
683
+ const patterns: string[] = [];
684
+ for (const pattern of scopedModelPatterns) {
685
+ const resolved = resolveScopedModelPatterns([pattern], allModels);
686
+ const matchesModel = resolved.some(
687
+ (entry) => getCanonicalModelId(entry.model).toLowerCase() === lowerKey,
688
+ );
689
+ if (enabled || !matchesModel) {
690
+ if (pattern.toLowerCase() !== lowerKey) patterns.push(pattern);
691
+ continue;
692
+ }
693
+ for (const entry of resolved) {
694
+ if (getCanonicalModelId(entry.model).toLowerCase() === lowerKey) continue;
695
+ const expandedPattern = formatScopedModelPattern(entry);
696
+ const duplicated = patterns.some(
697
+ (item) => item.toLowerCase() === expandedPattern.toLowerCase(),
698
+ );
699
+ if (!duplicated) patterns.push(expandedPattern);
700
+ }
701
+ }
702
+ if (enabled) patterns.push(key);
703
+ state.scopedModelPatterns = patterns;
704
+ state.scopedModels = sortScopedModels(
705
+ resolveScopedModelPatterns(
706
+ patterns,
707
+ state.allModels.map((entry) => entry.model),
708
+ ),
709
+ model,
710
+ );
711
+ if (state.scope === "scoped" && state.scopedModels.length === 0) {
712
+ state.scope = "all";
713
+ }
714
+ return { patterns, enabled };
715
+ }
716
+
717
+ export function toggleTelegramModelScope(
718
+ state: TelegramModelMenuState,
719
+ model: MenuModel,
720
+ ): { patterns: string[]; enabled: boolean } {
721
+ return setTelegramModelScope(
722
+ state,
723
+ model,
724
+ !isTelegramModelScoped(state, model),
725
+ );
726
+ }
727
+
566
728
  export function buildTelegramModelCallbackPlan<
567
729
  TModel extends MenuModel = MenuModel,
568
730
  >(
@@ -609,10 +771,55 @@ export function buildTelegramModelCallbackPlan<
609
771
  }
610
772
  return { kind: "update-menu" };
611
773
  }
612
- if (action.action !== "pick") {
774
+ if (action.action === "open") {
775
+ const detailResult = applyTelegramModelDetailSelection(
776
+ params.state,
777
+ action.value,
778
+ );
779
+ if (detailResult === "invalid") {
780
+ return { kind: "answer", text: "Selected model is no longer available." };
781
+ }
782
+ return { kind: "update-menu" };
783
+ }
784
+ if (
785
+ action.action === "scope-toggle" ||
786
+ action.action === "scope-enable" ||
787
+ action.action === "scope-disable"
788
+ ) {
789
+ if (params.state.canMutateScope === false) {
790
+ return {
791
+ kind: "answer",
792
+ text: "Model scope is controlled by CLI --models.",
793
+ };
794
+ }
795
+ const selectionResult = getTelegramSelectedDetailModel(params.state);
796
+ if (selectionResult.kind !== "selected") {
797
+ return { kind: "answer", text: "Selected model is no longer available." };
798
+ }
799
+ const model = selectionResult.selection.model;
800
+ const enabled =
801
+ action.action === "scope-toggle"
802
+ ? !isTelegramModelScoped(params.state, model)
803
+ : action.action === "scope-enable";
804
+ if (enabled === isTelegramModelScoped(params.state, model)) {
805
+ return { kind: "answer" };
806
+ }
807
+ const result = setTelegramModelScope(params.state, model, enabled);
808
+ return {
809
+ kind: "persist-scope",
810
+ patterns: result.patterns,
811
+ text: result.enabled
812
+ ? "Added to scoped models"
813
+ : "Removed from scoped models",
814
+ };
815
+ }
816
+ if (action.action !== "pick" && action.action !== "pick-selected") {
613
817
  return { kind: "answer" };
614
818
  }
615
- const selectionResult = getTelegramModelSelection(params.state, action.value);
819
+ const selectionResult =
820
+ action.action === "pick-selected"
821
+ ? getTelegramSelectedDetailModel(params.state)
822
+ : getTelegramModelSelection(params.state, action.value);
616
823
  if (selectionResult.kind === "invalid") {
617
824
  return { kind: "answer", text: "Invalid model selection." };
618
825
  }
@@ -621,6 +828,9 @@ export function buildTelegramModelCallbackPlan<
621
828
  }
622
829
  const selection = selectionResult.selection;
623
830
  if (modelsMatch(selection.model, params.activeModel)) {
831
+ if (action.action === "pick-selected") {
832
+ focusTelegramModelListPage(params.state, selection.model);
833
+ }
624
834
  return {
625
835
  kind: "refresh-status",
626
836
  selection,
@@ -693,11 +903,24 @@ export async function handleTelegramModelMenuCallbackAction<
693
903
  await deps.answerCallbackQuery(callbackQueryId, plan.text);
694
904
  return true;
695
905
  }
906
+ if (plan.kind === "persist-scope") {
907
+ if (!deps.persistScopedModelPatterns) {
908
+ await deps.answerCallbackQuery(
909
+ callbackQueryId,
910
+ "Scoped model persistence is unavailable.",
911
+ );
912
+ return true;
913
+ }
914
+ await deps.persistScopedModelPatterns(plan.patterns);
915
+ await deps.updateModelMenuMessage();
916
+ await deps.answerCallbackQuery(callbackQueryId, plan.text);
917
+ return true;
918
+ }
696
919
  if (plan.kind === "refresh-status") {
697
920
  if (plan.shouldApplyThinkingLevel && plan.selection.thinkingLevel) {
698
921
  deps.setThinkingLevel(plan.selection.thinkingLevel);
699
922
  }
700
- await deps.updateStatusMessage();
923
+ await deps.updateModelMenuMessage();
701
924
  await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
702
925
  return true;
703
926
  }
@@ -710,7 +933,7 @@ export async function handleTelegramModelMenuCallbackAction<
710
933
  if (plan.selection.thinkingLevel) {
711
934
  deps.setThinkingLevel(plan.selection.thinkingLevel);
712
935
  }
713
- await deps.updateStatusMessage();
936
+ await deps.updateModelMenuMessage();
714
937
  if (plan.mode === "restart-after-tool") {
715
938
  deps.stagePendingModelSwitch(plan.selection);
716
939
  await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
@@ -756,11 +979,11 @@ export function buildModelMenuReplyMarkup(
756
979
  if (state.scopedModels.length > 0) {
757
980
  rows.push([
758
981
  {
759
- text: state.scope === "scoped" ? " Scoped" : "Scoped",
982
+ text: state.scope === "scoped" ? "🟡 Scoped" : "⚫️ Scoped",
760
983
  callback_data: "model:scope:scoped",
761
984
  },
762
985
  {
763
- text: state.scope === "all" ? " All" : "All",
986
+ text: state.scope === "all" ? "🟡 All" : "⚫️ All",
764
987
  callback_data: "model:scope:all",
765
988
  },
766
989
  ]);
@@ -790,13 +1013,60 @@ export function buildModelMenuReplyMarkup(
790
1013
  ...menuPage.items.map((entry, index) => [
791
1014
  {
792
1015
  text: formatScopedModelButtonText(entry, currentModel),
793
- callback_data: `model:pick:${menuPage.start + index}`,
1016
+ callback_data: `model:open:${menuPage.start + index}`,
794
1017
  },
795
1018
  ]),
796
1019
  );
797
1020
  return { inline_keyboard: rows };
798
1021
  }
799
1022
 
1023
+ export function buildModelDetailMenuReplyMarkup(
1024
+ state: TelegramModelMenuState,
1025
+ currentModel: MenuModel | undefined,
1026
+ ): TelegramReplyMarkup {
1027
+ const selection = getTelegramSelectedDetailModel(state);
1028
+ if (selection.kind !== "selected") {
1029
+ return {
1030
+ inline_keyboard: [
1031
+ [{ text: "⬆️ Back", callback_data: "model:pages:back" }],
1032
+ ],
1033
+ };
1034
+ }
1035
+ const model = selection.selection.model;
1036
+ const active = modelsMatch(model, currentModel);
1037
+ const scoped = isTelegramModelScoped(state, model);
1038
+ return {
1039
+ inline_keyboard: [
1040
+ [{ text: "⬆️ Back", callback_data: "model:pages:back" }],
1041
+ [
1042
+ {
1043
+ text: active ? "🟢 Active" : "☑️ Activate",
1044
+ callback_data: "model:pick-selected",
1045
+ },
1046
+ ],
1047
+ [
1048
+ {
1049
+ text: scoped ? "🟡 Scoped" : "⚫️ Scoped",
1050
+ callback_data: "model:scope-enable",
1051
+ },
1052
+ {
1053
+ text: scoped ? "⚫️ All" : "🟡 All",
1054
+ callback_data: "model:scope-disable",
1055
+ },
1056
+ ],
1057
+ ],
1058
+ };
1059
+ }
1060
+
1061
+ export function buildModelDetailMenuText(
1062
+ state: TelegramModelMenuState,
1063
+ ): string {
1064
+ const selection = getTelegramSelectedDetailModel(state);
1065
+ if (selection.kind !== "selected") return MODEL_DETAIL_MENU_TITLE;
1066
+ const model = selection.selection.model;
1067
+ return `${MODEL_DETAIL_MENU_TITLE}\n${getCanonicalModelId(model)}`;
1068
+ }
1069
+
800
1070
  export function buildModelPageMenuReplyMarkup(
801
1071
  state: TelegramModelMenuState,
802
1072
  pageSize: number,
@@ -816,10 +1086,16 @@ export function buildModelPageMenuReplyMarkup(
816
1086
  menuPage.pageCount - page,
817
1087
  ),
818
1088
  },
819
- (_unused, offset) => ({
820
- text: String(page + offset + 1),
821
- callback_data: `model:page:${page + offset}`,
822
- }),
1089
+ (_unused, offset) => {
1090
+ const pageIndex = page + offset;
1091
+ return {
1092
+ text:
1093
+ pageIndex === menuPage.page
1094
+ ? `🟢 ${pageIndex + 1}`
1095
+ : String(pageIndex + 1),
1096
+ callback_data: `model:page:${pageIndex}`,
1097
+ };
1098
+ },
823
1099
  ),
824
1100
  );
825
1101
  }
@@ -844,6 +1120,14 @@ export function buildTelegramModelMenuRenderPayload(
844
1120
  if (state.mode === "model-pages") {
845
1121
  return buildTelegramModelPageMenuRenderPayload(state);
846
1122
  }
1123
+ if (state.mode === "model-detail") {
1124
+ return {
1125
+ nextMode: "model-detail",
1126
+ text: buildModelDetailMenuText(state),
1127
+ mode: "html",
1128
+ replyMarkup: buildModelDetailMenuReplyMarkup(state, activeModel),
1129
+ };
1130
+ }
847
1131
  return {
848
1132
  nextMode: "model",
849
1133
  text: MODEL_MENU_TITLE,