diff --git a/README.md b/README.md index 50f5171..453634f 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,9 @@ Telegram **手机遥控器** for DeepSeek Harness:附着本机正在跑的 Web ### 30 秒理解 -1. 电脑 `dsh web` 开着,并打开一个对话 -2. 手机 Bot 发 `/sessions`,点选该对话 -3. 之后手机 ↔ Web 走**同一条**轨迹 +1. 电脑 `dsh web` 开着(会话列表与 Web 对齐,已归档除外) +2. 手机 Bot:`/sessions` → **工作区** → **会话** → 附着 +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`) -2. 浏览器打开一个对话并保持运行 -3. 手机对 Bot:`/start` → `/sessions` → 点选 → 聊天 +2. 浏览器里可看到工作区与会话(归档会话不会出现在手机列表) +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` | 当前绑定 | | `/unbind` | 只断开手机,**不关**电脑会话 | | `/help` | 帮助 | @@ -184,7 +186,9 @@ dsh plugin --profile web add D:\path\to\dsh-telegram-channel | 手机完全没回复 / ConnectTimeout | 打开本地代理(如 7890),重启 `dsh web` | | `missing bot token` | 检查环境变量;**新开终端**再 `dsh web` | | 「无权限」 | User ID 必须是 `@userinfobot` 的数字 | -| `/sessions` 无会话 | 先在 Web 打开对话 | +| `/sessions` 无会话 | 确认 Web 有未归档会话;空白会话会被隐藏 | +| `/sessions` 比电脑少很多 | 升级到 ≥0.3.0:应按工作区列出;仍少则检查是否归档 | +| `/model` 不可用 | 需 `dsh web`(apiProxy);先 `/sessions` 绑定 | | Telegram 401 | Token 错了或被 revoke | --- @@ -233,11 +237,11 @@ dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel ### 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 -Phone: pick a live session and chat: +Phone: pick a session and chat: ![Telegram mobile remote](docs/screenshots/mobile-chat.jpg) @@ -267,7 +271,7 @@ Direct actions: .\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 diff --git a/examples/telegram-agent/README.md b/examples/telegram-agent/README.md index eb6895b..997a45a 100644 --- a/examples/telegram-agent/README.md +++ b/examples/telegram-agent/README.md @@ -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): [`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. diff --git a/lib/bridge.d.ts b/lib/bridge.d.ts index 4e8aa51..3731968 100644 --- a/lib/bridge.d.ts +++ b/lib/bridge.d.ts @@ -18,6 +18,7 @@ export declare class TelegramBridge { private readonly sleep; private readonly maxMessageLength; private readonly bindings; + private readonly pickers; private polling; private offset; private pollPromise; @@ -28,9 +29,17 @@ export declare class TelegramBridge { stop(): Promise; processUpdate(update: TelegramUpdate): Promise; private handleCallback; + private resolveCatalog; + private sendWorkspacePicker; + private sendSessionPicker; + private bindSession; + private sendModelPicker; + private sendEffortPicker; + private applyModel; private liveAgents; private findLiveAgent; - private sendSessionPicker; + /** Resume cold sessions when needed; never dispose the returned handle. */ + private ensureLiveAgent; private sendStatus; private followupBound; private pollLoop; diff --git a/lib/bridge.js b/lib/bridge.js index 9bea425..b37faa6 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -1,10 +1,19 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm/message'; import { SessionId } from '@deepseek-ai/dsh-session/types'; import { isAuthorized } from './auth.js'; +import { catalogFromLiveAgents, loadCatalog, truncateButton, visibleSessionsForWorkspace, workspacesWithVisibleSessions, } from './catalog.js'; import { TelegramClient, } from './client.js'; -import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js'; +import { MSG, parseCommand } from './commands.js'; import { markdownToHtml, splitMessage } from './format.js'; -import { buttonLabel, describeAgent, detailLines, displayLabel } from './label.js'; +import { describeAgent, displayLabel } from './label.js'; +import { formatModel, loadSessionModels, selectSessionModel, } from './models.js'; +const WS_CB = 'ws:'; +const SID_CB = 'sid:'; +const BACK_WS_CB = 'wb'; +const MODEL_CB = 'mdl:'; +const EFFORT_CB = 'me:'; +const BACK_MODEL_CB = 'mb'; +const MAX_BUTTONS = 40; const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function contentToText(content) { return content @@ -21,6 +30,7 @@ export class TelegramBridge { sleep; maxMessageLength; bindings = new Map(); + pickers = new Map(); polling = false; offset; pollPromise; @@ -46,7 +56,8 @@ export class TelegramBridge { }); void this.client.setMyCommands([ { command: 'start', description: '欢迎与用法' }, - { command: 'sessions', description: '列出并附着本机 live 会话' }, + { command: 'sessions', description: '按工作区列出并附着会话' }, + { command: 'model', description: '切换当前绑定会话的模型' }, { command: 'status', description: '查看当前绑定' }, { command: 'unbind', description: '断开手机绑定(不关闭本机会话)' }, { command: 'help', description: '显示帮助' }, @@ -70,6 +81,7 @@ export class TelegramBridge { this.disposeSessionListener = undefined; // Never dispose host agents — only clear remote bindings. this.bindings.clear(); + this.pickers.clear(); if (this.pollPromise) { await this.pollPromise.catch(() => { }); this.pollPromise = undefined; @@ -98,13 +110,17 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.HELP); return; case 'sessions': - await this.sendSessionPicker(chatId); + await this.sendWorkspacePicker(chatId); + return; + case 'model': + await this.sendModelPicker(chatId); return; case 'status': await this.sendStatus(chatId); return; case 'unbind': this.bindings.delete(String(chatId)); + this.pickers.delete(String(chatId)); await this.client.sendMessage(chatId, MSG.UNBOUND); return; case 'unknown': @@ -129,21 +145,261 @@ export class TelegramBridge { return; } const data = cq.data ?? ''; - if (!data.startsWith(BIND_CB_PREFIX)) { + const picker = this.pickers.get(String(chatId)); + if (data === BACK_WS_CB) { await this.client.answerCallbackQuery(cq.id); + await this.sendWorkspacePicker(chatId, picker?.catalog); return; } - const sessionId = data.slice(BIND_CB_PREFIX.length); - const agent = this.findLiveAgent(sessionId); + if (data === BACK_MODEL_CB) { + await this.client.answerCallbackQuery(cq.id); + await this.sendModelPicker(chatId); + return; + } + if (data.startsWith(WS_CB)) { + const index = Number(data.slice(WS_CB.length)); + await this.client.answerCallbackQuery(cq.id); + await this.sendSessionPicker(chatId, index); + return; + } + if (data.startsWith(SID_CB)) { + const index = Number(data.slice(SID_CB.length)); + const row = picker?.sessions[index]; + if (!row) { + await this.client.answerCallbackQuery(cq.id, '会话已过期'); + await this.client.sendMessage(chatId, MSG.PICKER_STALE); + return; + } + await this.bindSession(chatId, cq.id, row); + return; + } + if (data.startsWith(MODEL_CB)) { + const index = Number(data.slice(MODEL_CB.length)); + const option = picker?.models?.[index]; + if (!option) { + await this.client.answerCallbackQuery(cq.id, '列表已过期'); + await this.client.sendMessage(chatId, MSG.PICKER_STALE); + return; + } + if (option.efforts && option.efforts.length > 0) { + if (picker) + picker.pendingModel = option; + await this.client.answerCallbackQuery(cq.id); + await this.sendEffortPicker(chatId, option); + return; + } + await this.applyModel(chatId, cq.id, option); + return; + } + if (data.startsWith(EFFORT_CB)) { + const index = Number(data.slice(EFFORT_CB.length)); + const pending = picker?.pendingModel; + const effort = pending?.efforts?.[index]; + if (!pending || !effort) { + await this.client.answerCallbackQuery(cq.id, '列表已过期'); + await this.client.sendMessage(chatId, MSG.PICKER_STALE); + return; + } + await this.applyModel(chatId, cq.id, pending, effort.id); + return; + } + // Legacy bind: callbacks (older messages / tests) + if (data.startsWith('bind:')) { + const sessionId = data.slice('bind:'.length); + await this.bindSession(chatId, cq.id, { + sessionId, + title: sessionId, + blank: false, + running: true, + updatedAt: 0, + }); + return; + } + await this.client.answerCallbackQuery(cq.id); + } + async resolveCatalog() { + try { + const fromApi = await loadCatalog(this.ctx); + if (fromApi) + return fromApi; + } + catch (err) { + this.ctx.logger.warn(`dsh-telegram-channel: catalog via apiProxy failed: ${this.redact(err)}`); + } + return catalogFromLiveAgents(this.liveAgents(), this.ctx); + } + async sendWorkspacePicker(chatId, existing) { + const catalog = existing ?? await this.resolveCatalog(); + const workspaces = workspacesWithVisibleSessions(catalog); + if (workspaces.length === 0) { + this.pickers.delete(String(chatId)); + await this.client.sendMessage(chatId, MSG.NO_SESSIONS); + return; + } + const shown = workspaces.slice(0, MAX_BUTTONS); + this.pickers.set(String(chatId), { workspaces: shown, sessions: [], catalog }); + const keyboard = { + inline_keyboard: shown.map((ws, i) => ([{ + text: truncateButton(`${i + 1}. ${ws.title}`), + callback_data: `${WS_CB}${i}`, + }])), + }; + const body = [ + `选择工作区(共 ${workspaces.length} 个,与 Web 对齐,已排除归档):`, + '', + ...shown.map((ws, i) => { + const n = visibleSessionsForWorkspace(catalog, ws).length; + return `${i + 1}. ${ws.title}\n ${ws.path}\n 会话:${n}`; + }), + workspaces.length > MAX_BUTTONS ? `\n仅显示前 ${MAX_BUTTONS} 个工作区。` : '', + '', + '点下方按钮进入该工作区的会话列表。', + ].filter(Boolean).join('\n'); + await this.client.sendMessage(chatId, body, undefined, keyboard); + } + async sendSessionPicker(chatId, workspaceIndex) { + const picker = this.pickers.get(String(chatId)); + const catalog = picker?.catalog ?? await this.resolveCatalog(); + const workspaces = picker?.workspaces?.length + ? picker.workspaces + : workspacesWithVisibleSessions(catalog); + const workspace = workspaces[workspaceIndex]; + if (!workspace) { + await this.client.sendMessage(chatId, MSG.PICKER_STALE); + return; + } + const sessions = visibleSessionsForWorkspace(catalog, workspace) + .slice() + .sort((a, b) => b.updatedAt - a.updatedAt); + if (sessions.length === 0) { + await this.client.sendMessage(chatId, MSG.NO_SESSIONS_IN_WS(workspace.title)); + return; + } + const shown = sessions.slice(0, MAX_BUTTONS); + this.pickers.set(String(chatId), { workspaces, sessions: shown, catalog }); + const keyboard = { + inline_keyboard: [ + ...shown.map((row, i) => ([{ + text: truncateButton(`${i + 1}. ${row.title}${row.running ? '' : ' · 冷'}`), + callback_data: `${SID_CB}${i}`, + }])), + [{ text: '← 返回工作区', callback_data: BACK_WS_CB }], + ], + }; + const body = [ + `工作区:${workspace.title}`, + workspace.path, + '', + `选择会话(共 ${sessions.length} 个):`, + '', + ...shown.map((row, i) => { + const mark = row.running ? '运行中' : '未附着'; + return `${i + 1}. ${row.title}\n ${mark} · …${row.sessionId.slice(-12)}`; + }), + sessions.length > MAX_BUTTONS ? `\n仅显示前 ${MAX_BUTTONS} 个会话。` : '', + '', + '点下方按钮附着;冷会话会自动 resume(不关闭 Web)。', + ].filter(Boolean).join('\n'); + await this.client.sendMessage(chatId, body, undefined, keyboard); + } + async bindSession(chatId, callbackId, row) { + const agent = await this.ensureLiveAgent(row.sessionId); if (!agent) { - await this.client.answerCallbackQuery(cq.id, '会话不存在'); + await this.client.answerCallbackQuery(callbackId, '无法附着'); + await this.client.sendMessage(chatId, MSG.RESUME_FAILED); + return; + } + const parts = describeAgent(agent, 0, this.ctx); + const label = row.title && row.title !== row.sessionId + ? displayLabel({ ...parts, title: row.title }) + : displayLabel(parts); + this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label }); + await this.client.answerCallbackQuery(callbackId, '已附着'); + await this.client.sendMessage(chatId, MSG.BOUND(label)); + } + async sendModelPicker(chatId) { + const binding = this.bindings.get(String(chatId)); + if (!binding) { + await this.client.sendMessage(chatId, MSG.NEED_BIND); + return; + } + const agent = await this.ensureLiveAgent(binding.sessionId); + if (!agent) { + this.bindings.delete(String(chatId)); await this.client.sendMessage(chatId, MSG.GONE); return; } - const label = displayLabel(describeAgent(agent, 0, this.ctx)); - this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label }); - await this.client.answerCallbackQuery(cq.id, '已附着'); - await this.client.sendMessage(chatId, MSG.BOUND(label)); + try { + const snap = await loadSessionModels(this.ctx, binding.sessionId); + if (!snap.routable) { + await this.client.sendMessage(chatId, MSG.MODEL_UNROUTABLE(formatModel(snap.current))); + return; + } + if (snap.options.length === 0) { + await this.client.sendMessage(chatId, MSG.MODEL_EMPTY(formatModel(snap.current))); + return; + } + const shown = snap.options.slice(0, MAX_BUTTONS); + const prev = this.pickers.get(String(chatId)); + this.pickers.set(String(chatId), { + workspaces: prev?.workspaces ?? [], + sessions: prev?.sessions ?? [], + catalog: prev?.catalog, + models: shown, + }); + const keyboard = { + inline_keyboard: shown.map((opt, i) => ([{ + text: truncateButton(`${opt.label}${opt.provider === snap.current.provider && opt.model === snap.current.model ? ' ✓' : ''}`), + callback_data: `${MODEL_CB}${i}`, + }])), + }; + const body = [ + `当前模型:${formatModel(snap.current)}`, + `会话:${binding.label}`, + '', + '选择新模型(下一回合生效):', + ].join('\n'); + await this.client.sendMessage(chatId, body, undefined, keyboard); + } + catch (err) { + this.ctx.logger.warn(`dsh-telegram-channel: /model failed: ${this.redact(err)}`); + await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE); + } + } + async sendEffortPicker(chatId, option) { + const efforts = option.efforts ?? []; + const keyboard = { + inline_keyboard: [ + ...efforts.map((e, i) => ([{ + text: truncateButton(e.name || e.id), + callback_data: `${EFFORT_CB}${i}`, + }])), + [{ text: '← 返回模型列表', callback_data: BACK_MODEL_CB }], + ], + }; + await this.client.sendMessage(chatId, `已选 ${option.label}\n请选择 reasoning effort:`, undefined, keyboard); + } + async applyModel(chatId, callbackId, option, reasoningEffort) { + const binding = this.bindings.get(String(chatId)); + if (!binding) { + await this.client.answerCallbackQuery(callbackId, '未绑定'); + await this.client.sendMessage(chatId, MSG.NEED_BIND); + return; + } + try { + const selected = await selectSessionModel(this.ctx, binding.sessionId, { + provider: option.provider, + model: option.model, + reasoningEffort, + }); + await this.client.answerCallbackQuery(callbackId, '已切换'); + await this.client.sendMessage(chatId, MSG.MODEL_SET(formatModel(selected))); + } + catch (err) { + this.ctx.logger.warn(`dsh-telegram-channel: selectModel failed: ${this.redact(err)}`); + await this.client.answerCallbackQuery(callbackId, '切换失败'); + await this.client.sendMessage(chatId, MSG.MODEL_FAILED); + } } liveAgents() { const agents = this.ctx.agents; @@ -170,27 +426,22 @@ export class TelegramBridge { } return this.liveAgents().find((a) => String(a.id) === sessionId); } - async sendSessionPicker(chatId) { - const agents = this.liveAgents(); - if (agents.length === 0) { - await this.client.sendMessage(chatId, MSG.NO_LIVE); - return; + /** Resume cold sessions when needed; never dispose the returned handle. */ + async ensureLiveAgent(sessionId) { + const live = this.findLiveAgent(sessionId); + if (live) + return live; + const agents = this.ctx.agents; + if (typeof agents.resume !== 'function') + return undefined; + try { + const handle = await agents.resume({ resumeSessionId: SessionId(sessionId) }); + return handle.agent; + } + catch (err) { + this.ctx.logger.warn(`dsh-telegram-channel: resume failed for ${sessionId}: ${this.redact(err)}`); + return undefined; } - const described = agents.map((agent, i) => describeAgent(agent, i, this.ctx)); - const keyboard = { - inline_keyboard: described.map((parts) => ([{ - text: buttonLabel(parts), - callback_data: `${BIND_CB_PREFIX}${parts.sessionId}`, - }])), - }; - const body = [ - `选择要遥控的本机会话(共 ${agents.length} 个):`, - '', - ...described.map((parts) => detailLines(parts)), - '', - '点下方按钮附着对应会话。', - ].join('\n'); - await this.client.sendMessage(chatId, body, undefined, keyboard); } async sendStatus(chatId) { const binding = this.bindings.get(String(chatId)); @@ -200,8 +451,7 @@ export class TelegramBridge { } const stillLive = this.findLiveAgent(binding.sessionId); if (!stillLive) { - this.bindings.delete(String(chatId)); - await this.client.sendMessage(chatId, MSG.GONE); + await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label)); return; } await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label)); @@ -212,7 +462,7 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.NEED_BIND); return; } - const agent = this.findLiveAgent(binding.sessionId); + const agent = await this.ensureLiveAgent(binding.sessionId); if (!agent) { this.bindings.delete(String(chatId)); await this.client.sendMessage(chatId, MSG.GONE); diff --git a/lib/catalog.d.ts b/lib/catalog.d.ts new file mode 100644 index 0000000..e1f4cdc --- /dev/null +++ b/lib/catalog.d.ts @@ -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; + archivedIds: Set; +} +/** Load workspaces + sessions aligned with Web UI (via apiProxy when available). */ +export declare function loadCatalog(ctx: Context): Promise; +/** 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; diff --git a/lib/catalog.js b/lib/catalog.js new file mode 100644 index 0000000..730adc0 --- /dev/null +++ b/lib/catalog.js @@ -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() }; +} diff --git a/lib/commands.d.ts b/lib/commands.d.ts index 3d93faf..ea81248 100644 --- a/lib/commands.d.ts +++ b/lib/commands.d.ts @@ -3,12 +3,23 @@ export declare const MSG: { readonly WELCOME: string; readonly HELP: string; 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 UNBOUND: "已断开绑定。本机会话仍在运行。"; readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。"; 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; }; export type ParsedCommand = { @@ -20,6 +31,9 @@ export type ParsedCommand = { } | { type: 'sessions'; text: string; +} | { + type: 'model'; + text: string; } | { type: 'status'; text: string; @@ -35,5 +49,5 @@ export type ParsedCommand = { text: string; }; 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:"; diff --git a/lib/commands.js b/lib/commands.js index ac2ab3b..eb4d3af 100644 --- a/lib/commands.js +++ b/lib/commands.js @@ -2,20 +2,28 @@ export const MSG = { DENIED: '无权限。', WELCOME: [ '你好,这是 DeepSeek Harness 的手机遥控器。', - '本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。', + '本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择工作区 → 会话并附着。', '发送 /help 查看命令。', ].join('\n'), HELP: [ - '/sessions — 列出本机正在运行的会话并选择附着', + '/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着', + '/model — 切换当前绑定会话的模型(下一回合生效)', '/status — 查看当前绑定', '/unbind — 断开绑定(不关闭本机会话)', '/help — 显示帮助', '', '绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。', - '仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。', + '仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。', ].join('\n'), 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) { return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`; }, @@ -24,7 +32,21 @@ export const MSG = { STATUS_BOUND(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) { return `未知命令 ${command}。发送 /help 查看可用命令。`; }, @@ -42,6 +64,8 @@ export function parseCommand(text) { case '/sessions': case '/list': return { type: 'sessions', text }; + case '/model': + return { type: 'model', text }; case '/status': return { type: 'status', text }; case '/unbind': @@ -51,5 +75,5 @@ export function parseCommand(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:'; diff --git a/lib/models.d.ts b/lib/models.d.ts new file mode 100644 index 0000000..938f092 --- /dev/null +++ b/lib/models.d.ts @@ -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; +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; diff --git a/lib/models.js b/lib/models.js new file mode 100644 index 0000000..d324670 --- /dev/null +++ b/lib/models.js @@ -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; +} diff --git a/package.json b/package.json index d031f81..1695629 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-telegram-channel", - "version": "0.2.3", - "description": "DeepSeek Harness Telegram mobile remote: bind live Web sessions (Codex-style), same trajectory, /sessions picker, unbind/clear — dsh-plugin", + "version": "0.3.0", + "description": "DeepSeek Harness Telegram mobile remote: workspace→session picker (Web-aligned), /model switch, same trajectory — dsh-plugin", "keywords": [ "telegram", "bot", @@ -9,6 +9,8 @@ "remote", "channel", "sessions", + "workspace", + "model", "bind", "unbind", "remove", diff --git a/src/bridge.ts b/src/bridge.ts index f2b9be9..aaec237 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -4,15 +4,31 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm/message' import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types' import { isAuthorized } from './auth.js' +import { + catalogFromLiveAgents, + loadCatalog, + truncateButton, + visibleSessionsForWorkspace, + workspacesWithVisibleSessions, + type CatalogSnapshot, + type SessionRow, + type WorkspaceRow, +} from './catalog.js' import { TelegramClient, type InlineKeyboardMarkup, type TelegramClientLike, type TelegramUpdate, } from './client.js' -import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js' +import { MSG, parseCommand } from './commands.js' import { markdownToHtml, splitMessage } from './format.js' -import { buttonLabel, describeAgent, detailLines, displayLabel } from './label.js' +import { describeAgent, displayLabel } from './label.js' +import { + formatModel, + loadSessionModels, + selectSessionModel, + type ModelOption, +} from './models.js' export interface TelegramBridgeOptions { token: string @@ -31,10 +47,26 @@ interface Binding { label: string } +interface PickerState { + workspaces: WorkspaceRow[] + sessions: SessionRow[] + catalog?: CatalogSnapshot + models?: ModelOption[] + pendingModel?: ModelOption +} + interface SessionLike { id: ReturnType } +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((resolve) => setTimeout(resolve, ms)) function contentToText(content: readonly ContentBlock[]): string { @@ -54,6 +86,7 @@ export class TelegramBridge { private readonly maxMessageLength: number private readonly bindings = new Map() + private readonly pickers = new Map() private polling = false private offset: number | undefined private pollPromise: Promise | undefined @@ -81,7 +114,8 @@ export class TelegramBridge { }) void this.client.setMyCommands([ { command: 'start', description: '欢迎与用法' }, - { command: 'sessions', description: '列出并附着本机 live 会话' }, + { command: 'sessions', description: '按工作区列出并附着会话' }, + { command: 'model', description: '切换当前绑定会话的模型' }, { command: 'status', description: '查看当前绑定' }, { command: 'unbind', description: '断开手机绑定(不关闭本机会话)' }, { command: 'help', description: '显示帮助' }, @@ -106,6 +140,7 @@ export class TelegramBridge { this.disposeSessionListener = undefined // Never dispose host agents — only clear remote bindings. this.bindings.clear() + this.pickers.clear() if (this.pollPromise) { await this.pollPromise.catch(() => {}) this.pollPromise = undefined @@ -137,13 +172,17 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.HELP) return case 'sessions': - await this.sendSessionPicker(chatId) + await this.sendWorkspacePicker(chatId) + return + case 'model': + await this.sendModelPicker(chatId) return case 'status': await this.sendStatus(chatId) return case 'unbind': this.bindings.delete(String(chatId)) + this.pickers.delete(String(chatId)) await this.client.sendMessage(chatId, MSG.UNBOUND) return case 'unknown': @@ -169,21 +208,278 @@ export class TelegramBridge { return } const data = cq.data ?? '' - if (!data.startsWith(BIND_CB_PREFIX)) { + const picker = this.pickers.get(String(chatId)) + + if (data === BACK_WS_CB) { await this.client.answerCallbackQuery(cq.id) + await this.sendWorkspacePicker(chatId, picker?.catalog) return } - const sessionId = data.slice(BIND_CB_PREFIX.length) - const agent = this.findLiveAgent(sessionId) + if (data === BACK_MODEL_CB) { + await this.client.answerCallbackQuery(cq.id) + await this.sendModelPicker(chatId) + return + } + if (data.startsWith(WS_CB)) { + const index = Number(data.slice(WS_CB.length)) + await this.client.answerCallbackQuery(cq.id) + await this.sendSessionPicker(chatId, index) + return + } + if (data.startsWith(SID_CB)) { + const index = Number(data.slice(SID_CB.length)) + const row = picker?.sessions[index] + if (!row) { + await this.client.answerCallbackQuery(cq.id, '会话已过期') + await this.client.sendMessage(chatId, MSG.PICKER_STALE) + return + } + await this.bindSession(chatId, cq.id, row) + return + } + if (data.startsWith(MODEL_CB)) { + const index = Number(data.slice(MODEL_CB.length)) + const option = picker?.models?.[index] + if (!option) { + await this.client.answerCallbackQuery(cq.id, '列表已过期') + await this.client.sendMessage(chatId, MSG.PICKER_STALE) + return + } + if (option.efforts && option.efforts.length > 0) { + if (picker) picker.pendingModel = option + await this.client.answerCallbackQuery(cq.id) + await this.sendEffortPicker(chatId, option) + return + } + await this.applyModel(chatId, cq.id, option) + return + } + if (data.startsWith(EFFORT_CB)) { + const index = Number(data.slice(EFFORT_CB.length)) + const pending = picker?.pendingModel + const effort = pending?.efforts?.[index] + if (!pending || !effort) { + await this.client.answerCallbackQuery(cq.id, '列表已过期') + await this.client.sendMessage(chatId, MSG.PICKER_STALE) + return + } + await this.applyModel(chatId, cq.id, pending, effort.id) + return + } + + // Legacy bind: 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 { + 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 { + 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 { + 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 { + const agent = await this.ensureLiveAgent(row.sessionId) if (!agent) { - await this.client.answerCallbackQuery(cq.id, '会话不存在') + await this.client.answerCallbackQuery(callbackId, '无法附着') + await this.client.sendMessage(chatId, MSG.RESUME_FAILED) + return + } + const parts = describeAgent(agent, 0, this.ctx) + const label = row.title && row.title !== row.sessionId + ? displayLabel({ ...parts, title: row.title }) + : displayLabel(parts) + this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label }) + await this.client.answerCallbackQuery(callbackId, '已附着') + await this.client.sendMessage(chatId, MSG.BOUND(label)) + } + + private async sendModelPicker(chatId: number): Promise { + const binding = this.bindings.get(String(chatId)) + if (!binding) { + await this.client.sendMessage(chatId, MSG.NEED_BIND) + return + } + const agent = await this.ensureLiveAgent(binding.sessionId) + if (!agent) { + this.bindings.delete(String(chatId)) await this.client.sendMessage(chatId, MSG.GONE) return } - const label = displayLabel(describeAgent(agent, 0, this.ctx)) - this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label }) - await this.client.answerCallbackQuery(cq.id, '已附着') - await this.client.sendMessage(chatId, MSG.BOUND(label)) + try { + const snap = await loadSessionModels(this.ctx, binding.sessionId) + if (!snap.routable) { + await this.client.sendMessage(chatId, MSG.MODEL_UNROUTABLE(formatModel(snap.current))) + return + } + if (snap.options.length === 0) { + await this.client.sendMessage(chatId, MSG.MODEL_EMPTY(formatModel(snap.current))) + return + } + const shown = snap.options.slice(0, MAX_BUTTONS) + const prev = this.pickers.get(String(chatId)) + this.pickers.set(String(chatId), { + workspaces: prev?.workspaces ?? [], + sessions: prev?.sessions ?? [], + catalog: prev?.catalog, + models: shown, + }) + const keyboard: 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 { + 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 { + 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[] { @@ -209,27 +505,19 @@ export class TelegramBridge { return this.liveAgents().find((a) => String(a.id) === sessionId) } - private async sendSessionPicker(chatId: number): Promise { - const agents = this.liveAgents() - if (agents.length === 0) { - await this.client.sendMessage(chatId, MSG.NO_LIVE) - return + /** Resume cold sessions when needed; never dispose the returned handle. */ + private async ensureLiveAgent(sessionId: string): Promise { + const live = this.findLiveAgent(sessionId) + if (live) return live + const agents = this.ctx.agents as { resume?: (opts: { resumeSessionId: ReturnType }) => 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 { @@ -240,8 +528,7 @@ export class TelegramBridge { } const stillLive = this.findLiveAgent(binding.sessionId) if (!stillLive) { - this.bindings.delete(String(chatId)) - await this.client.sendMessage(chatId, MSG.GONE) + await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label)) return } await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label)) @@ -253,7 +540,7 @@ export class TelegramBridge { await this.client.sendMessage(chatId, MSG.NEED_BIND) return } - const agent = this.findLiveAgent(binding.sessionId) + const agent = await this.ensureLiveAgent(binding.sessionId) if (!agent) { this.bindings.delete(String(chatId)) await this.client.sendMessage(chatId, MSG.GONE) diff --git a/src/catalog.ts b/src/catalog.ts new file mode 100644 index 0000000..eab45b6 --- /dev/null +++ b/src/catalog.ts @@ -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 + archivedIds: Set +} + +type RpcOk = { result: { ok: true; value: T } } +type ApiFn = (req: { rpcId: string; payload: unknown }) => Promise + +function rpcCall(fn: ApiFn | undefined, payload: unknown): Promise | undefined { + if (typeof fn !== 'function') return undefined + return fn({ rpcId: randomUUID(), payload }) +} + +function unwrap(res: unknown): T | undefined { + const r = res as RpcOk | 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 { + 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() + 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() + 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: WorkspaceRow[] = [...byPath.entries()].map(([path, bucket], i) => ({ + id: `live:${i}`, + title: bucket.title, + path, + sessionIds: bucket.sessionIds, + })) + + return { workspaces, sessionsById, archivedIds: new Set() } +} diff --git a/src/commands.ts b/src/commands.ts index c736e36..5d23c5b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,20 +2,28 @@ export const MSG = { DENIED: '无权限。', WELCOME: [ '你好,这是 DeepSeek Harness 的手机遥控器。', - '本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。', + '本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择工作区 → 会话并附着。', '发送 /help 查看命令。', ].join('\n'), HELP: [ - '/sessions — 列出本机正在运行的会话并选择附着', + '/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着', + '/model — 切换当前绑定会话的模型(下一回合生效)', '/status — 查看当前绑定', '/unbind — 断开绑定(不关闭本机会话)', '/help — 显示帮助', '', '绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。', - '仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。', + '仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。', ].join('\n'), 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 { return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。` }, @@ -24,7 +32,21 @@ export const MSG = { STATUS_BOUND(label: string): string { 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 { return `未知命令 ${command}。发送 /help 查看可用命令。` }, @@ -34,6 +56,7 @@ export type ParsedCommand = | { type: 'start'; text: string } | { type: 'help'; text: string } | { type: 'sessions'; text: string } + | { type: 'model'; text: string } | { type: 'status'; text: string } | { type: 'unbind'; text: string } | { type: 'unknown'; command: string; text: string } @@ -51,6 +74,8 @@ export function parseCommand(text: string): ParsedCommand { case '/sessions': case '/list': return { type: 'sessions', text } + case '/model': + return { type: 'model', text } case '/status': return { type: 'status', text } 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:' diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..300922f --- /dev/null +++ b/src/models.ts @@ -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 + +function unwrap(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 { + 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 { + 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 +} diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index df82686..3c8fcd6 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -65,6 +65,10 @@ function makeAgent(id: string, followups: UserMessage[], opts?: { } } +function rpcOk(value: T) { + return { rpcId: 'x', result: { ok: true as const, value } } +} + test('unauthorized user gets denied', async () => { const sent: SentMessage[] = [] 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) }) -test('/sessions with no live agents shows NO_LIVE', async () => { +test('/sessions with no sessions shows NO_SESSIONS', async () => { const sent: SentMessage[] = [] const ctx = { logger: { info() {}, warn() {}, error() {} }, @@ -123,10 +127,10 @@ test('/sessions with no live agents shows NO_LIVE', async () => { sleep: async () => {}, }) 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 followups: UserMessage[] = [] const agent = makeAgent('live-aaa', followups, { @@ -150,11 +154,94 @@ test('/sessions lists live agents with bind callbacks', async () => { sleep: async () => {}, }) await bridge.processUpdate(messageUpdate(10, 1, '/sessions')) - assert.match(sent[0]!.text, /选择要遥控/) - assert.match(sent[0]!.text, /演示会话/) - assert.match(sent[0]!.text, /D:\/gitData\/demo-app/) - 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)) + assert.match(sent[0]!.text, /选择工作区/) + assert.match(sent[0]!.text, /demo-app/) + assert.equal(sent[0]!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, 'ws:0') + + 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 () => { @@ -215,6 +302,148 @@ test('callback bind then plain text followups live agent; mirror assistant to ch 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 () => { const sent: SentMessage[] = [] const followups: UserMessage[] = [] diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts new file mode 100644 index 0000000..f49e437 --- /dev/null +++ b/tests/catalog.test.ts @@ -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) +}) diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 9ff7b6c..242d460 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -10,6 +10,7 @@ test('parse slash commands for remote control', () => { assert.equal(parseCommand('/status').type, 'status') assert.equal(parseCommand('/unbind').type, 'unbind') assert.equal(parseCommand('/disconnect').type, 'unbind') + assert.equal(parseCommand('/model').type, 'model') assert.equal(parseCommand('/sessions@MyBot').type, 'sessions') assert.equal(parseCommand('/foo').type, 'unknown') assert.equal(parseCommand('hello').type, 'plain') @@ -18,6 +19,7 @@ test('parse slash commands for remote control', () => { test('Chinese copy mentions sessions and bind', () => { assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权')) assert.ok(MSG.HELP.includes('/sessions')) + assert.ok(MSG.HELP.includes('/model')) assert.ok(MSG.NEED_BIND.includes('/sessions')) assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器')) })