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:
@@ -117,13 +117,15 @@ curl -fsSL https://raw.githubusercontent.com/hi-wenw/dsh-telegram-channel/master
|
||||
1. 菜单选 **2** 启动 `dsh web`(或自己运行 `dsh web`)
|
||||
2. 浏览器里可看到工作区与会话(归档会话不会出现在手机列表)
|
||||
3. 手机对 Bot:`/start` → `/sessions` → 选工作区 → 选会话 → 聊天
|
||||
4. 需要换模型时:`/model` → 点选(与 Web 同 API,下一回合生效)
|
||||
4. 需要换模型时:`/model` → 点选(与 Web 同 API,下一回合生效)
|
||||
5. 续接上下文:附着后点 **查看上次对话**,或发 `/last`
|
||||
|
||||
输入框旁的 **/** 菜单应有:`start` `sessions` `model` `status` `unbind` `help`。
|
||||
输入框旁的 **/** 菜单应有:`start` `sessions` `last` `model` `status` `unbind` `help`。
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `/sessions` | 先列工作区,再列该工作区会话(与 Web 对齐,排除归档/空白/子代理);冷会话附着时会自动 resume |
|
||||
| `/last` | 查看绑定会话的**上次问答**(附着后也会出现「查看上次对话」按钮) |
|
||||
| `/model` | 切换当前绑定会话的模型 |
|
||||
| `/status` | 当前绑定 |
|
||||
| `/unbind` | 只断开手机,**不关**电脑会话 |
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
|
||||
Vendored
+1
@@ -33,6 +33,7 @@ export declare class TelegramBridge {
|
||||
private sendWorkspacePicker;
|
||||
private sendSessionPicker;
|
||||
private bindSession;
|
||||
private sendLastTurn;
|
||||
private sendModelPicker;
|
||||
private sendEffortPicker;
|
||||
private applyModel;
|
||||
|
||||
+34
-2
@@ -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));
|
||||
|
||||
Vendored
+6
@@ -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
@@ -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';
|
||||
|
||||
Vendored
+28
@@ -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
@@ -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);
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dsh-telegram-channel",
|
||||
"version": "0.3.3",
|
||||
"description": "DeepSeek Harness Telegram mobile remote: workspace→session picker (Web-aligned), /model switch, same trajectory — dsh-plugin",
|
||||
"version": "0.3.4",
|
||||
"description": "DeepSeek Harness Telegram mobile remote: workspace→session, /last context, /model — dsh-plugin",
|
||||
"keywords": [
|
||||
"telegram",
|
||||
"bot",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
/** Loose ApiProxy surface used by catalog + model helpers. */
|
||||
export type ApiProxySessions = {
|
||||
list?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||
history?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||
models?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||
selectModel?: (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||
}
|
||||
|
||||
+35
-2
@@ -20,8 +20,9 @@ import {
|
||||
type TelegramClientLike,
|
||||
type TelegramUpdate,
|
||||
} 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,
|
||||
@@ -67,6 +68,12 @@ const EFFORT_CB = 'me:'
|
||||
const BACK_MODEL_CB = 'mb'
|
||||
const MAX_BUTTONS = 40
|
||||
|
||||
function lastContextKeyboard(): InlineKeyboardMarkup {
|
||||
return {
|
||||
inline_keyboard: [[{ text: '查看上次对话', callback_data: LAST_CB }]],
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
function contentToText(content: readonly ContentBlock[]): string {
|
||||
@@ -115,6 +122,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: '断开手机绑定(不关闭本机会话)' },
|
||||
@@ -174,6 +182,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
|
||||
@@ -210,6 +221,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)
|
||||
@@ -384,7 +400,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())
|
||||
}
|
||||
|
||||
private async sendLastTurn(chatId: number): Promise<void> {
|
||||
const binding = this.bindings.get(String(chatId))
|
||||
if (!binding) {
|
||||
await this.client.sendMessage(chatId, MSG.NEED_BIND)
|
||||
return
|
||||
}
|
||||
const agent = await this.ensureLiveAgent(binding.sessionId)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private async sendModelPicker(chatId: number): Promise<void> {
|
||||
|
||||
+10
-1
@@ -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: string): string {
|
||||
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?: string): string {
|
||||
const tip = '无法读取模型列表。请确认已绑定会话,且本机 dsh web 已加载 host-apiproxy。'
|
||||
if (!detail) return tip
|
||||
@@ -60,6 +62,7 @@ export type ParsedCommand =
|
||||
| { type: 'start'; text: string }
|
||||
| { type: 'help'; text: string }
|
||||
| { type: 'sessions'; text: string }
|
||||
| { type: 'last'; text: string }
|
||||
| { type: 'model'; text: string }
|
||||
| { type: 'status'; text: string }
|
||||
| { type: 'unbind'; text: string }
|
||||
@@ -78,6 +81,9 @@ export function parseCommand(text: string): ParsedCommand {
|
||||
case '/sessions':
|
||||
case '/list':
|
||||
return { type: 'sessions', text }
|
||||
case '/last':
|
||||
case '/context':
|
||||
return { type: 'last', text }
|
||||
case '/model':
|
||||
return { type: 'model', text }
|
||||
case '/status':
|
||||
@@ -92,3 +98,6 @@ export function parseCommand(text: string): ParsedCommand {
|
||||
|
||||
/** @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'
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import { resolveApiProxy } from './apiproxy.js'
|
||||
|
||||
export interface LastTurn {
|
||||
userText?: string
|
||||
assistantText?: string
|
||||
}
|
||||
|
||||
type ApiFn = (req: { rpcId: string; payload: unknown }) => Promise<unknown>
|
||||
|
||||
function unwrap<T>(res: unknown): T | undefined {
|
||||
const r = res as { result?: { ok?: boolean; value?: T; error?: { message?: string } } } | undefined
|
||||
if (r?.result?.ok === true) return r.result.value
|
||||
return undefined
|
||||
}
|
||||
|
||||
function contentToText(content: unknown): string {
|
||||
if (!Array.isArray(content)) return ''
|
||||
return (content as ContentBlock[])
|
||||
.filter((block): block is TextBlock => block?.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text)
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
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 function extractLastTurn(events: readonly LooseEvent[]): LastTurn {
|
||||
let lastUser: string | undefined
|
||||
let lastAssistant: string | undefined
|
||||
|
||||
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: LastTurn, maxEach = 3500): string {
|
||||
if (!turn.userText && !turn.assistantText) {
|
||||
return '暂无上次问答(会话可能为空或尚未产生用户回合)。'
|
||||
}
|
||||
const clip = (s: string) => {
|
||||
const chars = [...s]
|
||||
if (chars.length <= maxEach) return s
|
||||
return `${chars.slice(0, maxEach - 1).join('')}…`
|
||||
}
|
||||
const parts: string[] = ['—— 上次对话 ——']
|
||||
if (turn.userText) {
|
||||
parts.push('', '【用户】', clip(turn.userText))
|
||||
}
|
||||
if (turn.assistantText) {
|
||||
parts.push('', '【助手】', clip(turn.assistantText))
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
async function historyViaApi(ctx: Context, sessionId: string): Promise<LooseEvent[] | undefined> {
|
||||
const api = resolveApiProxy(ctx)
|
||||
const history = api?.sessions?.history as ApiFn | undefined
|
||||
if (typeof history !== 'function') return undefined
|
||||
const raw = await history({
|
||||
rpcId: randomUUID(),
|
||||
payload: { sessionId, maxMessages: 8 },
|
||||
})
|
||||
const value = unwrap<{ events: Array<{ event?: LooseEvent } | LooseEvent> }>(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 as LooseEvent
|
||||
})
|
||||
}
|
||||
|
||||
function historyViaAgent(agent: Agent | undefined): LooseEvent[] | undefined {
|
||||
const events = (agent as { session?: { events?: readonly LooseEvent[] } } | undefined)?.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: Context,
|
||||
sessionId: string,
|
||||
agent?: Agent,
|
||||
): Promise<LastTurn> {
|
||||
let events: LooseEvent[] | undefined
|
||||
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)
|
||||
}
|
||||
+75
-1
@@ -4,7 +4,7 @@ import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { TelegramBridge } from '../src/bridge.ts'
|
||||
import type { InlineKeyboardMarkup, TelegramClientLike, TelegramUpdate } from '../src/client.ts'
|
||||
import { BIND_CB_PREFIX, MSG } from '../src/commands.ts'
|
||||
import { BIND_CB_PREFIX, LAST_CB, MSG } from '../src/commands.ts'
|
||||
|
||||
type SentMessage = {
|
||||
chatId: number
|
||||
@@ -285,6 +285,7 @@ test('callback bind then plain text followups live agent; mirror assistant to ch
|
||||
},
|
||||
})
|
||||
assert.match(sent.at(-1)!.text, /已附着/)
|
||||
assert.equal(sent.at(-1)!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data, LAST_CB)
|
||||
|
||||
await bridge.processUpdate(messageUpdate(10, 1, 'hello from phone', 3))
|
||||
assert.equal(followups.length, 1)
|
||||
@@ -444,6 +445,79 @@ test('/model lists and selects via apiProxy', async () => {
|
||||
assert.match(sent.at(-1)!.text, /已切换模型/)
|
||||
})
|
||||
|
||||
test('/last returns previous Q/A via apiProxy history', async () => {
|
||||
const sent: SentMessage[] = []
|
||||
const followups: UserMessage[] = []
|
||||
const agent = makeAgent('live-last', followups, { title: '有历史' })
|
||||
;(agent as any).session.events = [
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { source: { kind: 'user' }, content: [{ type: 'text', text: '手机续接前的问题' }] },
|
||||
},
|
||||
{
|
||||
type: 'assistant/message',
|
||||
data: { message: { content: [{ type: 'text', text: '电脑上的回答' }] } },
|
||||
},
|
||||
]
|
||||
const ctx = {
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
agents: {
|
||||
list: () => [agent],
|
||||
roots: () => [agent],
|
||||
get: () => agent,
|
||||
},
|
||||
apiProxy: {
|
||||
sessions: {
|
||||
history: async () => ({
|
||||
rpcId: 'h',
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
events: [
|
||||
{
|
||||
event: {
|
||||
type: 'user/message',
|
||||
data: { source: { kind: 'user' }, content: [{ type: 'text', text: '手机续接前的问题' }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: 'assistant/message',
|
||||
data: { message: { content: [{ type: 'text', text: '电脑上的回答' }] } },
|
||||
},
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
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-last`,
|
||||
},
|
||||
})
|
||||
await bridge.processUpdate(messageUpdate(10, 1, '/last', 2))
|
||||
const body = sent.at(-1)!.text
|
||||
assert.match(body, /上次对话|用户/)
|
||||
assert.match(body, /手机续接前的问题/)
|
||||
assert.match(body, /电脑上的回答/)
|
||||
})
|
||||
|
||||
test('/unbind clears binding without needing create/dispose', async () => {
|
||||
const sent: SentMessage[] = []
|
||||
const followups: UserMessage[] = []
|
||||
|
||||
@@ -11,6 +11,8 @@ test('parse slash commands for remote control', () => {
|
||||
assert.equal(parseCommand('/unbind').type, 'unbind')
|
||||
assert.equal(parseCommand('/disconnect').type, 'unbind')
|
||||
assert.equal(parseCommand('/model').type, 'model')
|
||||
assert.equal(parseCommand('/last').type, 'last')
|
||||
assert.equal(parseCommand('/context').type, 'last')
|
||||
assert.equal(parseCommand('/sessions@MyBot').type, 'sessions')
|
||||
assert.equal(parseCommand('/foo').type, 'unknown')
|
||||
assert.equal(parseCommand('hello').type, 'plain')
|
||||
@@ -19,6 +21,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('/last'))
|
||||
assert.ok(MSG.HELP.includes('/model'))
|
||||
assert.ok(MSG.NEED_BIND.includes('/sessions'))
|
||||
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { extractLastTurn, formatLastTurn } from '../src/history.ts'
|
||||
|
||||
test('extractLastTurn pairs latest human user with following assistant', () => {
|
||||
const turn = extractLastTurn([
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { source: { kind: 'plugin', plugin: 'x' }, content: [{ type: 'text', text: 'injected' }] },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第一问' }] },
|
||||
},
|
||||
{
|
||||
type: 'assistant/message',
|
||||
data: { message: { content: [{ type: 'text', text: '第一答' }] } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第二问' }] },
|
||||
},
|
||||
{
|
||||
type: 'assistant/message',
|
||||
data: { message: { content: [{ type: 'text', text: '第二答' }] } },
|
||||
},
|
||||
])
|
||||
assert.equal(turn.userText, '第二问')
|
||||
assert.equal(turn.assistantText, '第二答')
|
||||
})
|
||||
|
||||
test('formatLastTurn renders sections', () => {
|
||||
const text = formatLastTurn({ userText: 'hi', assistantText: 'hello' })
|
||||
assert.match(text, /【用户】/)
|
||||
assert.match(text, /hi/)
|
||||
assert.match(text, /【助手】/)
|
||||
assert.match(text, /hello/)
|
||||
})
|
||||
Reference in New Issue
Block a user