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:
@@ -30,9 +30,9 @@ Telegram **手机遥控器** for DeepSeek Harness:附着本机正在跑的 Web
|
|||||||
|
|
||||||
### 30 秒理解
|
### 30 秒理解
|
||||||
|
|
||||||
1. 电脑 `dsh web` 开着,并打开一个对话
|
1. 电脑 `dsh web` 开着(会话列表与 Web 对齐,已归档除外)
|
||||||
2. 手机 Bot 发 `/sessions`,点选该对话
|
2. 手机 Bot:`/sessions` → **工作区** → **会话** → 附着
|
||||||
3. 之后手机 ↔ Web 走**同一条**轨迹
|
3. 之后手机 ↔ Web 走**同一条**轨迹;可用 `/model` 切换模型(下一回合生效)
|
||||||
|
|
||||||
### 效果截图
|
### 效果截图
|
||||||
|
|
||||||
@@ -115,14 +115,16 @@ curl -fsSL https://raw.githubusercontent.com/hi-wenw/dsh-telegram-channel/master
|
|||||||
### 手机怎么用
|
### 手机怎么用
|
||||||
|
|
||||||
1. 菜单选 **2** 启动 `dsh web`(或自己运行 `dsh web`)
|
1. 菜单选 **2** 启动 `dsh web`(或自己运行 `dsh web`)
|
||||||
2. 浏览器打开一个对话并保持运行
|
2. 浏览器里可看到工作区与会话(归档会话不会出现在手机列表)
|
||||||
3. 手机对 Bot:`/start` → `/sessions` → 点选 → 聊天
|
3. 手机对 Bot:`/start` → `/sessions` → 选工作区 → 选会话 → 聊天
|
||||||
|
4. 需要换模型时:`/model` → 点选(与 Web 同 API,下一回合生效)
|
||||||
|
|
||||||
输入框旁的 **/** 菜单应有:`start` `sessions` `status` `unbind` `help`。
|
输入框旁的 **/** 菜单应有:`start` `sessions` `model` `status` `unbind` `help`。
|
||||||
|
|
||||||
| 命令 | 作用 |
|
| 命令 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/sessions` | 列出并附着本机 live 会话 |
|
| `/sessions` | 先列工作区,再列该工作区会话(与 Web 对齐,排除归档/空白/子代理);冷会话附着时会自动 resume |
|
||||||
|
| `/model` | 切换当前绑定会话的模型 |
|
||||||
| `/status` | 当前绑定 |
|
| `/status` | 当前绑定 |
|
||||||
| `/unbind` | 只断开手机,**不关**电脑会话 |
|
| `/unbind` | 只断开手机,**不关**电脑会话 |
|
||||||
| `/help` | 帮助 |
|
| `/help` | 帮助 |
|
||||||
@@ -184,7 +186,9 @@ dsh plugin --profile web add D:\path\to\dsh-telegram-channel
|
|||||||
| 手机完全没回复 / ConnectTimeout | 打开本地代理(如 7890),重启 `dsh web` |
|
| 手机完全没回复 / ConnectTimeout | 打开本地代理(如 7890),重启 `dsh web` |
|
||||||
| `missing bot token` | 检查环境变量;**新开终端**再 `dsh web` |
|
| `missing bot token` | 检查环境变量;**新开终端**再 `dsh web` |
|
||||||
| 「无权限」 | User ID 必须是 `@userinfobot` 的数字 |
|
| 「无权限」 | User ID 必须是 `@userinfobot` 的数字 |
|
||||||
| `/sessions` 无会话 | 先在 Web 打开对话 |
|
| `/sessions` 无会话 | 确认 Web 有未归档会话;空白会话会被隐藏 |
|
||||||
|
| `/sessions` 比电脑少很多 | 升级到 ≥0.3.0:应按工作区列出;仍少则检查是否归档 |
|
||||||
|
| `/model` 不可用 | 需 `dsh web`(apiProxy);先 `/sessions` 绑定 |
|
||||||
| Telegram 401 | Token 错了或被 revoke |
|
| Telegram 401 | Token 错了或被 revoke |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -233,11 +237,11 @@ dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel
|
|||||||
|
|
||||||
### What this is
|
### What this is
|
||||||
|
|
||||||
Telegram **mobile remote** for **live** DeepSeek Harness Web agents. Desktop/Web is the source of truth; the phone **attaches** (no parallel hidden agent).
|
Telegram **mobile remote** for DeepSeek Harness Web sessions. Desktop/Web is the source of truth; the phone **attaches** (no parallel hidden agent). `/sessions` is **workspace → session** (Web-aligned, archived excluded). `/model` switches the bound session’s model for the next turn.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
Phone: pick a live session and chat:
|
Phone: pick a session and chat:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -267,7 +271,7 @@ Direct actions:
|
|||||||
.\scripts\install.ps1 -Action install -Token '...' -UserId '123456789'
|
.\scripts\install.ps1 -Action install -Token '...' -UserId '123456789'
|
||||||
```
|
```
|
||||||
|
|
||||||
The script sets user env vars, ensures `allowBuilds`, and runs `dsh plugin add`. After **start**, open a Web chat → phone: `/sessions` → bind.
|
The script sets user env vars, ensures `allowBuilds`, and runs `dsh plugin add`. After **start**, phone: `/sessions` → workspace → session → bind; optional `/model`.
|
||||||
|
|
||||||
### Unix
|
### Unix
|
||||||
|
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel
|
|||||||
Optional YAML allowlist (id override only — do **not** re-insert):
|
Optional YAML allowlist (id override only — do **not** re-insert):
|
||||||
[`cordis.patch.example.yml`](cordis.patch.example.yml)
|
[`cordis.patch.example.yml`](cordis.patch.example.yml)
|
||||||
|
|
||||||
Then: `dsh web` → open a Web chat → phone `/sessions` → bind. `/unbind` disconnects phone only.
|
Then: `dsh web` → phone `/sessions` → workspace → session → bind. Optional `/model`. `/unbind` disconnects phone only.
|
||||||
|
|||||||
Vendored
+10
-1
@@ -18,6 +18,7 @@ export declare class TelegramBridge {
|
|||||||
private readonly sleep;
|
private readonly sleep;
|
||||||
private readonly maxMessageLength;
|
private readonly maxMessageLength;
|
||||||
private readonly bindings;
|
private readonly bindings;
|
||||||
|
private readonly pickers;
|
||||||
private polling;
|
private polling;
|
||||||
private offset;
|
private offset;
|
||||||
private pollPromise;
|
private pollPromise;
|
||||||
@@ -28,9 +29,17 @@ export declare class TelegramBridge {
|
|||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
processUpdate(update: TelegramUpdate): Promise<void>;
|
processUpdate(update: TelegramUpdate): Promise<void>;
|
||||||
private handleCallback;
|
private handleCallback;
|
||||||
|
private resolveCatalog;
|
||||||
|
private sendWorkspacePicker;
|
||||||
|
private sendSessionPicker;
|
||||||
|
private bindSession;
|
||||||
|
private sendModelPicker;
|
||||||
|
private sendEffortPicker;
|
||||||
|
private applyModel;
|
||||||
private liveAgents;
|
private liveAgents;
|
||||||
private findLiveAgent;
|
private findLiveAgent;
|
||||||
private sendSessionPicker;
|
/** Resume cold sessions when needed; never dispose the returned handle. */
|
||||||
|
private ensureLiveAgent;
|
||||||
private sendStatus;
|
private sendStatus;
|
||||||
private followupBound;
|
private followupBound;
|
||||||
private pollLoop;
|
private pollLoop;
|
||||||
|
|||||||
+285
-35
@@ -1,10 +1,19 @@
|
|||||||
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
||||||
import { SessionId } from '@deepseek-ai/dsh-session/types';
|
import { SessionId } from '@deepseek-ai/dsh-session/types';
|
||||||
import { isAuthorized } from './auth.js';
|
import { isAuthorized } from './auth.js';
|
||||||
|
import { catalogFromLiveAgents, loadCatalog, truncateButton, visibleSessionsForWorkspace, workspacesWithVisibleSessions, } from './catalog.js';
|
||||||
import { TelegramClient, } from './client.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 { 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));
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
function contentToText(content) {
|
function contentToText(content) {
|
||||||
return content
|
return content
|
||||||
@@ -21,6 +30,7 @@ export class TelegramBridge {
|
|||||||
sleep;
|
sleep;
|
||||||
maxMessageLength;
|
maxMessageLength;
|
||||||
bindings = new Map();
|
bindings = new Map();
|
||||||
|
pickers = new Map();
|
||||||
polling = false;
|
polling = false;
|
||||||
offset;
|
offset;
|
||||||
pollPromise;
|
pollPromise;
|
||||||
@@ -46,7 +56,8 @@ export class TelegramBridge {
|
|||||||
});
|
});
|
||||||
void this.client.setMyCommands([
|
void this.client.setMyCommands([
|
||||||
{ command: 'start', description: '欢迎与用法' },
|
{ command: 'start', description: '欢迎与用法' },
|
||||||
{ command: 'sessions', description: '列出并附着本机 live 会话' },
|
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
||||||
|
{ command: 'model', description: '切换当前绑定会话的模型' },
|
||||||
{ command: 'status', description: '查看当前绑定' },
|
{ command: 'status', description: '查看当前绑定' },
|
||||||
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
||||||
{ command: 'help', description: '显示帮助' },
|
{ command: 'help', description: '显示帮助' },
|
||||||
@@ -70,6 +81,7 @@ export class TelegramBridge {
|
|||||||
this.disposeSessionListener = undefined;
|
this.disposeSessionListener = undefined;
|
||||||
// Never dispose host agents — only clear remote bindings.
|
// Never dispose host agents — only clear remote bindings.
|
||||||
this.bindings.clear();
|
this.bindings.clear();
|
||||||
|
this.pickers.clear();
|
||||||
if (this.pollPromise) {
|
if (this.pollPromise) {
|
||||||
await this.pollPromise.catch(() => { });
|
await this.pollPromise.catch(() => { });
|
||||||
this.pollPromise = undefined;
|
this.pollPromise = undefined;
|
||||||
@@ -98,13 +110,17 @@ export class TelegramBridge {
|
|||||||
await this.client.sendMessage(chatId, MSG.HELP);
|
await this.client.sendMessage(chatId, MSG.HELP);
|
||||||
return;
|
return;
|
||||||
case 'sessions':
|
case 'sessions':
|
||||||
await this.sendSessionPicker(chatId);
|
await this.sendWorkspacePicker(chatId);
|
||||||
|
return;
|
||||||
|
case 'model':
|
||||||
|
await this.sendModelPicker(chatId);
|
||||||
return;
|
return;
|
||||||
case 'status':
|
case 'status':
|
||||||
await this.sendStatus(chatId);
|
await this.sendStatus(chatId);
|
||||||
return;
|
return;
|
||||||
case 'unbind':
|
case 'unbind':
|
||||||
this.bindings.delete(String(chatId));
|
this.bindings.delete(String(chatId));
|
||||||
|
this.pickers.delete(String(chatId));
|
||||||
await this.client.sendMessage(chatId, MSG.UNBOUND);
|
await this.client.sendMessage(chatId, MSG.UNBOUND);
|
||||||
return;
|
return;
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
@@ -129,21 +145,261 @@ export class TelegramBridge {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = cq.data ?? '';
|
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.client.answerCallbackQuery(cq.id);
|
||||||
|
await this.sendWorkspacePicker(chatId, picker?.catalog);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sessionId = data.slice(BIND_CB_PREFIX.length);
|
if (data === BACK_MODEL_CB) {
|
||||||
const agent = this.findLiveAgent(sessionId);
|
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) {
|
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);
|
await this.client.sendMessage(chatId, MSG.GONE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const label = displayLabel(describeAgent(agent, 0, this.ctx));
|
try {
|
||||||
this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label });
|
const snap = await loadSessionModels(this.ctx, binding.sessionId);
|
||||||
await this.client.answerCallbackQuery(cq.id, '已附着');
|
if (!snap.routable) {
|
||||||
await this.client.sendMessage(chatId, MSG.BOUND(label));
|
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() {
|
liveAgents() {
|
||||||
const agents = this.ctx.agents;
|
const agents = this.ctx.agents;
|
||||||
@@ -170,27 +426,22 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
return this.liveAgents().find((a) => String(a.id) === sessionId);
|
return this.liveAgents().find((a) => String(a.id) === sessionId);
|
||||||
}
|
}
|
||||||
async sendSessionPicker(chatId) {
|
/** Resume cold sessions when needed; never dispose the returned handle. */
|
||||||
const agents = this.liveAgents();
|
async ensureLiveAgent(sessionId) {
|
||||||
if (agents.length === 0) {
|
const live = this.findLiveAgent(sessionId);
|
||||||
await this.client.sendMessage(chatId, MSG.NO_LIVE);
|
if (live)
|
||||||
return;
|
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) {
|
async sendStatus(chatId) {
|
||||||
const binding = this.bindings.get(String(chatId));
|
const binding = this.bindings.get(String(chatId));
|
||||||
@@ -200,8 +451,7 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
const stillLive = this.findLiveAgent(binding.sessionId);
|
const stillLive = this.findLiveAgent(binding.sessionId);
|
||||||
if (!stillLive) {
|
if (!stillLive) {
|
||||||
this.bindings.delete(String(chatId));
|
await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label));
|
||||||
await this.client.sendMessage(chatId, MSG.GONE);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label));
|
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);
|
await this.client.sendMessage(chatId, MSG.NEED_BIND);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const agent = this.findLiveAgent(binding.sessionId);
|
const agent = await this.ensureLiveAgent(binding.sessionId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
this.bindings.delete(String(chatId));
|
this.bindings.delete(String(chatId));
|
||||||
await this.client.sendMessage(chatId, MSG.GONE);
|
await this.client.sendMessage(chatId, MSG.GONE);
|
||||||
|
|||||||
Vendored
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Context } from '@deepseek-ai/cordis';
|
||||||
|
import type { Agent } from '@deepseek-ai/dsh-agent';
|
||||||
|
export interface WorkspaceRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
sessionIds: string[];
|
||||||
|
}
|
||||||
|
export interface SessionRow {
|
||||||
|
sessionId: string;
|
||||||
|
title: string;
|
||||||
|
cwd?: string;
|
||||||
|
blank: boolean;
|
||||||
|
running: boolean;
|
||||||
|
origin?: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
export interface CatalogSnapshot {
|
||||||
|
workspaces: WorkspaceRow[];
|
||||||
|
sessionsById: Map<string, SessionRow>;
|
||||||
|
archivedIds: Set<string>;
|
||||||
|
}
|
||||||
|
/** Load workspaces + sessions aligned with Web UI (via apiProxy when available). */
|
||||||
|
export declare function loadCatalog(ctx: Context): Promise<CatalogSnapshot | undefined>;
|
||||||
|
/** Sessions visible under one workspace (Web-like filters). */
|
||||||
|
export declare function visibleSessionsForWorkspace(catalog: CatalogSnapshot, workspace: WorkspaceRow): SessionRow[];
|
||||||
|
export declare function workspacesWithVisibleSessions(catalog: CatalogSnapshot): WorkspaceRow[];
|
||||||
|
export declare function truncateButton(text: string, max?: number): string;
|
||||||
|
/** Fallback when apiProxy is unavailable: group live agents by cwd. */
|
||||||
|
export declare function catalogFromLiveAgents(agents: Agent[], ctx?: Context): CatalogSnapshot;
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { describeAgent, workspaceName } from './label.js';
|
||||||
|
function rpcCall(fn, payload) {
|
||||||
|
if (typeof fn !== 'function')
|
||||||
|
return undefined;
|
||||||
|
return fn({ rpcId: randomUUID(), payload });
|
||||||
|
}
|
||||||
|
function unwrap(res) {
|
||||||
|
const r = res;
|
||||||
|
if (r?.result && r.result.ok === true) {
|
||||||
|
return r.result.value;
|
||||||
|
}
|
||||||
|
// Some wrappers may return value directly
|
||||||
|
if (res && typeof res === 'object' && 'items' in res)
|
||||||
|
return res;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function titleOf(summary) {
|
||||||
|
const t = summary.projections?.values?.title;
|
||||||
|
if (typeof t === 'string' && t.trim())
|
||||||
|
return t.trim();
|
||||||
|
const ws = workspaceName(summary.cwd);
|
||||||
|
if (ws)
|
||||||
|
return ws;
|
||||||
|
const id = String(summary.sessionId);
|
||||||
|
return id.length > 12 ? `…${id.slice(-12)}` : id;
|
||||||
|
}
|
||||||
|
/** Load workspaces + sessions aligned with Web UI (via apiProxy when available). */
|
||||||
|
export async function loadCatalog(ctx) {
|
||||||
|
const api = ctx.apiProxy;
|
||||||
|
const wsPromise = rpcCall(api?.workspace?.list, {});
|
||||||
|
const sessPromise = rpcCall(api?.sessions?.list, {});
|
||||||
|
if (!wsPromise || !sessPromise)
|
||||||
|
return undefined;
|
||||||
|
const [wsRaw, sessRaw] = await Promise.all([wsPromise, sessPromise]);
|
||||||
|
const wsVal = unwrap(wsRaw);
|
||||||
|
const sessVal = unwrap(sessRaw);
|
||||||
|
if (!wsVal || !sessVal)
|
||||||
|
return undefined;
|
||||||
|
const archivedIds = new Set((wsVal.archivedSessionIds ?? []).map(String));
|
||||||
|
const sessionsById = new Map();
|
||||||
|
for (const item of sessVal.items ?? []) {
|
||||||
|
const sessionId = String(item.sessionId);
|
||||||
|
sessionsById.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
title: titleOf({ ...item, sessionId }),
|
||||||
|
cwd: item.cwd,
|
||||||
|
blank: Boolean(item.blank),
|
||||||
|
running: Boolean(item.running),
|
||||||
|
origin: item.origin,
|
||||||
|
updatedAt: Number(item.updatedAt) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const workspaces = (wsVal.items ?? []).map((w) => ({
|
||||||
|
id: String(w.workspaceId),
|
||||||
|
title: w.title || workspaceName(w.path) || String(w.workspaceId),
|
||||||
|
path: w.path,
|
||||||
|
sessionIds: (w.sessionIds ?? []).map(String),
|
||||||
|
}));
|
||||||
|
return { workspaces, sessionsById, archivedIds };
|
||||||
|
}
|
||||||
|
/** Sessions visible under one workspace (Web-like filters). */
|
||||||
|
export function visibleSessionsForWorkspace(catalog, workspace) {
|
||||||
|
const rows = [];
|
||||||
|
for (const id of workspace.sessionIds) {
|
||||||
|
if (catalog.archivedIds.has(id))
|
||||||
|
continue;
|
||||||
|
const row = catalog.sessionsById.get(id);
|
||||||
|
if (!row)
|
||||||
|
continue;
|
||||||
|
if (row.origin === 'subagent')
|
||||||
|
continue;
|
||||||
|
if (row.blank)
|
||||||
|
continue;
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
export function workspacesWithVisibleSessions(catalog) {
|
||||||
|
return catalog.workspaces.filter((w) => visibleSessionsForWorkspace(catalog, w).length > 0);
|
||||||
|
}
|
||||||
|
export function truncateButton(text, max = 64) {
|
||||||
|
const chars = [...text];
|
||||||
|
if (chars.length <= max)
|
||||||
|
return text;
|
||||||
|
return `${chars.slice(0, max - 1).join('')}…`;
|
||||||
|
}
|
||||||
|
/** Fallback when apiProxy is unavailable: group live agents by cwd. */
|
||||||
|
export function catalogFromLiveAgents(agents, ctx) {
|
||||||
|
const sessionsById = new Map();
|
||||||
|
const byPath = new Map();
|
||||||
|
agents.forEach((agent, index) => {
|
||||||
|
const parts = describeAgent(agent, index, ctx);
|
||||||
|
const sessionId = parts.sessionId;
|
||||||
|
const cwd = parts.cwd ?? '(unknown)';
|
||||||
|
sessionsById.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
title: parts.title || parts.workspace || sessionId,
|
||||||
|
cwd: parts.cwd,
|
||||||
|
blank: false,
|
||||||
|
running: true,
|
||||||
|
updatedAt: Date.now() - index,
|
||||||
|
});
|
||||||
|
const bucket = byPath.get(cwd) ?? {
|
||||||
|
title: parts.workspace || cwd,
|
||||||
|
path: cwd,
|
||||||
|
sessionIds: [],
|
||||||
|
};
|
||||||
|
bucket.sessionIds.push(sessionId);
|
||||||
|
byPath.set(cwd, bucket);
|
||||||
|
});
|
||||||
|
const workspaces = [...byPath.entries()].map(([path, bucket], i) => ({
|
||||||
|
id: `live:${i}`,
|
||||||
|
title: bucket.title,
|
||||||
|
path,
|
||||||
|
sessionIds: bucket.sessionIds,
|
||||||
|
}));
|
||||||
|
return { workspaces, sessionsById, archivedIds: new Set() };
|
||||||
|
}
|
||||||
Vendored
+17
-3
@@ -3,12 +3,23 @@ export declare const MSG: {
|
|||||||
readonly WELCOME: string;
|
readonly WELCOME: string;
|
||||||
readonly HELP: string;
|
readonly HELP: string;
|
||||||
readonly NEED_BIND: "尚未绑定本机会话。请先发送 /sessions 选择一个。";
|
readonly NEED_BIND: "尚未绑定本机会话。请先发送 /sessions 选择一个。";
|
||||||
readonly NO_LIVE: "当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。";
|
readonly NO_SESSIONS: "当前没有可附着的本机会话(已排除归档与空白会话)。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。";
|
||||||
|
readonly NO_SESSIONS_IN_WS: (title: string) => string;
|
||||||
|
/** @deprecated use NO_SESSIONS */
|
||||||
|
readonly NO_LIVE: "当前没有可附着的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。";
|
||||||
|
readonly PICKER_STALE: "列表已过期,请重新发送 /sessions。";
|
||||||
|
readonly RESUME_FAILED: "无法附着该会话(resume 失败)。请确认会话存在于 Web,或先在电脑打开后再试。";
|
||||||
readonly BOUND: (label: string) => string;
|
readonly BOUND: (label: string) => string;
|
||||||
readonly UNBOUND: "已断开绑定。本机会话仍在运行。";
|
readonly UNBOUND: "已断开绑定。本机会话仍在运行。";
|
||||||
readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。";
|
readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。";
|
||||||
readonly STATUS_BOUND: (label: string) => string;
|
readonly STATUS_BOUND: (label: string) => string;
|
||||||
readonly GONE: "绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。";
|
readonly STATUS_BOUND_COLD: (label: string) => string;
|
||||||
|
readonly GONE: "绑定的会话已不可用。请重新 /sessions。";
|
||||||
|
readonly MODEL_UNAVAILABLE: "无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。";
|
||||||
|
readonly MODEL_UNROUTABLE: (current: string) => string;
|
||||||
|
readonly MODEL_EMPTY: (current: string) => string;
|
||||||
|
readonly MODEL_SET: (selected: string) => string;
|
||||||
|
readonly MODEL_FAILED: "切换模型失败。请稍后重试或在 Web 中切换。";
|
||||||
readonly unknown: (command: string) => string;
|
readonly unknown: (command: string) => string;
|
||||||
};
|
};
|
||||||
export type ParsedCommand = {
|
export type ParsedCommand = {
|
||||||
@@ -20,6 +31,9 @@ export type ParsedCommand = {
|
|||||||
} | {
|
} | {
|
||||||
type: 'sessions';
|
type: 'sessions';
|
||||||
text: string;
|
text: string;
|
||||||
|
} | {
|
||||||
|
type: 'model';
|
||||||
|
text: string;
|
||||||
} | {
|
} | {
|
||||||
type: 'status';
|
type: 'status';
|
||||||
text: string;
|
text: string;
|
||||||
@@ -35,5 +49,5 @@ export type ParsedCommand = {
|
|||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
export declare function parseCommand(text: string): ParsedCommand;
|
export declare function parseCommand(text: string): ParsedCommand;
|
||||||
/** Callback data prefix for session bind buttons. */
|
/** @deprecated Prefer short index callbacks (ws:/sid:); kept for old messages. */
|
||||||
export declare const BIND_CB_PREFIX = "bind:";
|
export declare const BIND_CB_PREFIX = "bind:";
|
||||||
|
|||||||
+30
-6
@@ -2,20 +2,28 @@ export const MSG = {
|
|||||||
DENIED: '无权限。',
|
DENIED: '无权限。',
|
||||||
WELCOME: [
|
WELCOME: [
|
||||||
'你好,这是 DeepSeek Harness 的手机遥控器。',
|
'你好,这是 DeepSeek Harness 的手机遥控器。',
|
||||||
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。',
|
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择工作区 → 会话并附着。',
|
||||||
'发送 /help 查看命令。',
|
'发送 /help 查看命令。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
HELP: [
|
HELP: [
|
||||||
'/sessions — 列出本机正在运行的会话并选择附着',
|
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
||||||
|
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
||||||
'/status — 查看当前绑定',
|
'/status — 查看当前绑定',
|
||||||
'/unbind — 断开绑定(不关闭本机会话)',
|
'/unbind — 断开绑定(不关闭本机会话)',
|
||||||
'/help — 显示帮助',
|
'/help — 显示帮助',
|
||||||
'',
|
'',
|
||||||
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
||||||
'仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。',
|
'仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
||||||
NO_LIVE: '当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
|
NO_SESSIONS: '当前没有可附着的本机会话(已排除归档与空白会话)。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
|
||||||
|
NO_SESSIONS_IN_WS(title) {
|
||||||
|
return `工作区「${title}」下没有可附着的会话。`;
|
||||||
|
},
|
||||||
|
/** @deprecated use NO_SESSIONS */
|
||||||
|
NO_LIVE: '当前没有可附着的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
|
||||||
|
PICKER_STALE: '列表已过期,请重新发送 /sessions。',
|
||||||
|
RESUME_FAILED: '无法附着该会话(resume 失败)。请确认会话存在于 Web,或先在电脑打开后再试。',
|
||||||
BOUND(label) {
|
BOUND(label) {
|
||||||
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`;
|
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`;
|
||||||
},
|
},
|
||||||
@@ -24,7 +32,21 @@ export const MSG = {
|
|||||||
STATUS_BOUND(label) {
|
STATUS_BOUND(label) {
|
||||||
return `当前绑定:${label}`;
|
return `当前绑定:${label}`;
|
||||||
},
|
},
|
||||||
GONE: '绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。',
|
STATUS_BOUND_COLD(label) {
|
||||||
|
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`;
|
||||||
|
},
|
||||||
|
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
||||||
|
MODEL_UNAVAILABLE: '无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。',
|
||||||
|
MODEL_UNROUTABLE(current) {
|
||||||
|
return `当前模型不可路由:${current}\n请在 Web 或本机配置可用 provider 后再试 /model。`;
|
||||||
|
},
|
||||||
|
MODEL_EMPTY(current) {
|
||||||
|
return `当前:${current}\n没有可切换的模型选项。`;
|
||||||
|
},
|
||||||
|
MODEL_SET(selected) {
|
||||||
|
return `已切换模型:${selected}\n下一回合生效。`;
|
||||||
|
},
|
||||||
|
MODEL_FAILED: '切换模型失败。请稍后重试或在 Web 中切换。',
|
||||||
unknown(command) {
|
unknown(command) {
|
||||||
return `未知命令 ${command}。发送 /help 查看可用命令。`;
|
return `未知命令 ${command}。发送 /help 查看可用命令。`;
|
||||||
},
|
},
|
||||||
@@ -42,6 +64,8 @@ export function parseCommand(text) {
|
|||||||
case '/sessions':
|
case '/sessions':
|
||||||
case '/list':
|
case '/list':
|
||||||
return { type: 'sessions', text };
|
return { type: 'sessions', text };
|
||||||
|
case '/model':
|
||||||
|
return { type: 'model', text };
|
||||||
case '/status':
|
case '/status':
|
||||||
return { type: 'status', text };
|
return { type: 'status', text };
|
||||||
case '/unbind':
|
case '/unbind':
|
||||||
@@ -51,5 +75,5 @@ export function parseCommand(text) {
|
|||||||
return { type: 'unknown', command, text };
|
return { type: 'unknown', command, text };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Callback data prefix for session bind buttons. */
|
/** @deprecated Prefer short index callbacks (ws:/sid:); kept for old messages. */
|
||||||
export const BIND_CB_PREFIX = 'bind:';
|
export const BIND_CB_PREFIX = 'bind:';
|
||||||
|
|||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Context } from '@deepseek-ai/cordis';
|
||||||
|
export interface ModelOption {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
label: string;
|
||||||
|
efforts?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
export interface ModelSnapshot {
|
||||||
|
current: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort?: string;
|
||||||
|
};
|
||||||
|
routable: boolean;
|
||||||
|
options: ModelOption[];
|
||||||
|
}
|
||||||
|
export declare function loadSessionModels(ctx: Context, sessionId: string): Promise<ModelSnapshot>;
|
||||||
|
export declare function selectSessionModel(ctx: Context, sessionId: string, selection: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort?: string;
|
||||||
|
}): Promise<{
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort?: string;
|
||||||
|
}>;
|
||||||
|
export declare function formatModel(sel: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort?: string;
|
||||||
|
}): string;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
function unwrap(res) {
|
||||||
|
const r = res;
|
||||||
|
if (r?.result?.ok === true)
|
||||||
|
return r.result.value;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
async function call(fn, payload) {
|
||||||
|
if (typeof fn !== 'function')
|
||||||
|
throw new Error('apiProxy sessions API unavailable');
|
||||||
|
return fn({ rpcId: randomUUID(), payload });
|
||||||
|
}
|
||||||
|
export async function loadSessionModels(ctx, sessionId) {
|
||||||
|
const api = ctx.apiProxy;
|
||||||
|
const raw = await call(api?.sessions?.models, { sessionId });
|
||||||
|
const value = unwrap(raw);
|
||||||
|
if (!value)
|
||||||
|
throw new Error('failed to load session models');
|
||||||
|
const options = [];
|
||||||
|
for (const group of value.groups ?? []) {
|
||||||
|
for (const m of group.models ?? []) {
|
||||||
|
options.push({
|
||||||
|
provider: group.id,
|
||||||
|
model: m.id,
|
||||||
|
label: `${group.name}/${m.name}`,
|
||||||
|
efforts: m.reasoning?.efforts?.map((e) => ({ id: e.id, name: e.name })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
current: value.current,
|
||||||
|
routable: value.routable,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function selectSessionModel(ctx, sessionId, selection) {
|
||||||
|
const api = ctx.apiProxy;
|
||||||
|
const raw = await call(api?.sessions?.selectModel, {
|
||||||
|
sessionId,
|
||||||
|
provider: selection.provider,
|
||||||
|
model: selection.model,
|
||||||
|
reasoningEffort: selection.reasoningEffort,
|
||||||
|
});
|
||||||
|
const value = unwrap(raw);
|
||||||
|
if (!value?.selected)
|
||||||
|
throw new Error('failed to select model');
|
||||||
|
return value.selected;
|
||||||
|
}
|
||||||
|
export function formatModel(sel) {
|
||||||
|
const base = `${sel.provider}/${sel.model}`;
|
||||||
|
return sel.reasoningEffort ? `${base} (${sel.reasoningEffort})` : base;
|
||||||
|
}
|
||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dsh-telegram-channel",
|
"name": "dsh-telegram-channel",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"description": "DeepSeek Harness Telegram mobile remote: bind live Web sessions (Codex-style), same trajectory, /sessions picker, unbind/clear — dsh-plugin",
|
"description": "DeepSeek Harness Telegram mobile remote: workspace→session picker (Web-aligned), /model switch, same trajectory — dsh-plugin",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"telegram",
|
"telegram",
|
||||||
"bot",
|
"bot",
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
"remote",
|
"remote",
|
||||||
"channel",
|
"channel",
|
||||||
"sessions",
|
"sessions",
|
||||||
|
"workspace",
|
||||||
|
"model",
|
||||||
"bind",
|
"bind",
|
||||||
"unbind",
|
"unbind",
|
||||||
"remove",
|
"remove",
|
||||||
|
|||||||
+322
-35
@@ -4,15 +4,31 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
|
|||||||
import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types'
|
import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types'
|
||||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types'
|
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||||
import { isAuthorized } from './auth.js'
|
import { isAuthorized } from './auth.js'
|
||||||
|
import {
|
||||||
|
catalogFromLiveAgents,
|
||||||
|
loadCatalog,
|
||||||
|
truncateButton,
|
||||||
|
visibleSessionsForWorkspace,
|
||||||
|
workspacesWithVisibleSessions,
|
||||||
|
type CatalogSnapshot,
|
||||||
|
type SessionRow,
|
||||||
|
type WorkspaceRow,
|
||||||
|
} from './catalog.js'
|
||||||
import {
|
import {
|
||||||
TelegramClient,
|
TelegramClient,
|
||||||
type InlineKeyboardMarkup,
|
type InlineKeyboardMarkup,
|
||||||
type TelegramClientLike,
|
type TelegramClientLike,
|
||||||
type TelegramUpdate,
|
type TelegramUpdate,
|
||||||
} from './client.js'
|
} 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 { markdownToHtml, splitMessage } from './format.js'
|
||||||
import { buttonLabel, describeAgent, detailLines, displayLabel } from './label.js'
|
import { describeAgent, displayLabel } from './label.js'
|
||||||
|
import {
|
||||||
|
formatModel,
|
||||||
|
loadSessionModels,
|
||||||
|
selectSessionModel,
|
||||||
|
type ModelOption,
|
||||||
|
} from './models.js'
|
||||||
|
|
||||||
export interface TelegramBridgeOptions {
|
export interface TelegramBridgeOptions {
|
||||||
token: string
|
token: string
|
||||||
@@ -31,10 +47,26 @@ interface Binding {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PickerState {
|
||||||
|
workspaces: WorkspaceRow[]
|
||||||
|
sessions: SessionRow[]
|
||||||
|
catalog?: CatalogSnapshot
|
||||||
|
models?: ModelOption[]
|
||||||
|
pendingModel?: ModelOption
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionLike {
|
interface SessionLike {
|
||||||
id: ReturnType<typeof SessionId>
|
id: ReturnType<typeof SessionId>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
function contentToText(content: readonly ContentBlock[]): string {
|
function contentToText(content: readonly ContentBlock[]): string {
|
||||||
@@ -54,6 +86,7 @@ export class TelegramBridge {
|
|||||||
private readonly maxMessageLength: number
|
private readonly maxMessageLength: number
|
||||||
|
|
||||||
private readonly bindings = new Map<string, Binding>()
|
private readonly bindings = new Map<string, Binding>()
|
||||||
|
private readonly pickers = new Map<string, PickerState>()
|
||||||
private polling = false
|
private polling = false
|
||||||
private offset: number | undefined
|
private offset: number | undefined
|
||||||
private pollPromise: Promise<void> | undefined
|
private pollPromise: Promise<void> | undefined
|
||||||
@@ -81,7 +114,8 @@ export class TelegramBridge {
|
|||||||
})
|
})
|
||||||
void this.client.setMyCommands([
|
void this.client.setMyCommands([
|
||||||
{ command: 'start', description: '欢迎与用法' },
|
{ command: 'start', description: '欢迎与用法' },
|
||||||
{ command: 'sessions', description: '列出并附着本机 live 会话' },
|
{ command: 'sessions', description: '按工作区列出并附着会话' },
|
||||||
|
{ command: 'model', description: '切换当前绑定会话的模型' },
|
||||||
{ command: 'status', description: '查看当前绑定' },
|
{ command: 'status', description: '查看当前绑定' },
|
||||||
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
|
||||||
{ command: 'help', description: '显示帮助' },
|
{ command: 'help', description: '显示帮助' },
|
||||||
@@ -106,6 +140,7 @@ export class TelegramBridge {
|
|||||||
this.disposeSessionListener = undefined
|
this.disposeSessionListener = undefined
|
||||||
// Never dispose host agents — only clear remote bindings.
|
// Never dispose host agents — only clear remote bindings.
|
||||||
this.bindings.clear()
|
this.bindings.clear()
|
||||||
|
this.pickers.clear()
|
||||||
if (this.pollPromise) {
|
if (this.pollPromise) {
|
||||||
await this.pollPromise.catch(() => {})
|
await this.pollPromise.catch(() => {})
|
||||||
this.pollPromise = undefined
|
this.pollPromise = undefined
|
||||||
@@ -137,13 +172,17 @@ export class TelegramBridge {
|
|||||||
await this.client.sendMessage(chatId, MSG.HELP)
|
await this.client.sendMessage(chatId, MSG.HELP)
|
||||||
return
|
return
|
||||||
case 'sessions':
|
case 'sessions':
|
||||||
await this.sendSessionPicker(chatId)
|
await this.sendWorkspacePicker(chatId)
|
||||||
|
return
|
||||||
|
case 'model':
|
||||||
|
await this.sendModelPicker(chatId)
|
||||||
return
|
return
|
||||||
case 'status':
|
case 'status':
|
||||||
await this.sendStatus(chatId)
|
await this.sendStatus(chatId)
|
||||||
return
|
return
|
||||||
case 'unbind':
|
case 'unbind':
|
||||||
this.bindings.delete(String(chatId))
|
this.bindings.delete(String(chatId))
|
||||||
|
this.pickers.delete(String(chatId))
|
||||||
await this.client.sendMessage(chatId, MSG.UNBOUND)
|
await this.client.sendMessage(chatId, MSG.UNBOUND)
|
||||||
return
|
return
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
@@ -169,21 +208,278 @@ export class TelegramBridge {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = cq.data ?? ''
|
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.client.answerCallbackQuery(cq.id)
|
||||||
|
await this.sendWorkspacePicker(chatId, picker?.catalog)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const sessionId = data.slice(BIND_CB_PREFIX.length)
|
if (data === BACK_MODEL_CB) {
|
||||||
const agent = this.findLiveAgent(sessionId)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCatalog(): Promise<CatalogSnapshot> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendWorkspacePicker(chatId: number, existing?: CatalogSnapshot): Promise<void> {
|
||||||
|
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: InlineKeyboardMarkup = {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendSessionPicker(chatId: number, workspaceIndex: number): Promise<void> {
|
||||||
|
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: InlineKeyboardMarkup = {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bindSession(chatId: number, callbackId: string, row: SessionRow): Promise<void> {
|
||||||
|
const agent = await this.ensureLiveAgent(row.sessionId)
|
||||||
if (!agent) {
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendModelPicker(chatId: number): Promise<void> {
|
||||||
|
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)
|
await this.client.sendMessage(chatId, MSG.GONE)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const label = displayLabel(describeAgent(agent, 0, this.ctx))
|
try {
|
||||||
this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label })
|
const snap = await loadSessionModels(this.ctx, binding.sessionId)
|
||||||
await this.client.answerCallbackQuery(cq.id, '已附着')
|
if (!snap.routable) {
|
||||||
await this.client.sendMessage(chatId, MSG.BOUND(label))
|
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: InlineKeyboardMarkup = {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendEffortPicker(chatId: number, option: ModelOption): Promise<void> {
|
||||||
|
const efforts = option.efforts ?? []
|
||||||
|
const keyboard: InlineKeyboardMarkup = {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyModel(
|
||||||
|
chatId: number,
|
||||||
|
callbackId: string,
|
||||||
|
option: ModelOption,
|
||||||
|
reasoningEffort?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private liveAgents(): Agent[] {
|
private liveAgents(): Agent[] {
|
||||||
@@ -209,27 +505,19 @@ export class TelegramBridge {
|
|||||||
return this.liveAgents().find((a) => String(a.id) === sessionId)
|
return this.liveAgents().find((a) => String(a.id) === sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendSessionPicker(chatId: number): Promise<void> {
|
/** Resume cold sessions when needed; never dispose the returned handle. */
|
||||||
const agents = this.liveAgents()
|
private async ensureLiveAgent(sessionId: string): Promise<Agent | undefined> {
|
||||||
if (agents.length === 0) {
|
const live = this.findLiveAgent(sessionId)
|
||||||
await this.client.sendMessage(chatId, MSG.NO_LIVE)
|
if (live) return live
|
||||||
return
|
const agents = this.ctx.agents as { resume?: (opts: { resumeSessionId: ReturnType<typeof SessionId> }) => Promise<{ agent: Agent }> }
|
||||||
|
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: InlineKeyboardMarkup = {
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendStatus(chatId: number): Promise<void> {
|
private async sendStatus(chatId: number): Promise<void> {
|
||||||
@@ -240,8 +528,7 @@ export class TelegramBridge {
|
|||||||
}
|
}
|
||||||
const stillLive = this.findLiveAgent(binding.sessionId)
|
const stillLive = this.findLiveAgent(binding.sessionId)
|
||||||
if (!stillLive) {
|
if (!stillLive) {
|
||||||
this.bindings.delete(String(chatId))
|
await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label))
|
||||||
await this.client.sendMessage(chatId, MSG.GONE)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label))
|
await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label))
|
||||||
@@ -253,7 +540,7 @@ export class TelegramBridge {
|
|||||||
await this.client.sendMessage(chatId, MSG.NEED_BIND)
|
await this.client.sendMessage(chatId, MSG.NEED_BIND)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const agent = this.findLiveAgent(binding.sessionId)
|
const agent = await this.ensureLiveAgent(binding.sessionId)
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
this.bindings.delete(String(chatId))
|
this.bindings.delete(String(chatId))
|
||||||
await this.client.sendMessage(chatId, MSG.GONE)
|
await this.client.sendMessage(chatId, MSG.GONE)
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
|
import { describeAgent, workspaceName } from './label.js'
|
||||||
|
|
||||||
|
export interface WorkspaceRow {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
sessionIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRow {
|
||||||
|
sessionId: string
|
||||||
|
title: string
|
||||||
|
cwd?: string
|
||||||
|
blank: boolean
|
||||||
|
running: boolean
|
||||||
|
origin?: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogSnapshot {
|
||||||
|
workspaces: WorkspaceRow[]
|
||||||
|
sessionsById: Map<string, SessionRow>
|
||||||
|
archivedIds: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type RpcOk<T> = { result: { ok: true; value: T } }
|
||||||
|
type ApiFn = (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||||
|
|
||||||
|
function rpcCall(fn: ApiFn | undefined, payload: unknown): Promise<unknown> | undefined {
|
||||||
|
if (typeof fn !== 'function') return undefined
|
||||||
|
return fn({ rpcId: randomUUID(), payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap<T>(res: unknown): T | undefined {
|
||||||
|
const r = res as RpcOk<T> | undefined
|
||||||
|
if (r?.result && (r.result as { ok?: boolean }).ok === true) {
|
||||||
|
return (r.result as { value: T }).value
|
||||||
|
}
|
||||||
|
// Some wrappers may return value directly
|
||||||
|
if (res && typeof res === 'object' && 'items' in (res as object)) return res as T
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleOf(summary: {
|
||||||
|
sessionId: string
|
||||||
|
cwd?: string
|
||||||
|
projections?: { values?: { title?: string | null } }
|
||||||
|
}): string {
|
||||||
|
const t = summary.projections?.values?.title
|
||||||
|
if (typeof t === 'string' && t.trim()) return t.trim()
|
||||||
|
const ws = workspaceName(summary.cwd)
|
||||||
|
if (ws) return ws
|
||||||
|
const id = String(summary.sessionId)
|
||||||
|
return id.length > 12 ? `…${id.slice(-12)}` : id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load workspaces + sessions aligned with Web UI (via apiProxy when available). */
|
||||||
|
export async function loadCatalog(ctx: Context): Promise<CatalogSnapshot | undefined> {
|
||||||
|
const api = (ctx as {
|
||||||
|
apiProxy?: {
|
||||||
|
workspace?: { list?: ApiFn }
|
||||||
|
sessions?: { list?: ApiFn }
|
||||||
|
}
|
||||||
|
}).apiProxy
|
||||||
|
|
||||||
|
const wsPromise = rpcCall(api?.workspace?.list, {})
|
||||||
|
const sessPromise = rpcCall(api?.sessions?.list, {})
|
||||||
|
if (!wsPromise || !sessPromise) return undefined
|
||||||
|
|
||||||
|
const [wsRaw, sessRaw] = await Promise.all([wsPromise, sessPromise])
|
||||||
|
const wsVal = unwrap<{
|
||||||
|
items: Array<{ workspaceId: string; path: string; title: string; sessionIds: string[] }>
|
||||||
|
archivedSessionIds: string[]
|
||||||
|
}>(wsRaw)
|
||||||
|
const sessVal = unwrap<{
|
||||||
|
items: Array<{
|
||||||
|
sessionId: string
|
||||||
|
updatedAt: number
|
||||||
|
running: boolean
|
||||||
|
blank: boolean
|
||||||
|
origin?: string
|
||||||
|
cwd?: string
|
||||||
|
projections?: { values?: { title?: string | null } }
|
||||||
|
}>
|
||||||
|
}>(sessRaw)
|
||||||
|
|
||||||
|
if (!wsVal || !sessVal) return undefined
|
||||||
|
|
||||||
|
const archivedIds = new Set((wsVal.archivedSessionIds ?? []).map(String))
|
||||||
|
const sessionsById = new Map<string, SessionRow>()
|
||||||
|
for (const item of sessVal.items ?? []) {
|
||||||
|
const sessionId = String(item.sessionId)
|
||||||
|
sessionsById.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
title: titleOf({ ...item, sessionId }),
|
||||||
|
cwd: item.cwd,
|
||||||
|
blank: Boolean(item.blank),
|
||||||
|
running: Boolean(item.running),
|
||||||
|
origin: item.origin,
|
||||||
|
updatedAt: Number(item.updatedAt) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaces: WorkspaceRow[] = (wsVal.items ?? []).map((w) => ({
|
||||||
|
id: String(w.workspaceId),
|
||||||
|
title: w.title || workspaceName(w.path) || String(w.workspaceId),
|
||||||
|
path: w.path,
|
||||||
|
sessionIds: (w.sessionIds ?? []).map(String),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { workspaces, sessionsById, archivedIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sessions visible under one workspace (Web-like filters). */
|
||||||
|
export function visibleSessionsForWorkspace(
|
||||||
|
catalog: CatalogSnapshot,
|
||||||
|
workspace: WorkspaceRow,
|
||||||
|
): SessionRow[] {
|
||||||
|
const rows: SessionRow[] = []
|
||||||
|
for (const id of workspace.sessionIds) {
|
||||||
|
if (catalog.archivedIds.has(id)) continue
|
||||||
|
const row = catalog.sessionsById.get(id)
|
||||||
|
if (!row) continue
|
||||||
|
if (row.origin === 'subagent') continue
|
||||||
|
if (row.blank) continue
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspacesWithVisibleSessions(catalog: CatalogSnapshot): WorkspaceRow[] {
|
||||||
|
return catalog.workspaces.filter((w) => visibleSessionsForWorkspace(catalog, w).length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateButton(text: string, max = 64): string {
|
||||||
|
const chars = [...text]
|
||||||
|
if (chars.length <= max) return text
|
||||||
|
return `${chars.slice(0, max - 1).join('')}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback when apiProxy is unavailable: group live agents by cwd. */
|
||||||
|
export function catalogFromLiveAgents(agents: Agent[], ctx?: Context): CatalogSnapshot {
|
||||||
|
const sessionsById = new Map<string, SessionRow>()
|
||||||
|
const byPath = new Map<string, { title: string; path: string; sessionIds: string[] }>()
|
||||||
|
|
||||||
|
agents.forEach((agent, index) => {
|
||||||
|
const parts = describeAgent(agent, index, ctx)
|
||||||
|
const sessionId = parts.sessionId
|
||||||
|
const cwd = parts.cwd ?? '(unknown)'
|
||||||
|
sessionsById.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
title: parts.title || parts.workspace || sessionId,
|
||||||
|
cwd: parts.cwd,
|
||||||
|
blank: false,
|
||||||
|
running: true,
|
||||||
|
updatedAt: Date.now() - index,
|
||||||
|
})
|
||||||
|
const bucket = byPath.get(cwd) ?? {
|
||||||
|
title: parts.workspace || cwd,
|
||||||
|
path: cwd,
|
||||||
|
sessionIds: [],
|
||||||
|
}
|
||||||
|
bucket.sessionIds.push(sessionId)
|
||||||
|
byPath.set(cwd, bucket)
|
||||||
|
})
|
||||||
|
|
||||||
|
const workspaces: WorkspaceRow[] = [...byPath.entries()].map(([path, bucket], i) => ({
|
||||||
|
id: `live:${i}`,
|
||||||
|
title: bucket.title,
|
||||||
|
path,
|
||||||
|
sessionIds: bucket.sessionIds,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { workspaces, sessionsById, archivedIds: new Set() }
|
||||||
|
}
|
||||||
+31
-6
@@ -2,20 +2,28 @@ export const MSG = {
|
|||||||
DENIED: '无权限。',
|
DENIED: '无权限。',
|
||||||
WELCOME: [
|
WELCOME: [
|
||||||
'你好,这是 DeepSeek Harness 的手机遥控器。',
|
'你好,这是 DeepSeek Harness 的手机遥控器。',
|
||||||
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。',
|
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择工作区 → 会话并附着。',
|
||||||
'发送 /help 查看命令。',
|
'发送 /help 查看命令。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
HELP: [
|
HELP: [
|
||||||
'/sessions — 列出本机正在运行的会话并选择附着',
|
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
|
||||||
|
'/model — 切换当前绑定会话的模型(下一回合生效)',
|
||||||
'/status — 查看当前绑定',
|
'/status — 查看当前绑定',
|
||||||
'/unbind — 断开绑定(不关闭本机会话)',
|
'/unbind — 断开绑定(不关闭本机会话)',
|
||||||
'/help — 显示帮助',
|
'/help — 显示帮助',
|
||||||
'',
|
'',
|
||||||
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
||||||
'仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。',
|
'仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
||||||
NO_LIVE: '当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
|
NO_SESSIONS: '当前没有可附着的本机会话(已排除归档与空白会话)。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
|
||||||
|
NO_SESSIONS_IN_WS(title: string): string {
|
||||||
|
return `工作区「${title}」下没有可附着的会话。`
|
||||||
|
},
|
||||||
|
/** @deprecated use NO_SESSIONS */
|
||||||
|
NO_LIVE: '当前没有可附着的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
|
||||||
|
PICKER_STALE: '列表已过期,请重新发送 /sessions。',
|
||||||
|
RESUME_FAILED: '无法附着该会话(resume 失败)。请确认会话存在于 Web,或先在电脑打开后再试。',
|
||||||
BOUND(label: string): string {
|
BOUND(label: string): string {
|
||||||
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`
|
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`
|
||||||
},
|
},
|
||||||
@@ -24,7 +32,21 @@ export const MSG = {
|
|||||||
STATUS_BOUND(label: string): string {
|
STATUS_BOUND(label: string): string {
|
||||||
return `当前绑定:${label}`
|
return `当前绑定:${label}`
|
||||||
},
|
},
|
||||||
GONE: '绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。',
|
STATUS_BOUND_COLD(label: string): string {
|
||||||
|
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`
|
||||||
|
},
|
||||||
|
GONE: '绑定的会话已不可用。请重新 /sessions。',
|
||||||
|
MODEL_UNAVAILABLE: '无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。',
|
||||||
|
MODEL_UNROUTABLE(current: string): string {
|
||||||
|
return `当前模型不可路由:${current}\n请在 Web 或本机配置可用 provider 后再试 /model。`
|
||||||
|
},
|
||||||
|
MODEL_EMPTY(current: string): string {
|
||||||
|
return `当前:${current}\n没有可切换的模型选项。`
|
||||||
|
},
|
||||||
|
MODEL_SET(selected: string): string {
|
||||||
|
return `已切换模型:${selected}\n下一回合生效。`
|
||||||
|
},
|
||||||
|
MODEL_FAILED: '切换模型失败。请稍后重试或在 Web 中切换。',
|
||||||
unknown(command: string): string {
|
unknown(command: string): string {
|
||||||
return `未知命令 ${command}。发送 /help 查看可用命令。`
|
return `未知命令 ${command}。发送 /help 查看可用命令。`
|
||||||
},
|
},
|
||||||
@@ -34,6 +56,7 @@ export type ParsedCommand =
|
|||||||
| { type: 'start'; text: string }
|
| { type: 'start'; text: string }
|
||||||
| { type: 'help'; text: string }
|
| { type: 'help'; text: string }
|
||||||
| { type: 'sessions'; text: string }
|
| { type: 'sessions'; text: string }
|
||||||
|
| { type: 'model'; text: string }
|
||||||
| { type: 'status'; text: string }
|
| { type: 'status'; text: string }
|
||||||
| { type: 'unbind'; text: string }
|
| { type: 'unbind'; text: string }
|
||||||
| { type: 'unknown'; command: string; text: string }
|
| { type: 'unknown'; command: string; text: string }
|
||||||
@@ -51,6 +74,8 @@ export function parseCommand(text: string): ParsedCommand {
|
|||||||
case '/sessions':
|
case '/sessions':
|
||||||
case '/list':
|
case '/list':
|
||||||
return { type: 'sessions', text }
|
return { type: 'sessions', text }
|
||||||
|
case '/model':
|
||||||
|
return { type: 'model', text }
|
||||||
case '/status':
|
case '/status':
|
||||||
return { type: 'status', text }
|
return { type: 'status', text }
|
||||||
case '/unbind':
|
case '/unbind':
|
||||||
@@ -61,5 +86,5 @@ export function parseCommand(text: string): ParsedCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Callback data prefix for session bind buttons. */
|
/** @deprecated Prefer short index callbacks (ws:/sid:); kept for old messages. */
|
||||||
export const BIND_CB_PREFIX = 'bind:'
|
export const BIND_CB_PREFIX = 'bind:'
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
|
||||||
|
export interface ModelOption {
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
label: string
|
||||||
|
efforts?: Array<{ id: string; name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelSnapshot {
|
||||||
|
current: { provider: string; model: string; reasoningEffort?: string }
|
||||||
|
routable: boolean
|
||||||
|
options: ModelOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiFn = (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||||
|
|
||||||
|
function unwrap<T>(res: unknown): T | undefined {
|
||||||
|
const r = res as { result?: { ok?: boolean; value?: T } } | undefined
|
||||||
|
if (r?.result?.ok === true) return r.result.value
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(fn: ApiFn | undefined, payload: unknown): Promise<unknown> {
|
||||||
|
if (typeof fn !== 'function') throw new Error('apiProxy sessions API unavailable')
|
||||||
|
return fn({ rpcId: randomUUID(), payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSessionModels(ctx: Context, sessionId: string): Promise<ModelSnapshot> {
|
||||||
|
const api = (ctx as {
|
||||||
|
apiProxy?: { sessions?: { models?: ApiFn } }
|
||||||
|
}).apiProxy
|
||||||
|
const raw = await call(api?.sessions?.models, { sessionId })
|
||||||
|
const value = unwrap<{
|
||||||
|
current: { provider: string; model: string; reasoningEffort?: string }
|
||||||
|
routable: boolean
|
||||||
|
groups: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
models: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
reasoning?: { efforts?: Array<{ id: string; name: string }> }
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
}>(raw)
|
||||||
|
if (!value) throw new Error('failed to load session models')
|
||||||
|
|
||||||
|
const options: ModelOption[] = []
|
||||||
|
for (const group of value.groups ?? []) {
|
||||||
|
for (const m of group.models ?? []) {
|
||||||
|
options.push({
|
||||||
|
provider: group.id,
|
||||||
|
model: m.id,
|
||||||
|
label: `${group.name}/${m.name}`,
|
||||||
|
efforts: m.reasoning?.efforts?.map((e) => ({ id: e.id, name: e.name })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: value.current,
|
||||||
|
routable: value.routable,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectSessionModel(
|
||||||
|
ctx: Context,
|
||||||
|
sessionId: string,
|
||||||
|
selection: { provider: string; model: string; reasoningEffort?: string },
|
||||||
|
): Promise<{ provider: string; model: string; reasoningEffort?: string }> {
|
||||||
|
const api = (ctx as {
|
||||||
|
apiProxy?: { sessions?: { selectModel?: ApiFn } }
|
||||||
|
}).apiProxy
|
||||||
|
const raw = await call(api?.sessions?.selectModel, {
|
||||||
|
sessionId,
|
||||||
|
provider: selection.provider,
|
||||||
|
model: selection.model,
|
||||||
|
reasoningEffort: selection.reasoningEffort,
|
||||||
|
})
|
||||||
|
const value = unwrap<{ selected: { provider: string; model: string; reasoningEffort?: string } }>(raw)
|
||||||
|
if (!value?.selected) throw new Error('failed to select model')
|
||||||
|
return value.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatModel(sel: { provider: string; model: string; reasoningEffort?: string }): string {
|
||||||
|
const base = `${sel.provider}/${sel.model}`
|
||||||
|
return sel.reasoningEffort ? `${base} (${sel.reasoningEffort})` : base
|
||||||
|
}
|
||||||
+237
-8
@@ -65,6 +65,10 @@ function makeAgent(id: string, followups: UserMessage[], opts?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rpcOk<T>(value: T) {
|
||||||
|
return { rpcId: 'x', result: { ok: true as const, value } }
|
||||||
|
}
|
||||||
|
|
||||||
test('unauthorized user gets denied', async () => {
|
test('unauthorized user gets denied', async () => {
|
||||||
const sent: SentMessage[] = []
|
const sent: SentMessage[] = []
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -108,7 +112,7 @@ test('plain text without bind prompts NEED_BIND and does not create', async () =
|
|||||||
assert.equal(sent[0]?.text, MSG.NEED_BIND)
|
assert.equal(sent[0]?.text, MSG.NEED_BIND)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('/sessions with no live agents shows NO_LIVE', async () => {
|
test('/sessions with no sessions shows NO_SESSIONS', async () => {
|
||||||
const sent: SentMessage[] = []
|
const sent: SentMessage[] = []
|
||||||
const ctx = {
|
const ctx = {
|
||||||
logger: { info() {}, warn() {}, error() {} },
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
@@ -123,10 +127,10 @@ test('/sessions with no live agents shows NO_LIVE', async () => {
|
|||||||
sleep: async () => {},
|
sleep: async () => {},
|
||||||
})
|
})
|
||||||
await bridge.processUpdate(messageUpdate(10, 1, '/sessions'))
|
await bridge.processUpdate(messageUpdate(10, 1, '/sessions'))
|
||||||
assert.equal(sent[0]?.text, MSG.NO_LIVE)
|
assert.equal(sent[0]?.text, MSG.NO_SESSIONS)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('/sessions lists live agents with bind callbacks', async () => {
|
test('/sessions lists workspaces then sessions via callbacks', async () => {
|
||||||
const sent: SentMessage[] = []
|
const sent: SentMessage[] = []
|
||||||
const followups: UserMessage[] = []
|
const followups: UserMessage[] = []
|
||||||
const agent = makeAgent('live-aaa', followups, {
|
const agent = makeAgent('live-aaa', followups, {
|
||||||
@@ -150,11 +154,94 @@ test('/sessions lists live agents with bind callbacks', async () => {
|
|||||||
sleep: async () => {},
|
sleep: async () => {},
|
||||||
})
|
})
|
||||||
await bridge.processUpdate(messageUpdate(10, 1, '/sessions'))
|
await bridge.processUpdate(messageUpdate(10, 1, '/sessions'))
|
||||||
assert.match(sent[0]!.text, /选择要遥控/)
|
assert.match(sent[0]!.text, /选择工作区/)
|
||||||
assert.match(sent[0]!.text, /演示会话/)
|
assert.match(sent[0]!.text, /demo-app/)
|
||||||
assert.match(sent[0]!.text, /D:\/gitData\/demo-app/)
|
assert.equal(sent[0]!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'ws:0')
|
||||||
assert.match(sent[0]!.replyMarkup!.inline_keyboard![0]![0]!.text, /演示会话/)
|
|
||||||
assert.ok(sent[0]!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data?.startsWith(BIND_CB_PREFIX))
|
await bridge.processUpdate({
|
||||||
|
update_id: 2,
|
||||||
|
callback_query: {
|
||||||
|
id: 'cq-ws',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: 'ws:0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const sessionMsg = sent.at(-1)!
|
||||||
|
assert.match(sessionMsg.text, /选择会话/)
|
||||||
|
assert.match(sessionMsg.text, /演示会话/)
|
||||||
|
assert.equal(sessionMsg.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'sid:0')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/sessions via apiProxy shows all workspaces excluding archived', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: { list: () => [], roots: () => [], get: () => undefined },
|
||||||
|
apiProxy: {
|
||||||
|
workspace: {
|
||||||
|
list: async () => rpcOk({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
workspaceId: 'w1',
|
||||||
|
path: 'D:/a',
|
||||||
|
title: 'Alpha',
|
||||||
|
sessionIds: ['s1', 's-archived'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workspaceId: 'w2',
|
||||||
|
path: 'D:/b',
|
||||||
|
title: 'Beta',
|
||||||
|
sessionIds: ['s2'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
archivedSessionIds: ['s-archived'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
list: async () => rpcOk({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
sessionId: 's1',
|
||||||
|
updatedAt: 2,
|
||||||
|
running: true,
|
||||||
|
blank: false,
|
||||||
|
cwd: 'D:/a',
|
||||||
|
projections: { values: { title: '会话一' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: 's-archived',
|
||||||
|
updatedAt: 1,
|
||||||
|
running: false,
|
||||||
|
blank: false,
|
||||||
|
cwd: 'D:/a',
|
||||||
|
projections: { values: { title: '已归档' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: 's2',
|
||||||
|
updatedAt: 3,
|
||||||
|
running: false,
|
||||||
|
blank: false,
|
||||||
|
cwd: 'D:/b',
|
||||||
|
projections: { values: { title: '会话二' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/sessions'))
|
||||||
|
assert.match(sent[0]!.text, /Alpha/)
|
||||||
|
assert.match(sent[0]!.text, /Beta/)
|
||||||
|
assert.equal(sent[0]!.replyMarkup?.inline_keyboard?.length, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('callback bind then plain text followups live agent; mirror assistant to chat', async () => {
|
test('callback bind then plain text followups live agent; mirror assistant to chat', async () => {
|
||||||
@@ -215,6 +302,148 @@ test('callback bind then plain text followups live agent; mirror assistant to ch
|
|||||||
await bridge.stop()
|
await bridge.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('cold session bind resumes then followups', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const agent = makeAgent('cold-1', followups, { cwd: 'D:/proj', title: '冷会话' })
|
||||||
|
let resumed = false
|
||||||
|
const ctx = {
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
agents: {
|
||||||
|
list: () => [],
|
||||||
|
roots: () => [],
|
||||||
|
get: () => (resumed ? agent : undefined),
|
||||||
|
resume: async () => {
|
||||||
|
resumed = true
|
||||||
|
return { agent, dispose: async () => {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apiProxy: {
|
||||||
|
workspace: {
|
||||||
|
list: async () => rpcOk({
|
||||||
|
items: [{ workspaceId: 'w', path: 'D:/proj', title: 'proj', sessionIds: ['cold-1'] }],
|
||||||
|
archivedSessionIds: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
list: async () => rpcOk({
|
||||||
|
items: [{
|
||||||
|
sessionId: 'cold-1',
|
||||||
|
updatedAt: 1,
|
||||||
|
running: false,
|
||||||
|
blank: false,
|
||||||
|
cwd: 'D:/proj',
|
||||||
|
projections: { values: { title: '冷会话' } },
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
on() { return () => {} },
|
||||||
|
}
|
||||||
|
const bridge = new TelegramBridge(ctx as any, {
|
||||||
|
token: 't',
|
||||||
|
allowedUserIds: [1],
|
||||||
|
allowAllUsers: false,
|
||||||
|
client: fakeClient(sent),
|
||||||
|
sleep: async () => {},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/sessions'))
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 2,
|
||||||
|
callback_query: {
|
||||||
|
id: 'cq-ws',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: 'ws:0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 3,
|
||||||
|
callback_query: {
|
||||||
|
id: 'cq-sid',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 2, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: 'sid:0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(resumed, true)
|
||||||
|
assert.match(sent.at(-1)!.text, /已附着/)
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, 'hi cold', 4))
|
||||||
|
assert.equal(followups.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('/model lists and selects via apiProxy', async () => {
|
||||||
|
const sent: SentMessage[] = []
|
||||||
|
const followups: UserMessage[] = []
|
||||||
|
const agent = makeAgent('live-mdl', 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: 'chat' },
|
||||||
|
routable: true,
|
||||||
|
groups: [{
|
||||||
|
id: 'deepseek',
|
||||||
|
name: 'DeepSeek',
|
||||||
|
models: [
|
||||||
|
{ id: 'chat', name: 'Chat' },
|
||||||
|
{ id: 'reasoner', name: 'Reasoner', reasoning: { efforts: [{ id: 'high', name: 'High' }] } },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
selectModel: async (req: { payload: unknown }) => {
|
||||||
|
selected = req.payload
|
||||||
|
return rpcOk({ selected: { provider: 'deepseek', model: 'chat' } })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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-mdl`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await bridge.processUpdate(messageUpdate(10, 1, '/model', 2))
|
||||||
|
assert.match(sent.at(-1)!.text, /当前模型/)
|
||||||
|
assert.equal(sent.at(-1)!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'mdl:0')
|
||||||
|
|
||||||
|
await bridge.processUpdate({
|
||||||
|
update_id: 3,
|
||||||
|
callback_query: {
|
||||||
|
id: 'pick',
|
||||||
|
from: { id: 1 },
|
||||||
|
message: { message_id: 2, date: 0, chat: { id: 10, type: 'private' } },
|
||||||
|
data: 'mdl:0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.deepEqual(selected, {
|
||||||
|
sessionId: 'live-mdl',
|
||||||
|
provider: 'deepseek',
|
||||||
|
model: 'chat',
|
||||||
|
reasoningEffort: undefined,
|
||||||
|
})
|
||||||
|
assert.match(sent.at(-1)!.text, /已切换模型/)
|
||||||
|
})
|
||||||
|
|
||||||
test('/unbind clears binding without needing create/dispose', async () => {
|
test('/unbind clears binding without needing create/dispose', async () => {
|
||||||
const sent: SentMessage[] = []
|
const sent: SentMessage[] = []
|
||||||
const followups: UserMessage[] = []
|
const followups: UserMessage[] = []
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
visibleSessionsForWorkspace,
|
||||||
|
workspacesWithVisibleSessions,
|
||||||
|
type CatalogSnapshot,
|
||||||
|
} from '../src/catalog.ts'
|
||||||
|
|
||||||
|
test('visible sessions exclude archived blank and subagent', () => {
|
||||||
|
const catalog: CatalogSnapshot = {
|
||||||
|
archivedIds: new Set(['arch']),
|
||||||
|
sessionsById: new Map([
|
||||||
|
['ok', { sessionId: 'ok', title: 'OK', blank: false, running: true, updatedAt: 1 }],
|
||||||
|
['arch', { sessionId: 'arch', title: 'Arch', blank: false, running: false, updatedAt: 1 }],
|
||||||
|
['blank', { sessionId: 'blank', title: 'Blank', blank: true, running: false, updatedAt: 1 }],
|
||||||
|
['sub', { sessionId: 'sub', title: 'Sub', blank: false, running: false, origin: 'subagent', updatedAt: 1 }],
|
||||||
|
]),
|
||||||
|
workspaces: [{
|
||||||
|
id: 'w',
|
||||||
|
title: 'W',
|
||||||
|
path: '/w',
|
||||||
|
sessionIds: ['ok', 'arch', 'blank', 'sub', 'missing'],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
const rows = visibleSessionsForWorkspace(catalog, catalog.workspaces[0]!)
|
||||||
|
assert.deepEqual(rows.map((r) => r.sessionId), ['ok'])
|
||||||
|
assert.equal(workspacesWithVisibleSessions(catalog).length, 1)
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ test('parse slash commands for remote control', () => {
|
|||||||
assert.equal(parseCommand('/status').type, 'status')
|
assert.equal(parseCommand('/status').type, 'status')
|
||||||
assert.equal(parseCommand('/unbind').type, 'unbind')
|
assert.equal(parseCommand('/unbind').type, 'unbind')
|
||||||
assert.equal(parseCommand('/disconnect').type, 'unbind')
|
assert.equal(parseCommand('/disconnect').type, 'unbind')
|
||||||
|
assert.equal(parseCommand('/model').type, 'model')
|
||||||
assert.equal(parseCommand('/sessions@MyBot').type, 'sessions')
|
assert.equal(parseCommand('/sessions@MyBot').type, 'sessions')
|
||||||
assert.equal(parseCommand('/foo').type, 'unknown')
|
assert.equal(parseCommand('/foo').type, 'unknown')
|
||||||
assert.equal(parseCommand('hello').type, 'plain')
|
assert.equal(parseCommand('hello').type, 'plain')
|
||||||
@@ -18,6 +19,7 @@ test('parse slash commands for remote control', () => {
|
|||||||
test('Chinese copy mentions sessions and bind', () => {
|
test('Chinese copy mentions sessions and bind', () => {
|
||||||
assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
|
assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
|
||||||
assert.ok(MSG.HELP.includes('/sessions'))
|
assert.ok(MSG.HELP.includes('/sessions'))
|
||||||
|
assert.ok(MSG.HELP.includes('/model'))
|
||||||
assert.ok(MSG.NEED_BIND.includes('/sessions'))
|
assert.ok(MSG.NEED_BIND.includes('/sessions'))
|
||||||
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
|
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user