feat: workspace→session picker and /model switch (v0.3.0)

Align Telegram session list with Web via apiProxy (exclude archived), resume cold sessions on bind, and allow model selection from the phone.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
owo
2026-08-14 18:46:56 +08:00
co-authored by Cursor
parent 9bae1a3702
commit ef0e7f34e3
18 changed files with 1486 additions and 108 deletions
+285 -35
View File
@@ -1,10 +1,19 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
import { SessionId } from '@deepseek-ai/dsh-session/types';
import { isAuthorized } from './auth.js';
import { catalogFromLiveAgents, loadCatalog, truncateButton, visibleSessionsForWorkspace, workspacesWithVisibleSessions, } from './catalog.js';
import { TelegramClient, } from './client.js';
import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js';
import { MSG, parseCommand } from './commands.js';
import { markdownToHtml, splitMessage } from './format.js';
import { buttonLabel, describeAgent, detailLines, displayLabel } from './label.js';
import { describeAgent, displayLabel } from './label.js';
import { formatModel, loadSessionModels, selectSessionModel, } from './models.js';
const WS_CB = 'ws:';
const SID_CB = 'sid:';
const BACK_WS_CB = 'wb';
const MODEL_CB = 'mdl:';
const EFFORT_CB = 'me:';
const BACK_MODEL_CB = 'mb';
const MAX_BUTTONS = 40;
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function contentToText(content) {
return content
@@ -21,6 +30,7 @@ export class TelegramBridge {
sleep;
maxMessageLength;
bindings = new Map();
pickers = new Map();
polling = false;
offset;
pollPromise;
@@ -46,7 +56,8 @@ export class TelegramBridge {
});
void this.client.setMyCommands([
{ command: 'start', description: '欢迎与用法' },
{ command: 'sessions', description: '列出并附着本机 live 会话' },
{ command: 'sessions', description: '按工作区列出并附着会话' },
{ command: 'model', description: '切换当前绑定会话的模型' },
{ command: 'status', description: '查看当前绑定' },
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
{ command: 'help', description: '显示帮助' },
@@ -70,6 +81,7 @@ export class TelegramBridge {
this.disposeSessionListener = undefined;
// Never dispose host agents — only clear remote bindings.
this.bindings.clear();
this.pickers.clear();
if (this.pollPromise) {
await this.pollPromise.catch(() => { });
this.pollPromise = undefined;
@@ -98,13 +110,17 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.HELP);
return;
case 'sessions':
await this.sendSessionPicker(chatId);
await this.sendWorkspacePicker(chatId);
return;
case 'model':
await this.sendModelPicker(chatId);
return;
case 'status':
await this.sendStatus(chatId);
return;
case 'unbind':
this.bindings.delete(String(chatId));
this.pickers.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.UNBOUND);
return;
case 'unknown':
@@ -129,21 +145,261 @@ export class TelegramBridge {
return;
}
const data = cq.data ?? '';
if (!data.startsWith(BIND_CB_PREFIX)) {
const picker = this.pickers.get(String(chatId));
if (data === BACK_WS_CB) {
await this.client.answerCallbackQuery(cq.id);
await this.sendWorkspacePicker(chatId, picker?.catalog);
return;
}
const sessionId = data.slice(BIND_CB_PREFIX.length);
const agent = this.findLiveAgent(sessionId);
if (data === BACK_MODEL_CB) {
await this.client.answerCallbackQuery(cq.id);
await this.sendModelPicker(chatId);
return;
}
if (data.startsWith(WS_CB)) {
const index = Number(data.slice(WS_CB.length));
await this.client.answerCallbackQuery(cq.id);
await this.sendSessionPicker(chatId, index);
return;
}
if (data.startsWith(SID_CB)) {
const index = Number(data.slice(SID_CB.length));
const row = picker?.sessions[index];
if (!row) {
await this.client.answerCallbackQuery(cq.id, '会话已过期');
await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return;
}
await this.bindSession(chatId, cq.id, row);
return;
}
if (data.startsWith(MODEL_CB)) {
const index = Number(data.slice(MODEL_CB.length));
const option = picker?.models?.[index];
if (!option) {
await this.client.answerCallbackQuery(cq.id, '列表已过期');
await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return;
}
if (option.efforts && option.efforts.length > 0) {
if (picker)
picker.pendingModel = option;
await this.client.answerCallbackQuery(cq.id);
await this.sendEffortPicker(chatId, option);
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;
const effort = pending?.efforts?.[index];
if (!pending || !effort) {
await this.client.answerCallbackQuery(cq.id, '列表已过期');
await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return;
}
await this.applyModel(chatId, cq.id, pending, effort.id);
return;
}
// Legacy bind:<sessionId> callbacks (older messages / tests)
if (data.startsWith('bind:')) {
const sessionId = data.slice('bind:'.length);
await this.bindSession(chatId, cq.id, {
sessionId,
title: sessionId,
blank: false,
running: true,
updatedAt: 0,
});
return;
}
await this.client.answerCallbackQuery(cq.id);
}
async resolveCatalog() {
try {
const fromApi = await loadCatalog(this.ctx);
if (fromApi)
return fromApi;
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: catalog via apiProxy failed: ${this.redact(err)}`);
}
return catalogFromLiveAgents(this.liveAgents(), this.ctx);
}
async sendWorkspacePicker(chatId, existing) {
const catalog = existing ?? await this.resolveCatalog();
const workspaces = workspacesWithVisibleSessions(catalog);
if (workspaces.length === 0) {
this.pickers.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.NO_SESSIONS);
return;
}
const shown = workspaces.slice(0, MAX_BUTTONS);
this.pickers.set(String(chatId), { workspaces: shown, sessions: [], catalog });
const keyboard = {
inline_keyboard: shown.map((ws, i) => ([{
text: truncateButton(`${i + 1}. ${ws.title}`),
callback_data: `${WS_CB}${i}`,
}])),
};
const body = [
`选择工作区(共 ${workspaces.length} 个,与 Web 对齐,已排除归档):`,
'',
...shown.map((ws, i) => {
const n = visibleSessionsForWorkspace(catalog, ws).length;
return `${i + 1}. ${ws.title}\n ${ws.path}\n 会话:${n}`;
}),
workspaces.length > MAX_BUTTONS ? `\n仅显示前 ${MAX_BUTTONS} 个工作区。` : '',
'',
'点下方按钮进入该工作区的会话列表。',
].filter(Boolean).join('\n');
await this.client.sendMessage(chatId, body, undefined, keyboard);
}
async sendSessionPicker(chatId, workspaceIndex) {
const picker = this.pickers.get(String(chatId));
const catalog = picker?.catalog ?? await this.resolveCatalog();
const workspaces = picker?.workspaces?.length
? picker.workspaces
: workspacesWithVisibleSessions(catalog);
const workspace = workspaces[workspaceIndex];
if (!workspace) {
await this.client.sendMessage(chatId, MSG.PICKER_STALE);
return;
}
const sessions = visibleSessionsForWorkspace(catalog, workspace)
.slice()
.sort((a, b) => b.updatedAt - a.updatedAt);
if (sessions.length === 0) {
await this.client.sendMessage(chatId, MSG.NO_SESSIONS_IN_WS(workspace.title));
return;
}
const shown = sessions.slice(0, MAX_BUTTONS);
this.pickers.set(String(chatId), { workspaces, sessions: shown, catalog });
const keyboard = {
inline_keyboard: [
...shown.map((row, i) => ([{
text: truncateButton(`${i + 1}. ${row.title}${row.running ? '' : ' · 冷'}`),
callback_data: `${SID_CB}${i}`,
}])),
[{ text: '← 返回工作区', callback_data: BACK_WS_CB }],
],
};
const body = [
`工作区:${workspace.title}`,
workspace.path,
'',
`选择会话(共 ${sessions.length} 个):`,
'',
...shown.map((row, i) => {
const mark = row.running ? '运行中' : '未附着';
return `${i + 1}. ${row.title}\n ${mark} · …${row.sessionId.slice(-12)}`;
}),
sessions.length > MAX_BUTTONS ? `\n仅显示前 ${MAX_BUTTONS} 个会话。` : '',
'',
'点下方按钮附着;冷会话会自动 resume(不关闭 Web)。',
].filter(Boolean).join('\n');
await this.client.sendMessage(chatId, body, undefined, keyboard);
}
async bindSession(chatId, callbackId, row) {
const agent = await this.ensureLiveAgent(row.sessionId);
if (!agent) {
await this.client.answerCallbackQuery(cq.id, '会话不存在');
await this.client.answerCallbackQuery(callbackId, '无法附着');
await this.client.sendMessage(chatId, MSG.RESUME_FAILED);
return;
}
const parts = describeAgent(agent, 0, this.ctx);
const label = row.title && row.title !== row.sessionId
? displayLabel({ ...parts, title: row.title })
: displayLabel(parts);
this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label });
await this.client.answerCallbackQuery(callbackId, '已附着');
await this.client.sendMessage(chatId, MSG.BOUND(label));
}
async sendModelPicker(chatId) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
const agent = await this.ensureLiveAgent(binding.sessionId);
if (!agent) {
this.bindings.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.GONE);
return;
}
const label = displayLabel(describeAgent(agent, 0, this.ctx));
this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label });
await this.client.answerCallbackQuery(cq.id, '已附着');
await this.client.sendMessage(chatId, MSG.BOUND(label));
try {
const snap = await loadSessionModels(this.ctx, binding.sessionId);
if (!snap.routable) {
await this.client.sendMessage(chatId, MSG.MODEL_UNROUTABLE(formatModel(snap.current)));
return;
}
if (snap.options.length === 0) {
await this.client.sendMessage(chatId, MSG.MODEL_EMPTY(formatModel(snap.current)));
return;
}
const shown = snap.options.slice(0, MAX_BUTTONS);
const prev = this.pickers.get(String(chatId));
this.pickers.set(String(chatId), {
workspaces: prev?.workspaces ?? [],
sessions: prev?.sessions ?? [],
catalog: prev?.catalog,
models: shown,
});
const keyboard = {
inline_keyboard: shown.map((opt, i) => ([{
text: truncateButton(`${opt.label}${opt.provider === snap.current.provider && opt.model === snap.current.model ? ' ✓' : ''}`),
callback_data: `${MODEL_CB}${i}`,
}])),
};
const body = [
`当前模型:${formatModel(snap.current)}`,
`会话:${binding.label}`,
'',
'选择新模型(下一回合生效):',
].join('\n');
await this.client.sendMessage(chatId, body, undefined, keyboard);
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: /model failed: ${this.redact(err)}`);
await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE);
}
}
async sendEffortPicker(chatId, option) {
const efforts = option.efforts ?? [];
const keyboard = {
inline_keyboard: [
...efforts.map((e, i) => ([{
text: truncateButton(e.name || e.id),
callback_data: `${EFFORT_CB}${i}`,
}])),
[{ text: '← 返回模型列表', callback_data: BACK_MODEL_CB }],
],
};
await this.client.sendMessage(chatId, `已选 ${option.label}\n请选择 reasoning effort:`, undefined, keyboard);
}
async applyModel(chatId, callbackId, option, reasoningEffort) {
const binding = this.bindings.get(String(chatId));
if (!binding) {
await this.client.answerCallbackQuery(callbackId, '未绑定');
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
try {
const selected = await selectSessionModel(this.ctx, binding.sessionId, {
provider: option.provider,
model: option.model,
reasoningEffort,
});
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)}`);
await this.client.answerCallbackQuery(callbackId, '切换失败');
await this.client.sendMessage(chatId, MSG.MODEL_FAILED);
}
}
liveAgents() {
const agents = this.ctx.agents;
@@ -170,27 +426,22 @@ export class TelegramBridge {
}
return this.liveAgents().find((a) => String(a.id) === sessionId);
}
async sendSessionPicker(chatId) {
const agents = this.liveAgents();
if (agents.length === 0) {
await this.client.sendMessage(chatId, MSG.NO_LIVE);
return;
/** Resume cold sessions when needed; never dispose the returned handle. */
async ensureLiveAgent(sessionId) {
const live = this.findLiveAgent(sessionId);
if (live)
return live;
const agents = this.ctx.agents;
if (typeof agents.resume !== 'function')
return undefined;
try {
const handle = await agents.resume({ resumeSessionId: SessionId(sessionId) });
return handle.agent;
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: resume failed for ${sessionId}: ${this.redact(err)}`);
return undefined;
}
const described = agents.map((agent, i) => describeAgent(agent, i, this.ctx));
const keyboard = {
inline_keyboard: described.map((parts) => ([{
text: buttonLabel(parts),
callback_data: `${BIND_CB_PREFIX}${parts.sessionId}`,
}])),
};
const body = [
`选择要遥控的本机会话(共 ${agents.length} 个):`,
'',
...described.map((parts) => detailLines(parts)),
'',
'点下方按钮附着对应会话。',
].join('\n');
await this.client.sendMessage(chatId, body, undefined, keyboard);
}
async sendStatus(chatId) {
const binding = this.bindings.get(String(chatId));
@@ -200,8 +451,7 @@ export class TelegramBridge {
}
const stillLive = this.findLiveAgent(binding.sessionId);
if (!stillLive) {
this.bindings.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.GONE);
await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label));
return;
}
await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label));
@@ -212,7 +462,7 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.NEED_BIND);
return;
}
const agent = this.findLiveAgent(binding.sessionId);
const agent = await this.ensureLiveAgent(binding.sessionId);
if (!agent) {
this.bindings.delete(String(chatId));
await this.client.sendMessage(chatId, MSG.GONE);