From fb5a334edb4479b397e4662649bcd75e5c5f57d2 Mon Sep 17 00:00:00 2001 From: owo <17620121969@163.com> Date: Fri, 14 Aug 2026 20:25:07 +0800 Subject: [PATCH] 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 --- README.md | 6 +- lib/apiproxy.d.ts | 4 ++ lib/bridge.d.ts | 1 + lib/bridge.js | 36 ++++++++++- lib/commands.d.ts | 6 ++ lib/commands.js | 9 ++- lib/history.d.ts | 28 +++++++++ lib/history.js | 109 ++++++++++++++++++++++++++++++++++ package.json | 4 +- src/apiproxy.ts | 1 + src/bridge.ts | 37 +++++++++++- src/commands.ts | 11 +++- src/history.ts | 131 +++++++++++++++++++++++++++++++++++++++++ tests/bridge.test.ts | 76 +++++++++++++++++++++++- tests/commands.test.ts | 3 + tests/history.test.ts | 38 ++++++++++++ 16 files changed, 489 insertions(+), 11 deletions(-) create mode 100644 lib/history.d.ts create mode 100644 lib/history.js create mode 100644 src/history.ts create mode 100644 tests/history.test.ts diff --git a/README.md b/README.md index 1c13e1b..c1d0443 100644 --- a/README.md +++ b/README.md @@ -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` | 只断开手机,**不关**电脑会话 | diff --git a/lib/apiproxy.d.ts b/lib/apiproxy.d.ts index 2416a25..5b22e25 100644 --- a/lib/apiproxy.d.ts +++ b/lib/apiproxy.d.ts @@ -5,6 +5,10 @@ export type ApiProxySessions = { rpcId: string; payload: unknown; }) => Promise; + history?: (req: { + rpcId: string; + payload: unknown; + }) => Promise; models?: (req: { rpcId: string; payload: unknown; diff --git a/lib/bridge.d.ts b/lib/bridge.d.ts index 3731968..d6ea6a2 100644 --- a/lib/bridge.d.ts +++ b/lib/bridge.d.ts @@ -33,6 +33,7 @@ export declare class TelegramBridge { private sendWorkspacePicker; private sendSessionPicker; private bindSession; + private sendLastTurn; private sendModelPicker; private sendEffortPicker; private applyModel; diff --git a/lib/bridge.js b/lib/bridge.js index b724c08..0526928 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -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)); diff --git a/lib/commands.d.ts b/lib/commands.d.ts index 875a1c1..f6abd9a 100644 --- a/lib/commands.d.ts +++ b/lib/commands.d.ts @@ -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"; diff --git a/lib/commands.js b/lib/commands.js index 3825976..1c13720 100644 --- a/lib/commands.js +++ b/lib/commands.js @@ -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'; diff --git a/lib/history.d.ts b/lib/history.d.ts new file mode 100644 index 0000000..84543aa --- /dev/null +++ b/lib/history.d.ts @@ -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; +export {}; diff --git a/lib/history.js b/lib/history.js new file mode 100644 index 0000000..e66d4bc --- /dev/null +++ b/lib/history.js @@ -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); +} diff --git a/package.json b/package.json index ad6bcf9..41ec978 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/apiproxy.ts b/src/apiproxy.ts index 4d92740..1928d4f 100644 --- a/src/apiproxy.ts +++ b/src/apiproxy.ts @@ -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 + history?: (req: { rpcId: string; payload: unknown }) => Promise models?: (req: { rpcId: string; payload: unknown }) => Promise selectModel?: (req: { rpcId: string; payload: unknown }) => Promise } diff --git a/src/bridge.ts b/src/bridge.ts index d999fb1..8b8a19b 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -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((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 { + 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 { diff --git a/src/commands.ts b/src/commands.ts index 5adadcc..15920d4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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' diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..1ec8734 --- /dev/null +++ b/src/history.ts @@ -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 + +function unwrap(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 { + 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 { + 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) +} diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 3c8fcd6..7bcfd35 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -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[] = [] diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 242d460..2d79aa6 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -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('遥控器')) diff --git a/tests/history.test.ts b/tests/history.test.ts new file mode 100644 index 0000000..5eabb8a --- /dev/null +++ b/tests/history.test.ts @@ -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/) +})