@grinev/opencode-telegram-bot 0.9.2 → 0.10.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 +9 -9
- package/dist/bot/commands/sessions.js +108 -22
- package/dist/bot/handlers/model.js +24 -26
- package/dist/bot/handlers/voice.js +4 -1
- package/dist/i18n/de.js +11 -1
- package/dist/i18n/en.js +11 -1
- package/dist/i18n/es.js +11 -1
- package/dist/i18n/ru.js +11 -1
- package/dist/i18n/zh.js +11 -1
- package/dist/model/manager.js +46 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Quick start: `npx @grinev/opencode-telegram-bot`
|
|
|
24
24
|
- **Remote coding** — send prompts to OpenCode from anywhere, receive complete results with code sent as files
|
|
25
25
|
- **Session management** — create new sessions or continue existing ones, just like in the TUI
|
|
26
26
|
- **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
|
|
27
|
-
- **Model switching** — pick
|
|
27
|
+
- **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
|
|
28
28
|
- **Agent modes** — switch between Plan and Build modes on the fly
|
|
29
29
|
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
|
|
30
30
|
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
|
|
@@ -136,7 +136,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
136
136
|
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
|
|
137
137
|
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
138
138
|
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `ru`, `zh`) | No | `en` |
|
|
139
|
-
| `SESSIONS_LIST_LIMIT` |
|
|
139
|
+
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
140
140
|
| `PROJECTS_LIST_LIMIT` | Max projects shown in `/projects` | No | `10` |
|
|
141
141
|
| `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
|
|
142
142
|
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
@@ -176,15 +176,14 @@ If STT variables are not set, voice/audio transcription is disabled and the bot
|
|
|
176
176
|
|
|
177
177
|
### Model Configuration
|
|
178
178
|
|
|
179
|
-
The
|
|
179
|
+
The model picker uses OpenCode local model state (`favorite` + `recent`):
|
|
180
180
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
- Favorites are shown first, then recent
|
|
182
|
+
- Models already in favorites are not duplicated in recent
|
|
183
|
+
- Current model is marked with `✅`
|
|
184
|
+
- Default model from `OPENCODE_MODEL_PROVIDER` + `OPENCODE_MODEL_ID` is always included in favorites
|
|
184
185
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
A free model (`opencode/big-pickle`) is configured as the default fallback — if you haven't set up any favorites yet, the bot will use it automatically.
|
|
186
|
+
To add a model to favorites, open OpenCode TUI (`opencode`), go to model selection, and press **Cmd+F/Ctrl+F** on the model.
|
|
188
187
|
|
|
189
188
|
## Security
|
|
190
189
|
|
|
@@ -240,6 +239,7 @@ npm run dev
|
|
|
240
239
|
**No models in model picker**
|
|
241
240
|
|
|
242
241
|
- Add models to your OpenCode favorites: open OpenCode TUI, go to model selection, press **Ctrl+F** on desired models
|
|
242
|
+
- Verify `OPENCODE_MODEL_PROVIDER` and `OPENCODE_MODEL_ID` point to an available model in your setup
|
|
243
243
|
|
|
244
244
|
**Linux: permission denied errors**
|
|
245
245
|
|
|
@@ -6,45 +6,105 @@ import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
|
6
6
|
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
7
7
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
8
8
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
9
|
-
import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
|
|
9
|
+
import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
|
|
10
10
|
import { logger } from "../../utils/logger.js";
|
|
11
11
|
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
12
12
|
import { config } from "../../config.js";
|
|
13
13
|
import { getDateLocale, t } from "../../i18n/index.js";
|
|
14
|
+
const SESSION_CALLBACK_PREFIX = "session:";
|
|
15
|
+
const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
|
|
16
|
+
const SESSION_FETCH_EXTRA_COUNT = 1;
|
|
17
|
+
function buildSessionPageCallback(page) {
|
|
18
|
+
return `${SESSION_PAGE_CALLBACK_PREFIX}${page}`;
|
|
19
|
+
}
|
|
20
|
+
function parseSessionPageCallback(data) {
|
|
21
|
+
if (!data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const rawPage = data.slice(SESSION_PAGE_CALLBACK_PREFIX.length);
|
|
25
|
+
const page = Number(rawPage);
|
|
26
|
+
if (!Number.isInteger(page) || page < 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return page;
|
|
30
|
+
}
|
|
31
|
+
function parseSessionIdCallback(data) {
|
|
32
|
+
if (!data.startsWith(SESSION_CALLBACK_PREFIX)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const sessionId = data.slice(SESSION_CALLBACK_PREFIX.length);
|
|
39
|
+
return sessionId.length > 0 ? sessionId : null;
|
|
40
|
+
}
|
|
41
|
+
function formatSessionsSelectText(page) {
|
|
42
|
+
if (page === 0) {
|
|
43
|
+
return t("sessions.select");
|
|
44
|
+
}
|
|
45
|
+
return t("sessions.select_page", { page: page + 1 });
|
|
46
|
+
}
|
|
47
|
+
async function loadSessionPage(directory, page, pageSize) {
|
|
48
|
+
const startIndex = page * pageSize;
|
|
49
|
+
const endExclusive = startIndex + pageSize;
|
|
50
|
+
const { data: sessions, error } = await opencodeClient.session.list({
|
|
51
|
+
directory,
|
|
52
|
+
limit: endExclusive + SESSION_FETCH_EXTRA_COUNT,
|
|
53
|
+
});
|
|
54
|
+
if (error || !sessions) {
|
|
55
|
+
throw error || new Error("No data received from server");
|
|
56
|
+
}
|
|
57
|
+
const hasNext = sessions.length > endExclusive;
|
|
58
|
+
const pagedSessions = sessions.slice(startIndex, endExclusive);
|
|
59
|
+
logger.debug(`[Sessions] Loaded page=${page + 1}, startIndex=${startIndex}, endExclusive=${endExclusive}, pageSize=${pageSize}, items=${pagedSessions.length}, hasNext=${hasNext}`);
|
|
60
|
+
return {
|
|
61
|
+
sessions: pagedSessions,
|
|
62
|
+
hasNext,
|
|
63
|
+
page,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function buildSessionsKeyboard(pageData, pageSize) {
|
|
67
|
+
const keyboard = new InlineKeyboard();
|
|
68
|
+
const localeForDate = getDateLocale();
|
|
69
|
+
const pageStartIndex = pageData.page * pageSize;
|
|
70
|
+
pageData.sessions.forEach((session, index) => {
|
|
71
|
+
const date = new Date(session.time.created).toLocaleDateString(localeForDate);
|
|
72
|
+
const label = `${pageStartIndex + index + 1}. ${session.title} (${date})`;
|
|
73
|
+
keyboard.text(label, `${SESSION_CALLBACK_PREFIX}${session.id}`).row();
|
|
74
|
+
});
|
|
75
|
+
if (pageData.page > 0) {
|
|
76
|
+
keyboard.text(t("sessions.button.prev_page"), buildSessionPageCallback(pageData.page - 1));
|
|
77
|
+
}
|
|
78
|
+
if (pageData.hasNext) {
|
|
79
|
+
keyboard.text(t("sessions.button.next_page"), buildSessionPageCallback(pageData.page + 1));
|
|
80
|
+
}
|
|
81
|
+
if (pageData.page > 0 || pageData.hasNext) {
|
|
82
|
+
keyboard.row();
|
|
83
|
+
}
|
|
84
|
+
return keyboard;
|
|
85
|
+
}
|
|
14
86
|
export async function sessionsCommand(ctx) {
|
|
15
87
|
try {
|
|
16
|
-
const
|
|
88
|
+
const pageSize = config.bot.sessionsListLimit;
|
|
17
89
|
const currentProject = getCurrentProject();
|
|
18
90
|
if (!currentProject) {
|
|
19
91
|
await ctx.reply(t("sessions.project_not_selected"));
|
|
20
92
|
return;
|
|
21
93
|
}
|
|
22
94
|
logger.debug(`[Sessions] Fetching sessions for directory: ${currentProject.worktree}`);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
});
|
|
27
|
-
if (error || !sessions) {
|
|
28
|
-
throw error || new Error("No data received from server");
|
|
29
|
-
}
|
|
30
|
-
logger.debug(`[Sessions] Found ${sessions.length} sessions`);
|
|
31
|
-
sessions.forEach((session) => {
|
|
95
|
+
const firstPage = await loadSessionPage(currentProject.worktree, 0, pageSize);
|
|
96
|
+
logger.debug(`[Sessions] Found ${firstPage.sessions.length} sessions on page 1`);
|
|
97
|
+
firstPage.sessions.forEach((session) => {
|
|
32
98
|
logger.debug(`[Sessions] Session: ${session.title} | ${session.directory}`);
|
|
33
99
|
});
|
|
34
|
-
if (sessions.length === 0) {
|
|
100
|
+
if (firstPage.sessions.length === 0) {
|
|
35
101
|
await ctx.reply(t("sessions.empty"));
|
|
36
102
|
return;
|
|
37
103
|
}
|
|
38
|
-
const keyboard =
|
|
39
|
-
const localeForDate = getDateLocale();
|
|
40
|
-
sessions.forEach((session, index) => {
|
|
41
|
-
const date = new Date(session.time.created).toLocaleDateString(localeForDate);
|
|
42
|
-
const label = `${index + 1}. ${session.title} (${date})`;
|
|
43
|
-
keyboard.text(label, `session:${session.id}`).row();
|
|
44
|
-
});
|
|
104
|
+
const keyboard = buildSessionsKeyboard(firstPage, pageSize);
|
|
45
105
|
await replyWithInlineMenu(ctx, {
|
|
46
106
|
menuKind: "session",
|
|
47
|
-
text:
|
|
107
|
+
text: formatSessionsSelectText(firstPage.page),
|
|
48
108
|
keyboard,
|
|
49
109
|
});
|
|
50
110
|
}
|
|
@@ -55,10 +115,11 @@ export async function sessionsCommand(ctx) {
|
|
|
55
115
|
}
|
|
56
116
|
export async function handleSessionSelect(ctx) {
|
|
57
117
|
const callbackQuery = ctx.callbackQuery;
|
|
58
|
-
if (!callbackQuery?.data || !callbackQuery.data.startsWith(
|
|
118
|
+
if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
|
|
59
119
|
return false;
|
|
60
120
|
}
|
|
61
|
-
const
|
|
121
|
+
const page = parseSessionPageCallback(callbackQuery.data);
|
|
122
|
+
const sessionId = parseSessionIdCallback(callbackQuery.data);
|
|
62
123
|
const isActiveMenu = await ensureActiveInlineMenu(ctx, "session");
|
|
63
124
|
if (!isActiveMenu) {
|
|
64
125
|
return true;
|
|
@@ -71,6 +132,31 @@ export async function handleSessionSelect(ctx) {
|
|
|
71
132
|
await ctx.reply(t("sessions.select_project_first"));
|
|
72
133
|
return true;
|
|
73
134
|
}
|
|
135
|
+
if (page !== null) {
|
|
136
|
+
try {
|
|
137
|
+
const pageSize = config.bot.sessionsListLimit;
|
|
138
|
+
const pageData = await loadSessionPage(currentProject.worktree, page, pageSize);
|
|
139
|
+
if (pageData.sessions.length === 0) {
|
|
140
|
+
await ctx.answerCallbackQuery({ text: t("sessions.page_empty_callback") });
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
const keyboard = buildSessionsKeyboard(pageData, pageSize);
|
|
144
|
+
appendInlineMenuCancelButton(keyboard, "session");
|
|
145
|
+
await ctx.editMessageText(formatSessionsSelectText(pageData.page), {
|
|
146
|
+
reply_markup: keyboard,
|
|
147
|
+
});
|
|
148
|
+
await ctx.answerCallbackQuery();
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
logger.error("[Sessions] Error loading sessions page:", error);
|
|
152
|
+
await ctx.answerCallbackQuery({ text: t("sessions.page_load_error_callback") });
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
if (!sessionId) {
|
|
157
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
74
160
|
const { data: session, error } = await opencodeClient.session.get({
|
|
75
161
|
sessionID: sessionId,
|
|
76
162
|
directory: currentProject.worktree,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
|
-
import { selectModel,
|
|
2
|
+
import { selectModel, fetchCurrentModel, getModelSelectionLists } from "../../model/manager.js";
|
|
3
3
|
import { formatModelForDisplay } from "../../model/types.js";
|
|
4
4
|
import { formatVariantForButton } from "../../variant/manager.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
@@ -7,19 +7,18 @@ import { createMainKeyboard } from "../utils/keyboard.js";
|
|
|
7
7
|
import { getStoredAgent } from "../../agent/manager.js";
|
|
8
8
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
9
9
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
10
|
-
import { config } from "../../config.js";
|
|
11
10
|
import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
|
|
12
11
|
import { t } from "../../i18n/index.js";
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
function buildModelSelectionMenuText(modelLists) {
|
|
13
|
+
const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
|
|
14
|
+
if (modelLists.favorites.length === 0) {
|
|
15
|
+
lines.push(t("model.menu.favorites_empty"));
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return false;
|
|
17
|
+
lines.push(t("model.menu.recent_title"));
|
|
18
|
+
if (modelLists.recent.length === 0) {
|
|
19
|
+
lines.push(t("model.menu.recent_empty"));
|
|
21
20
|
}
|
|
22
|
-
return
|
|
21
|
+
return lines.join("\n");
|
|
23
22
|
}
|
|
24
23
|
/**
|
|
25
24
|
* Handle model selection callback
|
|
@@ -91,27 +90,30 @@ export async function handleModelSelect(ctx) {
|
|
|
91
90
|
}
|
|
92
91
|
}
|
|
93
92
|
/**
|
|
94
|
-
* Build inline keyboard with favorite models
|
|
93
|
+
* Build inline keyboard with favorite and recent models
|
|
95
94
|
* @param currentModel Current model for highlighting
|
|
96
95
|
* @returns InlineKeyboard with model selection buttons
|
|
97
96
|
*/
|
|
98
|
-
export async function buildModelSelectionMenu(currentModel,
|
|
97
|
+
export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
99
98
|
const keyboard = new InlineKeyboard();
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
const lists = modelLists ?? (await getModelSelectionLists());
|
|
100
|
+
const favorites = lists.favorites;
|
|
101
|
+
const recent = lists.recent;
|
|
102
|
+
if (favorites.length === 0 && recent.length === 0) {
|
|
103
|
+
logger.warn("[ModelHandler] No model choices found in favorites/recent");
|
|
103
104
|
return keyboard;
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
favorites.forEach((model) => {
|
|
106
|
+
const addButton = (model, prefix) => {
|
|
107
107
|
const isActive = currentModel &&
|
|
108
108
|
model.providerID === currentModel.providerID &&
|
|
109
109
|
model.modelID === currentModel.modelID;
|
|
110
110
|
// Inline buttons use full model ID without truncation
|
|
111
|
-
const label = `${model.providerID}/${model.modelID}`;
|
|
111
|
+
const label = `${prefix} ${model.providerID}/${model.modelID}`;
|
|
112
112
|
const labelWithCheck = isActive ? `✅ ${label}` : label;
|
|
113
113
|
keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
|
|
114
|
-
}
|
|
114
|
+
};
|
|
115
|
+
favorites.forEach((model) => addButton(model, "⭐"));
|
|
116
|
+
recent.forEach((model) => addButton(model, "🕘"));
|
|
115
117
|
return keyboard;
|
|
116
118
|
}
|
|
117
119
|
/**
|
|
@@ -121,22 +123,18 @@ export async function buildModelSelectionMenu(currentModel, favoriteModels) {
|
|
|
121
123
|
export async function showModelSelectionMenu(ctx) {
|
|
122
124
|
try {
|
|
123
125
|
const currentModel = fetchCurrentModel();
|
|
124
|
-
const
|
|
125
|
-
const keyboard = await buildModelSelectionMenu(currentModel,
|
|
126
|
+
const modelLists = await getModelSelectionLists();
|
|
127
|
+
const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
|
|
126
128
|
if (keyboard.inline_keyboard.length === 0) {
|
|
127
129
|
await ctx.reply(t("model.menu.empty"));
|
|
128
130
|
return;
|
|
129
131
|
}
|
|
130
|
-
const
|
|
131
|
-
const text = t("model.menu.current", { name: displayName });
|
|
132
|
+
const text = buildModelSelectionMenuText(modelLists);
|
|
132
133
|
await replyWithInlineMenu(ctx, {
|
|
133
134
|
menuKind: "model",
|
|
134
135
|
text,
|
|
135
136
|
keyboard,
|
|
136
137
|
});
|
|
137
|
-
if (isOnlyConfigDefaultModel(favorites)) {
|
|
138
|
-
await ctx.reply(t("model.menu.favorites_hint"));
|
|
139
|
-
}
|
|
140
138
|
}
|
|
141
139
|
catch (err) {
|
|
142
140
|
logger.error("[ModelHandler] Error showing model menu:", err);
|
|
@@ -80,7 +80,10 @@ async function downloadTelegramFile(ctx, fileId) {
|
|
|
80
80
|
logger.debug(`[Voice] Downloading file: ${file.file_path} (${file.file_size ?? "?"} bytes)`);
|
|
81
81
|
const buffer = await downloadTelegramFileByUrl(fileUrl);
|
|
82
82
|
// Extract filename from file_path (e.g., "voice/file_123.oga" -> "file_123.oga")
|
|
83
|
-
|
|
83
|
+
let filename = file.file_path.split("/").pop() || "audio.ogg";
|
|
84
|
+
if (filename.endsWith(".oga")) {
|
|
85
|
+
filename = filename.slice(0, -4) + ".ogg";
|
|
86
|
+
}
|
|
84
87
|
logger.debug(`[Voice] Downloaded file: ${filename} (${buffer.length} bytes)`);
|
|
85
88
|
return { buffer, filename };
|
|
86
89
|
}
|
package/dist/i18n/de.js
CHANGED
|
@@ -76,8 +76,13 @@ export const de = {
|
|
|
76
76
|
"sessions.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.",
|
|
77
77
|
"sessions.empty": "📭 Keine Sitzungen gefunden.\n\nErstelle eine neue Sitzung mit /new.",
|
|
78
78
|
"sessions.select": "Sitzung auswählen:",
|
|
79
|
+
"sessions.select_page": "Sitzung auswählen (Seite {page}):",
|
|
79
80
|
"sessions.fetch_error": "🔴 OpenCode-Server ist nicht verfügbar oder beim Laden der Sitzungen ist ein Fehler aufgetreten.",
|
|
80
81
|
"sessions.select_project_first": "🔴 Projekt ist nicht ausgewählt. Nutze /projects.",
|
|
82
|
+
"sessions.page_empty_callback": "Auf dieser Seite gibt es keine Sitzungen",
|
|
83
|
+
"sessions.page_load_error_callback": "Diese Seite kann nicht geladen werden. Bitte versuche es erneut.",
|
|
84
|
+
"sessions.button.prev_page": "⬅️ Zurück",
|
|
85
|
+
"sessions.button.next_page": "Weiter ➡️",
|
|
81
86
|
"sessions.loading_context": "⏳ Lade Kontext und letzte Nachrichten...",
|
|
82
87
|
"sessions.selected": "✅ Sitzung ausgewählt: {title}",
|
|
83
88
|
"sessions.select_error": "🔴 Sitzung konnte nicht ausgewählt werden.",
|
|
@@ -119,8 +124,13 @@ export const de = {
|
|
|
119
124
|
"model.changed_message": "✅ Modell geändert zu: {name}",
|
|
120
125
|
"model.change_error_callback": "Modell konnte nicht geändert werden",
|
|
121
126
|
"model.menu.empty": "⚠️ Keine verfügbaren Modelle",
|
|
127
|
+
"model.menu.select": "Modell auswählen:",
|
|
122
128
|
"model.menu.current": "Aktuelles Modell: {name}\n\nModell auswählen:",
|
|
123
|
-
"model.menu.
|
|
129
|
+
"model.menu.favorites_title": "⭐ Favoriten (Füge Modelle in OpenCode CLI zu den Favoriten hinzu)",
|
|
130
|
+
"model.menu.favorites_empty": "— Leer.",
|
|
131
|
+
"model.menu.recent_title": "🕘 Zuletzt verwendet",
|
|
132
|
+
"model.menu.recent_empty": "— Leer.",
|
|
133
|
+
"model.menu.favorites_hint": "ℹ️ Füge Modelle in OpenCode CLI zu den Favoriten hinzu, damit sie oben angezeigt werden.",
|
|
124
134
|
"model.menu.error": "🔴 Modellliste konnte nicht geladen werden",
|
|
125
135
|
"variant.model_not_selected_callback": "Fehler: Modell ist nicht ausgewählt",
|
|
126
136
|
"variant.changed_callback": "Variante geändert: {name}",
|
package/dist/i18n/en.js
CHANGED
|
@@ -76,8 +76,13 @@ export const en = {
|
|
|
76
76
|
"sessions.project_not_selected": "🏗 Project is not selected.\n\nFirst select a project with /projects.",
|
|
77
77
|
"sessions.empty": "📭 No sessions found.\n\nCreate a new session with /new.",
|
|
78
78
|
"sessions.select": "Select a session:",
|
|
79
|
+
"sessions.select_page": "Select a session (page {page}):",
|
|
79
80
|
"sessions.fetch_error": "🔴 OpenCode Server is unavailable or an error occurred while loading sessions.",
|
|
80
81
|
"sessions.select_project_first": "🔴 Project is not selected. Use /projects.",
|
|
82
|
+
"sessions.page_empty_callback": "No sessions on this page",
|
|
83
|
+
"sessions.page_load_error_callback": "Cannot load this page. Please try again.",
|
|
84
|
+
"sessions.button.prev_page": "⬅️ Prev",
|
|
85
|
+
"sessions.button.next_page": "Next ➡️",
|
|
81
86
|
"sessions.loading_context": "⏳ Loading context and latest messages...",
|
|
82
87
|
"sessions.selected": "✅ Session selected: {title}",
|
|
83
88
|
"sessions.select_error": "🔴 Failed to select session.",
|
|
@@ -119,8 +124,13 @@ export const en = {
|
|
|
119
124
|
"model.changed_message": "✅ Model changed to: {name}",
|
|
120
125
|
"model.change_error_callback": "Failed to change model",
|
|
121
126
|
"model.menu.empty": "⚠️ No available models",
|
|
127
|
+
"model.menu.select": "Select model:",
|
|
122
128
|
"model.menu.current": "Current model: {name}\n\nSelect model:",
|
|
123
|
-
"model.menu.
|
|
129
|
+
"model.menu.favorites_title": "⭐ Favorites (Add models to favorites in OpenCode CLI)",
|
|
130
|
+
"model.menu.favorites_empty": "— Empty.",
|
|
131
|
+
"model.menu.recent_title": "🕘 Recent",
|
|
132
|
+
"model.menu.recent_empty": "— Empty.",
|
|
133
|
+
"model.menu.favorites_hint": "ℹ️ Add models to favorites in OpenCode CLI to keep them at the top.",
|
|
124
134
|
"model.menu.error": "🔴 Failed to get models list",
|
|
125
135
|
"variant.model_not_selected_callback": "Error: model is not selected",
|
|
126
136
|
"variant.changed_callback": "Variant changed: {name}",
|
package/dist/i18n/es.js
CHANGED
|
@@ -76,8 +76,13 @@ export const es = {
|
|
|
76
76
|
"sessions.project_not_selected": "🏗 No hay un proyecto seleccionado.\n\nPrimero selecciona un proyecto con /projects.",
|
|
77
77
|
"sessions.empty": "📭 No se encontraron sesiones.\n\nCrea una sesión nueva con /new.",
|
|
78
78
|
"sessions.select": "Selecciona una sesión:",
|
|
79
|
+
"sessions.select_page": "Selecciona una sesión (página {page}):",
|
|
79
80
|
"sessions.fetch_error": "🔴 OpenCode Server no está disponible u ocurrió un error al cargar las sesiones.",
|
|
80
81
|
"sessions.select_project_first": "🔴 No hay un proyecto seleccionado. Usa /projects.",
|
|
82
|
+
"sessions.page_empty_callback": "No hay sesiones en esta página",
|
|
83
|
+
"sessions.page_load_error_callback": "No se puede cargar esta página. Inténtalo de nuevo.",
|
|
84
|
+
"sessions.button.prev_page": "⬅️ Anterior",
|
|
85
|
+
"sessions.button.next_page": "Siguiente ➡️",
|
|
81
86
|
"sessions.loading_context": "⏳ Cargando contexto y los últimos mensajes...",
|
|
82
87
|
"sessions.selected": "✅ Sesión seleccionada: {title}",
|
|
83
88
|
"sessions.select_error": "🔴 No se pudo seleccionar la sesión.",
|
|
@@ -119,8 +124,13 @@ export const es = {
|
|
|
119
124
|
"model.changed_message": "✅ Modelo cambiado a: {name}",
|
|
120
125
|
"model.change_error_callback": "No se pudo cambiar el modelo",
|
|
121
126
|
"model.menu.empty": "⚠️ No hay modelos disponibles",
|
|
127
|
+
"model.menu.select": "Selecciona el modelo:",
|
|
122
128
|
"model.menu.current": "Modelo actual: {name}\n\nSelecciona el modelo:",
|
|
123
|
-
"model.menu.
|
|
129
|
+
"model.menu.favorites_title": "⭐ Favoritos (Agrega modelos a favoritos en OpenCode CLI)",
|
|
130
|
+
"model.menu.favorites_empty": "— Vacío.",
|
|
131
|
+
"model.menu.recent_title": "🕘 Recientes",
|
|
132
|
+
"model.menu.recent_empty": "— Vacío.",
|
|
133
|
+
"model.menu.favorites_hint": "ℹ️ Agrega modelos a favoritos en OpenCode CLI para mantenerlos arriba de la lista.",
|
|
124
134
|
"model.menu.error": "🔴 No se pudo obtener la lista de modelos",
|
|
125
135
|
"variant.model_not_selected_callback": "Error: no hay un modelo seleccionado",
|
|
126
136
|
"variant.changed_callback": "Variante cambiada: {name}",
|
package/dist/i18n/ru.js
CHANGED
|
@@ -76,8 +76,13 @@ export const ru = {
|
|
|
76
76
|
"sessions.project_not_selected": "🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.",
|
|
77
77
|
"sessions.empty": "📭 Сессий нет.\n\nСоздайте новую сессию командой /new.",
|
|
78
78
|
"sessions.select": "Выберите сессию:",
|
|
79
|
+
"sessions.select_page": "Выберите сессию (страница {page}):",
|
|
79
80
|
"sessions.fetch_error": "🔴 OpenCode Server недоступен или произошла ошибка при получении списка сессий.",
|
|
80
81
|
"sessions.select_project_first": "🔴 Проект не выбран. Используйте /projects.",
|
|
82
|
+
"sessions.page_empty_callback": "На этой странице нет сессий",
|
|
83
|
+
"sessions.page_load_error_callback": "Не удалось загрузить эту страницу. Пожалуйста, попробуйте снова.",
|
|
84
|
+
"sessions.button.prev_page": "⬅️ Назад",
|
|
85
|
+
"sessions.button.next_page": "Вперёд ➡️",
|
|
81
86
|
"sessions.loading_context": "⏳ Загружаю контекст и последние сообщения...",
|
|
82
87
|
"sessions.selected": "✅ Сессия выбрана: {title}",
|
|
83
88
|
"sessions.select_error": "🔴 Ошибка при выборе сессии.",
|
|
@@ -119,8 +124,13 @@ export const ru = {
|
|
|
119
124
|
"model.changed_message": "✅ Модель изменена на: {name}",
|
|
120
125
|
"model.change_error_callback": "Ошибка при смене модели",
|
|
121
126
|
"model.menu.empty": "⚠️ Нет доступных моделей",
|
|
127
|
+
"model.menu.select": "Выберите модель:",
|
|
122
128
|
"model.menu.current": "Текущая модель: {name}\n\nВыберите модель:",
|
|
123
|
-
"model.menu.
|
|
129
|
+
"model.menu.favorites_title": "⭐ Избранное (Добавляйте модели в избранное через OpenCode CLI)",
|
|
130
|
+
"model.menu.favorites_empty": "— Список пуст.",
|
|
131
|
+
"model.menu.recent_title": "🕘 Недавние",
|
|
132
|
+
"model.menu.recent_empty": "— Список пуст.",
|
|
133
|
+
"model.menu.favorites_hint": "ℹ️ Добавляйте модели в избранное через OpenCode CLI, чтобы они были вверху списка.",
|
|
124
134
|
"model.menu.error": "🔴 Не удалось получить список моделей",
|
|
125
135
|
"variant.model_not_selected_callback": "Ошибка: модель не выбрана",
|
|
126
136
|
"variant.changed_callback": "Вариант изменен: {name}",
|
package/dist/i18n/zh.js
CHANGED
|
@@ -76,8 +76,13 @@ export const zh = {
|
|
|
76
76
|
"sessions.project_not_selected": "🏗 未选择项目。\n\n请先使用 /projects 选择一个项目。",
|
|
77
77
|
"sessions.empty": "📭 未找到会话。\n\n使用 /new 创建新会话。",
|
|
78
78
|
"sessions.select": "请选择一个会话:",
|
|
79
|
+
"sessions.select_page": "请选择一个会话(第 {page} 页):",
|
|
79
80
|
"sessions.fetch_error": "🔴 OpenCode 服务器不可用,或加载会话时发生错误。",
|
|
80
81
|
"sessions.select_project_first": "🔴 未选择项目。使用 /projects。",
|
|
82
|
+
"sessions.page_empty_callback": "这一页没有会话",
|
|
83
|
+
"sessions.page_load_error_callback": "无法加载此页面。请重试。",
|
|
84
|
+
"sessions.button.prev_page": "⬅️ 上一页",
|
|
85
|
+
"sessions.button.next_page": "下一页 ➡️",
|
|
81
86
|
"sessions.loading_context": "⏳ 正在加载上下文和最新消息...",
|
|
82
87
|
"sessions.selected": "✅ 已选择会话:{title}",
|
|
83
88
|
"sessions.select_error": "🔴 选择会话失败。",
|
|
@@ -119,8 +124,13 @@ export const zh = {
|
|
|
119
124
|
"model.changed_message": "✅ 模型已切换为:{name}",
|
|
120
125
|
"model.change_error_callback": "切换模型失败",
|
|
121
126
|
"model.menu.empty": "⚠️ 没有可用模型",
|
|
127
|
+
"model.menu.select": "请选择模型:",
|
|
122
128
|
"model.menu.current": "当前模型:{name}\n\n请选择模型:",
|
|
123
|
-
"model.menu.
|
|
129
|
+
"model.menu.favorites_title": "⭐ 收藏(可在 OpenCode CLI 中将模型加入收藏)",
|
|
130
|
+
"model.menu.favorites_empty": "— 列表为空。",
|
|
131
|
+
"model.menu.recent_title": "🕘 最近使用",
|
|
132
|
+
"model.menu.recent_empty": "— 列表为空。",
|
|
133
|
+
"model.menu.favorites_hint": "ℹ️ 可在 OpenCode CLI 中将模型加入收藏,使其显示在列表顶部。",
|
|
124
134
|
"model.menu.error": "🔴 获取模型列表失败",
|
|
125
135
|
"variant.model_not_selected_callback": "错误:未选择模型",
|
|
126
136
|
"variant.changed_callback": "变体已更改:{name}",
|
package/dist/model/manager.js
CHANGED
|
@@ -34,6 +34,20 @@ function normalizeFavoriteModels(state) {
|
|
|
34
34
|
modelID: model.modelID,
|
|
35
35
|
}));
|
|
36
36
|
}
|
|
37
|
+
function normalizeRecentModels(state) {
|
|
38
|
+
if (!Array.isArray(state.recent)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
return state.recent
|
|
42
|
+
.filter((model) => typeof model?.providerID === "string" &&
|
|
43
|
+
model.providerID.length > 0 &&
|
|
44
|
+
typeof model.modelID === "string" &&
|
|
45
|
+
model.modelID.length > 0)
|
|
46
|
+
.map((model) => ({
|
|
47
|
+
providerID: model.providerID,
|
|
48
|
+
modelID: model.modelID,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
37
51
|
function getOpenCodeModelStatePath() {
|
|
38
52
|
const xdgStateHome = process.env.XDG_STATE_HOME;
|
|
39
53
|
if (xdgStateHome && xdgStateHome.trim().length > 0) {
|
|
@@ -43,38 +57,54 @@ function getOpenCodeModelStatePath() {
|
|
|
43
57
|
return path.join(homeDir, ".local", "state", "opencode", "model.json");
|
|
44
58
|
}
|
|
45
59
|
/**
|
|
46
|
-
* Get
|
|
47
|
-
*
|
|
60
|
+
* Get favorite and recent models from OpenCode local state file.
|
|
61
|
+
* Config model is always treated as favorite.
|
|
48
62
|
*/
|
|
49
|
-
export async function
|
|
63
|
+
export async function getModelSelectionLists() {
|
|
50
64
|
const envDefaultModel = getEnvDefaultModel();
|
|
51
65
|
try {
|
|
52
66
|
const fs = await import("fs/promises");
|
|
53
67
|
const stateFilePath = getOpenCodeModelStatePath();
|
|
54
68
|
const content = await fs.readFile(stateFilePath, "utf-8");
|
|
55
69
|
const state = JSON.parse(content);
|
|
56
|
-
const
|
|
70
|
+
const rawFavorites = normalizeFavoriteModels(state);
|
|
71
|
+
const favorites = envDefaultModel
|
|
72
|
+
? dedupeModels([...rawFavorites, envDefaultModel])
|
|
73
|
+
: rawFavorites;
|
|
74
|
+
if (rawFavorites.length === 0 && envDefaultModel) {
|
|
75
|
+
logger.info(`[ModelManager] No favorites in ${stateFilePath}, using config model as favorite`);
|
|
76
|
+
}
|
|
57
77
|
if (favorites.length === 0) {
|
|
58
|
-
if (envDefaultModel) {
|
|
59
|
-
logger.info(`[ModelManager] No favorites in ${stateFilePath}, using env default model`);
|
|
60
|
-
return [envDefaultModel];
|
|
61
|
-
}
|
|
62
78
|
logger.warn(`[ModelManager] No favorites in ${stateFilePath}`);
|
|
63
|
-
return [];
|
|
64
79
|
}
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
const favoriteKeys = new Set(favorites.map((model) => `${model.providerID}/${model.modelID}`));
|
|
81
|
+
const recent = dedupeModels(normalizeRecentModels(state)).filter((model) => !favoriteKeys.has(`${model.providerID}/${model.modelID}`));
|
|
82
|
+
logger.debug(`[ModelManager] Loaded model selection lists from ${stateFilePath}: favorites=${favorites.length}, recent=${recent.length}`);
|
|
83
|
+
return { favorites, recent };
|
|
68
84
|
}
|
|
69
85
|
catch (err) {
|
|
70
86
|
if (envDefaultModel) {
|
|
71
|
-
logger.warn("[ModelManager] Failed to load OpenCode
|
|
72
|
-
return
|
|
87
|
+
logger.warn("[ModelManager] Failed to load OpenCode model state, using config model as favorite:", err);
|
|
88
|
+
return {
|
|
89
|
+
favorites: [envDefaultModel],
|
|
90
|
+
recent: [],
|
|
91
|
+
};
|
|
73
92
|
}
|
|
74
|
-
logger.error("[ModelManager] Failed to load OpenCode
|
|
75
|
-
return
|
|
93
|
+
logger.error("[ModelManager] Failed to load OpenCode model state:", err);
|
|
94
|
+
return {
|
|
95
|
+
favorites: [],
|
|
96
|
+
recent: [],
|
|
97
|
+
};
|
|
76
98
|
}
|
|
77
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Get list of favorite models from OpenCode local state file
|
|
102
|
+
* Falls back to env default model if file is unavailable or empty
|
|
103
|
+
*/
|
|
104
|
+
export async function getFavoriteModels() {
|
|
105
|
+
const { favorites } = await getModelSelectionLists();
|
|
106
|
+
return favorites;
|
|
107
|
+
}
|
|
78
108
|
/**
|
|
79
109
|
* Get current model from settings or fallback to config
|
|
80
110
|
* @returns Current model info
|