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