diff --git a/lib/bridge.d.ts b/lib/bridge.d.ts index d6ea6a2..6bd93a0 100644 --- a/lib/bridge.d.ts +++ b/lib/bridge.d.ts @@ -19,6 +19,8 @@ export declare class TelegramBridge { private readonly maxMessageLength; private readonly bindings; private readonly pickers; + /** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */ + private readonly pendingModels; private polling; private offset; private pollPromise; diff --git a/lib/bridge.js b/lib/bridge.js index 0526928..2516a0a 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -12,7 +12,8 @@ const WS_CB = 'ws:'; const SID_CB = 'sid:'; const BACK_WS_CB = 'wb'; const MODEL_CB = 'mdl:'; -const EFFORT_CB = 'me:'; +/** Use eff: (not me:) — short prefix, no collision with other callbacks. */ +const EFFORT_CB = 'eff:'; const BACK_MODEL_CB = 'mb'; const MAX_BUTTONS = 40; function lastContextKeyboard() { @@ -37,6 +38,8 @@ export class TelegramBridge { maxMessageLength; bindings = new Map(); pickers = new Map(); + /** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */ + pendingModels = new Map(); polling = false; offset; pollPromise; @@ -89,6 +92,7 @@ export class TelegramBridge { // Never dispose host agents — only clear remote bindings. this.bindings.clear(); this.pickers.clear(); + this.pendingModels.clear(); if (this.pollPromise) { await this.pollPromise.catch(() => { }); this.pollPromise = undefined; @@ -196,21 +200,33 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.PICKER_STALE); return; } - if (option.efforts && option.efforts.length > 0) { + const efforts = (option.efforts ?? []).filter((e) => e.id); + if (efforts.length === 1) { + // Single effort (often only "off") — apply immediately, no second tap. + await this.applyModel(chatId, cq.id, { ...option, efforts }, efforts[0].id); + return; + } + if (efforts.length > 1) { + const pending = { ...option, efforts }; + this.pendingModels.set(String(chatId), pending); if (picker) - picker.pendingModel = option; + picker.pendingModel = pending; await this.client.answerCallbackQuery(cq.id); - await this.sendEffortPicker(chatId, option); + await this.sendEffortPicker(chatId, pending); return; } await this.applyModel(chatId, cq.id, option); return; } - if (data.startsWith(EFFORT_CB)) { - const index = Number(data.slice(EFFORT_CB.length)); - const pending = picker?.pendingModel; + if (data.startsWith(EFFORT_CB) || data.startsWith('me:')) { + // Accept legacy "me:" callbacks from older bot messages. + const rawIndex = data.startsWith(EFFORT_CB) + ? data.slice(EFFORT_CB.length) + : data.slice('me:'.length); + const index = Number(rawIndex); + const pending = this.pendingModels.get(String(chatId)) ?? picker?.pendingModel; const effort = pending?.efforts?.[index]; - if (!pending || !effort) { + if (!pending || !effort?.id) { await this.client.answerCallbackQuery(cq.id, '列表已过期'); await this.client.sendMessage(chatId, MSG.PICKER_STALE); return; @@ -421,19 +437,26 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.NEED_BIND); return; } + // Ensure session is live before selectModel (same as followup path). + await this.ensureLiveAgent(binding.sessionId); try { const selected = await selectSessionModel(this.ctx, binding.sessionId, { provider: option.provider, model: option.model, reasoningEffort, }); + this.pendingModels.delete(String(chatId)); + const picker = this.pickers.get(String(chatId)); + if (picker) + picker.pendingModel = undefined; await this.client.answerCallbackQuery(callbackId, '已切换'); await this.client.sendMessage(chatId, MSG.MODEL_SET(formatModel(selected))); } catch (err) { this.ctx.logger.warn(`dsh-telegram-channel: selectModel failed: ${this.redact(err)}`); + const detail = err instanceof Error ? err.message : String(err); await this.client.answerCallbackQuery(callbackId, '切换失败'); - await this.client.sendMessage(chatId, MSG.MODEL_FAILED); + await this.client.sendMessage(chatId, MSG.MODEL_FAILED(detail)); } } liveAgents() { diff --git a/lib/commands.d.ts b/lib/commands.d.ts index f6abd9a..be24ea9 100644 --- a/lib/commands.d.ts +++ b/lib/commands.d.ts @@ -20,7 +20,7 @@ export declare const MSG: { readonly MODEL_UNROUTABLE: (current: string) => string; readonly MODEL_EMPTY: (current: string) => string; readonly MODEL_SET: (selected: string) => string; - readonly MODEL_FAILED: "切换模型失败。请稍后重试或在 Web 中切换。"; + readonly MODEL_FAILED: (detail?: string) => string; readonly unknown: (command: string) => string; }; export type ParsedCommand = { diff --git a/lib/commands.js b/lib/commands.js index 1c13720..f1dedc4 100644 --- a/lib/commands.js +++ b/lib/commands.js @@ -53,7 +53,12 @@ export const MSG = { MODEL_SET(selected) { return `已切换模型:${selected}\n下一回合生效。`; }, - MODEL_FAILED: '切换模型失败。请稍后重试或在 Web 中切换。', + MODEL_FAILED(detail) { + const tip = '切换模型失败。请稍后重试或在 Web 中切换。'; + if (!detail) + return tip; + return `${tip}\n详情:${detail}`; + }, unknown(command) { return `未知命令 ${command}。发送 /help 查看可用命令。`; }, diff --git a/lib/models.js b/lib/models.js index c239d96..98dc248 100644 --- a/lib/models.js +++ b/lib/models.js @@ -53,12 +53,16 @@ export async function selectSessionModel(ctx, sessionId, selection) { if (!api?.sessions?.selectModel) { throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)'); } - const raw = await call(api.sessions.selectModel, { + // Omit undefined reasoningEffort — some validators treat explicit undefined as invalid. + const payload = { sessionId, provider: selection.provider, model: selection.model, - reasoningEffort: selection.reasoningEffort, - }); + }; + if (selection.reasoningEffort !== undefined && selection.reasoningEffort !== '') { + payload.reasoningEffort = selection.reasoningEffort; + } + const raw = await call(api.sessions.selectModel, payload); const value = unwrap(raw); if (!value?.selected) { throw new Error(unwrapError(raw) ?? 'failed to select model'); diff --git a/package.json b/package.json index 41ec978..d782aca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-telegram-channel", - "version": "0.3.4", + "version": "0.3.5", "description": "DeepSeek Harness Telegram mobile remote: workspace→session, /last context, /model — dsh-plugin", "keywords": [ "telegram", diff --git a/src/bridge.ts b/src/bridge.ts index 8b8a19b..5c17d99 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -64,7 +64,8 @@ const WS_CB = 'ws:' const SID_CB = 'sid:' const BACK_WS_CB = 'wb' const MODEL_CB = 'mdl:' -const EFFORT_CB = 'me:' +/** Use eff: (not me:) — short prefix, no collision with other callbacks. */ +const EFFORT_CB = 'eff:' const BACK_MODEL_CB = 'mb' const MAX_BUTTONS = 40 @@ -94,6 +95,8 @@ export class TelegramBridge { private readonly bindings = new Map() private readonly pickers = new Map() + /** chatId → model awaiting reasoning-effort pick (kept outside picker so list refreshes won't drop it). */ + private readonly pendingModels = new Map() private polling = false private offset: number | undefined private pollPromise: Promise | undefined @@ -149,6 +152,7 @@ export class TelegramBridge { // Never dispose host agents — only clear remote bindings. this.bindings.clear() this.pickers.clear() + this.pendingModels.clear() if (this.pollPromise) { await this.pollPromise.catch(() => {}) this.pollPromise = undefined @@ -261,20 +265,32 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.PICKER_STALE) return } - if (option.efforts && option.efforts.length > 0) { - if (picker) picker.pendingModel = option + const efforts = (option.efforts ?? []).filter((e) => e.id) + if (efforts.length === 1) { + // Single effort (often only "off") — apply immediately, no second tap. + await this.applyModel(chatId, cq.id, { ...option, efforts }, efforts[0]!.id) + return + } + if (efforts.length > 1) { + const pending = { ...option, efforts } + this.pendingModels.set(String(chatId), pending) + if (picker) picker.pendingModel = pending await this.client.answerCallbackQuery(cq.id) - await this.sendEffortPicker(chatId, option) + await this.sendEffortPicker(chatId, pending) return } await this.applyModel(chatId, cq.id, option) return } - if (data.startsWith(EFFORT_CB)) { - const index = Number(data.slice(EFFORT_CB.length)) - const pending = picker?.pendingModel + if (data.startsWith(EFFORT_CB) || data.startsWith('me:')) { + // Accept legacy "me:" callbacks from older bot messages. + const rawIndex = data.startsWith(EFFORT_CB) + ? data.slice(EFFORT_CB.length) + : data.slice('me:'.length) + const index = Number(rawIndex) + const pending = this.pendingModels.get(String(chatId)) ?? picker?.pendingModel const effort = pending?.efforts?.[index] - if (!pending || !effort) { + if (!pending || !effort?.id) { await this.client.answerCallbackQuery(cq.id, '列表已过期') await this.client.sendMessage(chatId, MSG.PICKER_STALE) return @@ -503,18 +519,24 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.NEED_BIND) return } + // Ensure session is live before selectModel (same as followup path). + await this.ensureLiveAgent(binding.sessionId) try { const selected = await selectSessionModel(this.ctx, binding.sessionId, { provider: option.provider, model: option.model, reasoningEffort, }) + this.pendingModels.delete(String(chatId)) + const picker = this.pickers.get(String(chatId)) + if (picker) picker.pendingModel = undefined await this.client.answerCallbackQuery(callbackId, '已切换') await this.client.sendMessage(chatId, MSG.MODEL_SET(formatModel(selected))) } catch (err) { this.ctx.logger.warn(`dsh-telegram-channel: selectModel failed: ${this.redact(err)}`) + const detail = err instanceof Error ? err.message : String(err) await this.client.answerCallbackQuery(callbackId, '切换失败') - await this.client.sendMessage(chatId, MSG.MODEL_FAILED) + await this.client.sendMessage(chatId, MSG.MODEL_FAILED(detail)) } } diff --git a/src/commands.ts b/src/commands.ts index 15920d4..ee80664 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -52,7 +52,11 @@ export const MSG = { MODEL_SET(selected: string): string { return `已切换模型:${selected}\n下一回合生效。` }, - MODEL_FAILED: '切换模型失败。请稍后重试或在 Web 中切换。', + MODEL_FAILED(detail?: string): string { + const tip = '切换模型失败。请稍后重试或在 Web 中切换。' + if (!detail) return tip + return `${tip}\n详情:${detail}` + }, unknown(command: string): string { return `未知命令 ${command}。发送 /help 查看可用命令。` }, diff --git a/src/models.ts b/src/models.ts index 564cf93..f56ec0f 100644 --- a/src/models.ts +++ b/src/models.ts @@ -89,12 +89,21 @@ export async function selectSessionModel( if (!api?.sessions?.selectModel) { throw new Error('apiProxy unavailable (use ctx.get / inject apiProxy)') } - const raw = await call(api.sessions.selectModel, { + // Omit undefined reasoningEffort — some validators treat explicit undefined as invalid. + const payload: { + sessionId: string + provider: string + model: string + reasoningEffort?: string + } = { sessionId, provider: selection.provider, model: selection.model, - reasoningEffort: selection.reasoningEffort, - }) + } + if (selection.reasoningEffort !== undefined && selection.reasoningEffort !== '') { + payload.reasoningEffort = selection.reasoningEffort + } + const raw = await call(api.sessions.selectModel, payload) const value = unwrap<{ selected: { provider: string; model: string; reasoningEffort?: string } }>(raw) if (!value?.selected) { throw new Error(unwrapError(raw) ?? 'failed to select model') diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 7bcfd35..e79ff69 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -440,7 +440,99 @@ test('/model lists and selects via apiProxy', async () => { sessionId: 'live-mdl', provider: 'deepseek', model: 'chat', - reasoningEffort: undefined, + }) + assert.match(sent.at(-1)!.text, /已切换模型/) +}) + +test('/model effort picker applies reasoningEffort', async () => { + const sent: SentMessage[] = [] + const followups: UserMessage[] = [] + const agent = makeAgent('live-eff', followups) + let selected: unknown + const ctx = { + logger: { info() {}, warn() {}, error() {} }, + agents: { + list: () => [agent], + roots: () => [agent], + get: () => agent, + }, + apiProxy: { + sessions: { + models: async () => rpcOk({ + current: { provider: 'deepseek', model: 'reasoner' }, + routable: true, + groups: [{ + id: 'deepseek', + name: 'DeepSeek', + models: [{ + id: 'reasoner', + name: 'Reasoner', + reasoning: { + efforts: [ + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max' }, + ], + }, + }], + }], + }), + selectModel: async (req: { payload: unknown }) => { + selected = req.payload + const p = req.payload as { provider: string; model: string; reasoningEffort?: string } + return rpcOk({ + selected: { + provider: p.provider, + model: p.model, + reasoningEffort: p.reasoningEffort, + }, + }) + }, + }, + }, + on() { return () => {} }, + } + const bridge = new TelegramBridge(ctx as any, { + token: 't', + allowedUserIds: [1], + allowAllUsers: false, + client: fakeClient(sent), + sleep: async () => {}, + }) + await bridge.processUpdate({ + update_id: 1, + callback_query: { + id: 'bind', + from: { id: 1 }, + message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } }, + data: `${BIND_CB_PREFIX}live-eff`, + }, + }) + await bridge.processUpdate(messageUpdate(10, 1, '/model', 2)) + await bridge.processUpdate({ + update_id: 3, + callback_query: { + id: 'pick-r', + from: { id: 1 }, + message: { message_id: 2, date: 0, chat: { id: 10, type: 'private' } }, + data: 'mdl:0', + }, + }) + assert.match(sent.at(-1)!.text, /reasoning effort|请选择/) + assert.equal(sent.at(-1)!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'eff:0') + await bridge.processUpdate({ + update_id: 4, + callback_query: { + id: 'pick-eff', + from: { id: 1 }, + message: { message_id: 3, date: 0, chat: { id: 10, type: 'private' } }, + data: 'eff:1', + }, + }) + assert.deepEqual(selected, { + sessionId: 'live-eff', + provider: 'deepseek', + model: 'reasoner', + reasoningEffort: 'max', }) assert.match(sent.at(-1)!.text, /已切换模型/) })