feat: /last and bind button for previous Q/A context (v0.3.4)

After attaching a session, show a button and /last command that load the latest human/assistant turn via session.history so phone users can continue with context.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
owo
2026-08-14 20:25:07 +08:00
co-authored by Cursor
parent 3566df945b
commit fb5a334edb
16 changed files with 489 additions and 11 deletions
+4
View File
@@ -5,6 +5,10 @@ export type ApiProxySessions = {
rpcId: string;
payload: unknown;
}) => Promise<unknown>;
history?: (req: {
rpcId: string;
payload: unknown;
}) => Promise<unknown>;
models?: (req: {
rpcId: string;
payload: unknown;
+1
View File
@@ -33,6 +33,7 @@ export declare class TelegramBridge {
private sendWorkspacePicker;
private sendSessionPicker;
private bindSession;
private sendLastTurn;
private sendModelPicker;
private sendEffortPicker;
private applyModel;
+34 -2
View File
@@ -3,8 +3,9 @@ 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 { MSG, parseCommand } from './commands.js';
import { MSG, LAST_CB, parseCommand } from './commands.js';
import { markdownToHtml, splitMessage } from './format.js';
import { formatLastTurn, loadLastTurn } from './history.js';
import { describeAgent, displayLabel } from './label.js';
import { formatModel, loadSessionModels, selectSessionModel, } from './models.js';
const WS_CB = 'ws:';
@@ -14,6 +15,11 @@ const MODEL_CB = 'mdl:';
const EFFORT_CB = 'me:';
const BACK_MODEL_CB = 'mb';
const MAX_BUTTONS = 40;
function lastContextKeyboard() {
return {
inline_keyboard: [[{ text: '查看上次对话', callback_data: LAST_CB }]],
};
}
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function contentToText(content) {
return content
@@ -57,6 +63,7 @@ export class TelegramBridge {
void this.client.setMyCommands([
{ command: 'start', description: '欢迎与用法' },
{ command: 'sessions', description: '按工作区列出并附着会话' },
{ command: 'last', description: '查看上次问答(续接上下文)' },
{ command: 'model', description: '切换当前绑定会话的模型' },
{ command: 'status', description: '查看当前绑定' },
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
@@ -112,6 +119,9 @@ export class TelegramBridge {
case 'sessions':
await this.sendWorkspacePicker(chatId);
return;
case 'last':
await this.sendLastTurn(chatId);
return;
case 'model':
await this.sendModelPicker(chatId);
return;
@@ -146,6 +156,11 @@ export class TelegramBridge {
}
const data = cq.data ?? '';
const picker = this.pickers.get(String(chatId));
if (data === LAST_CB) {
await this.client.answerCallbackQuery(cq.id);
await this.sendLastTurn(chatId);
return;
}
if (data === BACK_WS_CB) {
await this.client.answerCallbackQuery(cq.id);
await this.sendWorkspacePicker(chatId, picker?.catalog);
@@ -317,7 +332,24 @@ export class TelegramBridge {
: 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));
await this.client.sendMessage(chatId, MSG.BOUND(label), undefined, lastContextKeyboard());
}
async sendLastTurn(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);
try {
const turn = await loadLastTurn(this.ctx, binding.sessionId, agent);
const text = formatLastTurn(turn);
await this.deliver(chatId, text);
}
catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: /last failed: ${this.redact(err)}`);
await this.client.sendMessage(chatId, MSG.LAST_FAILED);
}
}
async sendModelPicker(chatId) {
const binding = this.bindings.get(String(chatId));
+6
View File
@@ -15,6 +15,7 @@ export declare const MSG: {
readonly STATUS_BOUND: (label: string) => string;
readonly STATUS_BOUND_COLD: (label: string) => string;
readonly GONE: "绑定的会话已不可用。请重新 /sessions。";
readonly LAST_FAILED: "无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。";
readonly MODEL_UNAVAILABLE: (detail?: string) => string;
readonly MODEL_UNROUTABLE: (current: string) => string;
readonly MODEL_EMPTY: (current: string) => string;
@@ -31,6 +32,9 @@ export type ParsedCommand = {
} | {
type: 'sessions';
text: string;
} | {
type: 'last';
text: string;
} | {
type: 'model';
text: string;
@@ -51,3 +55,5 @@ export type ParsedCommand = {
export declare function parseCommand(text: string): ParsedCommand;
/** @deprecated Prefer short index callbacks (ws:/sid:); kept for old messages. */
export declare const BIND_CB_PREFIX = "bind:";
/** Inline button: fetch last Q/A for the bound session. */
export declare const LAST_CB = "last";
+8 -1
View File
@@ -7,6 +7,7 @@ export const MSG = {
].join('\n'),
HELP: [
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
'/last — 查看绑定会话的上次问答(便于续接)',
'/model — 切换当前绑定会话的模型(下一回合生效)',
'/status — 查看当前绑定',
'/unbind — 断开绑定(不关闭本机会话)',
@@ -25,7 +26,7 @@ export const MSG = {
PICKER_STALE: '列表已过期,请重新发送 /sessions。',
RESUME_FAILED: '无法附着该会话(resume 失败)。请确认会话存在于 Web,或先在电脑打开后再试。',
BOUND(label) {
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`;
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。\n需要续接时可点「查看上次对话」,或发送 /last。`;
},
UNBOUND: '已断开绑定。本机会话仍在运行。',
STATUS_NONE: '当前未绑定任何本机会话。发送 /sessions 选择。',
@@ -36,6 +37,7 @@ export const MSG = {
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`;
},
GONE: '绑定的会话已不可用。请重新 /sessions。',
LAST_FAILED: '无法读取上次对话。请确认已绑定,且本机 dsh web / apiProxy 可用。',
MODEL_UNAVAILABLE(detail) {
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。';
if (!detail)
@@ -69,6 +71,9 @@ export function parseCommand(text) {
case '/sessions':
case '/list':
return { type: 'sessions', text };
case '/last':
case '/context':
return { type: 'last', text };
case '/model':
return { type: 'model', text };
case '/status':
@@ -82,3 +87,5 @@ export function parseCommand(text) {
}
/** @deprecated Prefer short index callbacks (ws:/sid:); kept for old messages. */
export const BIND_CB_PREFIX = 'bind:';
/** Inline button: fetch last Q/A for the bound session. */
export const LAST_CB = 'last';
+28
View File
@@ -0,0 +1,28 @@
import type { Context } from '@deepseek-ai/cordis';
import type { Agent } from '@deepseek-ai/dsh-agent';
export interface LastTurn {
userText?: string;
assistantText?: string;
}
type LooseEvent = {
type?: string;
data?: {
source?: {
kind?: string;
};
content?: unknown;
message?: {
content?: unknown;
source?: {
kind?: string;
};
};
text?: string;
};
};
/** Extract the latest human Q + assistant A from a session event list. */
export declare function extractLastTurn(events: readonly LooseEvent[]): LastTurn;
export declare function formatLastTurn(turn: LastTurn, maxEach?: number): string;
/** Load last Q/A for a bound session (apiProxy history, else live agent events). */
export declare function loadLastTurn(ctx: Context, sessionId: string, agent?: Agent): Promise<LastTurn>;
export {};
+109
View File
@@ -0,0 +1,109 @@
import { randomUUID } from 'node:crypto';
import { resolveApiProxy } from './apiproxy.js';
function unwrap(res) {
const r = res;
if (r?.result?.ok === true)
return r.result.value;
return undefined;
}
function contentToText(content) {
if (!Array.isArray(content))
return '';
return content
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('')
.trim();
}
/** Extract the latest human Q + assistant A from a session event list. */
export function extractLastTurn(events) {
let lastUser;
let lastAssistant;
for (const ev of events) {
if (ev.type === 'user/message') {
const sourceKind = ev.data?.source?.kind ?? ev.data?.message?.source?.kind;
// Prefer real human prompts; skip plugin-injected context noise.
if (sourceKind && sourceKind !== 'user')
continue;
const text = contentToText(ev.data?.content ?? ev.data?.message?.content);
if (text) {
lastUser = text;
// New user turn resets prior assistant for pairing.
lastAssistant = undefined;
}
continue;
}
if (ev.type === 'assistant/message') {
const text = contentToText(ev.data?.message?.content ?? ev.data?.content);
if (text)
lastAssistant = text;
continue;
}
if (ev.type === 'compaction/summary') {
const text = typeof ev.data?.text === 'string' ? ev.data.text.trim() : '';
if (text && !lastAssistant)
lastAssistant = `(摘要)${text}`;
}
}
return { userText: lastUser, assistantText: lastAssistant };
}
export function formatLastTurn(turn, maxEach = 3500) {
if (!turn.userText && !turn.assistantText) {
return '暂无上次问答(会话可能为空或尚未产生用户回合)。';
}
const clip = (s) => {
const chars = [...s];
if (chars.length <= maxEach)
return s;
return `${chars.slice(0, maxEach - 1).join('')}…`;
};
const parts = ['—— 上次对话 ——'];
if (turn.userText) {
parts.push('', '【用户】', clip(turn.userText));
}
if (turn.assistantText) {
parts.push('', '【助手】', clip(turn.assistantText));
}
return parts.join('\n');
}
async function historyViaApi(ctx, sessionId) {
const api = resolveApiProxy(ctx);
const history = api?.sessions?.history;
if (typeof history !== 'function')
return undefined;
const raw = await history({
rpcId: randomUUID(),
payload: { sessionId, maxMessages: 8 },
});
const value = unwrap(raw);
if (!value?.events)
return undefined;
return value.events.map((entry) => {
if (entry && typeof entry === 'object' && 'event' in entry && entry.event) {
return entry.event;
}
return entry;
});
}
function historyViaAgent(agent) {
const events = agent?.session?.events;
if (!events || events.length === 0)
return undefined;
return [...events];
}
/** Load last Q/A for a bound session (apiProxy history, else live agent events). */
export async function loadLastTurn(ctx, sessionId, agent) {
let events;
try {
events = await historyViaApi(ctx, sessionId);
}
catch {
events = undefined;
}
if (!events || events.length === 0) {
events = historyViaAgent(agent);
}
if (!events || events.length === 0)
return {};
return extractLastTurn(events);
}