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:
owo
2026-08-14 18:46:56 +08:00
co-authored by Cursor
parent 9bae1a3702
commit ef0e7f34e3
18 changed files with 1486 additions and 108 deletions
+322 -35
View File
@@ -4,15 +4,31 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types'
import { isAuthorized } from './auth.js'
import {
catalogFromLiveAgents,
loadCatalog,
truncateButton,
visibleSessionsForWorkspace,
workspacesWithVisibleSessions,
type CatalogSnapshot,
type SessionRow,
type WorkspaceRow,
} from './catalog.js'
import {
TelegramClient,
type InlineKeyboardMarkup,
type TelegramClientLike,
type TelegramUpdate,
} from './client.js'
import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js'
import { MSG, parseCommand } from './commands.js'
import { markdownToHtml, splitMessage } from './format.js'
import { buttonLabel, describeAgent, detailLines, displayLabel } from './label.js'
import { describeAgent, displayLabel } from './label.js'
import {
formatModel,
loadSessionModels,
selectSessionModel,
type ModelOption,
} from './models.js'
export interface TelegramBridgeOptions {
token: string
@@ -31,10 +47,26 @@ interface Binding {
label: string
}
interface PickerState {
workspaces: WorkspaceRow[]
sessions: SessionRow[]
catalog?: CatalogSnapshot
models?: ModelOption[]
pendingModel?: ModelOption
}
interface SessionLike {
id: ReturnType<typeof SessionId>
}
const WS_CB = 'ws:'
const SID_CB = 'sid:'
const BACK_WS_CB = 'wb'
const MODEL_CB = 'mdl:'
const EFFORT_CB = 'me:'
const BACK_MODEL_CB = 'mb'
const MAX_BUTTONS = 40
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
function contentToText(content: readonly ContentBlock[]): string {
@@ -54,6 +86,7 @@ export class TelegramBridge {
private readonly maxMessageLength: number
private readonly bindings = new Map<string, Binding>()
private readonly pickers = new Map<string, PickerState>()
private polling = false
private offset: number | undefined
private pollPromise: Promise<void> | undefined
@@ -81,7 +114,8 @@ export class TelegramBridge {
})
void this.client.setMyCommands([
{ command: 'start', description: '欢迎与用法' },
{ command: 'sessions', description: '列出并附着本机 live 会话' },
{ command: 'sessions', description: '按工作区列出并附着会话' },
{ command: 'model', description: '切换当前绑定会话的模型' },
{ command: 'status', description: '查看当前绑定' },
{ command: 'unbind', description: '断开手机绑定(不关闭本机会话)' },
{ command: 'help', description: '显示帮助' },
@@ -106,6 +140,7 @@ export class TelegramBridge {
this.disposeSessionListener = undefined
// Never dispose host agents — only clear remote bindings.
this.bindings.clear()
this.pickers.clear()
if (this.pollPromise) {
await this.pollPromise.catch(() => {})
this.pollPromise = undefined
@@ -137,13 +172,17 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.HELP)
return
case 'sessions':
await this.sendSessionPicker(chatId)
await this.sendWorkspacePicker(chatId)
return
case 'model':
await this.sendModelPicker(chatId)
return
case 'status':
await this.sendStatus(chatId)
return
case 'unbind':
this.bindings.delete(String(chatId))
this.pickers.delete(String(chatId))
await this.client.sendMessage(chatId, MSG.UNBOUND)
return
case 'unknown':
@@ -169,21 +208,278 @@ export class TelegramBridge {
return
}
const data = cq.data ?? ''
if (!data.startsWith(BIND_CB_PREFIX)) {
const picker = this.pickers.get(String(chatId))
if (data === BACK_WS_CB) {
await this.client.answerCallbackQuery(cq.id)
await this.sendWorkspacePicker(chatId, picker?.catalog)
return
}
const sessionId = data.slice(BIND_CB_PREFIX.length)
const agent = this.findLiveAgent(sessionId)
if (data === BACK_MODEL_CB) {
await this.client.answerCallbackQuery(cq.id)
await this.sendModelPicker(chatId)
return
}
if (data.startsWith(WS_CB)) {
const index = Number(data.slice(WS_CB.length))
await this.client.answerCallbackQuery(cq.id)
await this.sendSessionPicker(chatId, index)
return
}
if (data.startsWith(SID_CB)) {
const index = Number(data.slice(SID_CB.length))
const row = picker?.sessions[index]
if (!row) {
await this.client.answerCallbackQuery(cq.id, '会话已过期')
await this.client.sendMessage(chatId, MSG.PICKER_STALE)
return
}
await this.bindSession(chatId, cq.id, row)
return
}
if (data.startsWith(MODEL_CB)) {
const index = Number(data.slice(MODEL_CB.length))
const option = picker?.models?.[index]
if (!option) {
await this.client.answerCallbackQuery(cq.id, '列表已过期')
await this.client.sendMessage(chatId, MSG.PICKER_STALE)
return
}
if (option.efforts && option.efforts.length > 0) {
if (picker) picker.pendingModel = option
await this.client.answerCallbackQuery(cq.id)
await this.sendEffortPicker(chatId, option)
return
}
await this.applyModel(chatId, cq.id, option)
return
}
if (data.startsWith(EFFORT_CB)) {
const index = Number(data.slice(EFFORT_CB.length))
const pending = picker?.pendingModel
const effort = pending?.efforts?.[index]
if (!pending || !effort) {
await this.client.answerCallbackQuery(cq.id, '列表已过期')
await this.client.sendMessage(chatId, MSG.PICKER_STALE)
return
}
await this.applyModel(chatId, cq.id, pending, effort.id)
return
}
// Legacy bind:<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)
}
private async resolveCatalog(): Promise<CatalogSnapshot> {
try {
const fromApi = await loadCatalog(this.ctx)
if (fromApi) return fromApi
} catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: catalog via apiProxy failed: ${this.redact(err)}`)
}
return catalogFromLiveAgents(this.liveAgents(), this.ctx)
}
private async sendWorkspacePicker(chatId: number, existing?: CatalogSnapshot): Promise<void> {
const catalog = existing ?? await this.resolveCatalog()
const workspaces = workspacesWithVisibleSessions(catalog)
if (workspaces.length === 0) {
this.pickers.delete(String(chatId))
await this.client.sendMessage(chatId, MSG.NO_SESSIONS)
return
}
const shown = workspaces.slice(0, MAX_BUTTONS)
this.pickers.set(String(chatId), { workspaces: shown, sessions: [], catalog })
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: shown.map((ws, i) => ([{
text: truncateButton(`${i + 1}. ${ws.title}`),
callback_data: `${WS_CB}${i}`,
}])),
}
const body = [
`选择工作区(共 ${workspaces.length} 个,与 Web 对齐,已排除归档):`,
'',
...shown.map((ws, i) => {
const n = visibleSessionsForWorkspace(catalog, ws).length
return `${i + 1}. ${ws.title}\n ${ws.path}\n 会话:${n}`
}),
workspaces.length > MAX_BUTTONS ? `\n仅显示前 ${MAX_BUTTONS} 个工作区。` : '',
'',
'点下方按钮进入该工作区的会话列表。',
].filter(Boolean).join('\n')
await this.client.sendMessage(chatId, body, undefined, keyboard)
}
private async sendSessionPicker(chatId: number, workspaceIndex: number): Promise<void> {
const picker = this.pickers.get(String(chatId))
const catalog = picker?.catalog ?? await this.resolveCatalog()
const workspaces = picker?.workspaces?.length
? picker.workspaces
: workspacesWithVisibleSessions(catalog)
const workspace = workspaces[workspaceIndex]
if (!workspace) {
await this.client.sendMessage(chatId, MSG.PICKER_STALE)
return
}
const sessions = visibleSessionsForWorkspace(catalog, workspace)
.slice()
.sort((a, b) => b.updatedAt - a.updatedAt)
if (sessions.length === 0) {
await this.client.sendMessage(chatId, MSG.NO_SESSIONS_IN_WS(workspace.title))
return
}
const shown = sessions.slice(0, MAX_BUTTONS)
this.pickers.set(String(chatId), { workspaces, sessions: shown, catalog })
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
...shown.map((row, i) => ([{
text: truncateButton(`${i + 1}. ${row.title}${row.running ? '' : ' · 冷'}`),
callback_data: `${SID_CB}${i}`,
}])),
[{ text: '← 返回工作区', callback_data: BACK_WS_CB }],
],
}
const body = [
`工作区:${workspace.title}`,
workspace.path,
'',
`选择会话(共 ${sessions.length} 个):`,
'',
...shown.map((row, i) => {
const mark = row.running ? '运行中' : '未附着'
return `${i + 1}. ${row.title}\n ${mark} · …${row.sessionId.slice(-12)}`
}),
sessions.length > MAX_BUTTONS ? `\n仅显示前 ${MAX_BUTTONS} 个会话。` : '',
'',
'点下方按钮附着;冷会话会自动 resume(不关闭 Web)。',
].filter(Boolean).join('\n')
await this.client.sendMessage(chatId, body, undefined, keyboard)
}
private async bindSession(chatId: number, callbackId: string, row: SessionRow): Promise<void> {
const agent = await this.ensureLiveAgent(row.sessionId)
if (!agent) {
await this.client.answerCallbackQuery(cq.id, '会话不存在')
await this.client.answerCallbackQuery(callbackId, '无法附着')
await this.client.sendMessage(chatId, MSG.RESUME_FAILED)
return
}
const parts = describeAgent(agent, 0, this.ctx)
const label = row.title && row.title !== row.sessionId
? displayLabel({ ...parts, title: row.title })
: displayLabel(parts)
this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label })
await this.client.answerCallbackQuery(callbackId, '已附着')
await this.client.sendMessage(chatId, MSG.BOUND(label))
}
private async sendModelPicker(chatId: number): Promise<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)
if (!agent) {
this.bindings.delete(String(chatId))
await this.client.sendMessage(chatId, MSG.GONE)
return
}
const label = displayLabel(describeAgent(agent, 0, this.ctx))
this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label })
await this.client.answerCallbackQuery(cq.id, '已附着')
await this.client.sendMessage(chatId, MSG.BOUND(label))
try {
const snap = await loadSessionModels(this.ctx, binding.sessionId)
if (!snap.routable) {
await this.client.sendMessage(chatId, MSG.MODEL_UNROUTABLE(formatModel(snap.current)))
return
}
if (snap.options.length === 0) {
await this.client.sendMessage(chatId, MSG.MODEL_EMPTY(formatModel(snap.current)))
return
}
const shown = snap.options.slice(0, MAX_BUTTONS)
const prev = this.pickers.get(String(chatId))
this.pickers.set(String(chatId), {
workspaces: prev?.workspaces ?? [],
sessions: prev?.sessions ?? [],
catalog: prev?.catalog,
models: shown,
})
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: shown.map((opt, i) => ([{
text: truncateButton(
`${opt.label}${opt.provider === snap.current.provider && opt.model === snap.current.model ? ' ✓' : ''}`,
),
callback_data: `${MODEL_CB}${i}`,
}])),
}
const body = [
`当前模型:${formatModel(snap.current)}`,
`会话:${binding.label}`,
'',
'选择新模型(下一回合生效):',
].join('\n')
await this.client.sendMessage(chatId, body, undefined, keyboard)
} catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: /model failed: ${this.redact(err)}`)
await this.client.sendMessage(chatId, MSG.MODEL_UNAVAILABLE)
}
}
private async sendEffortPicker(chatId: number, option: ModelOption): Promise<void> {
const efforts = option.efforts ?? []
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
...efforts.map((e, i) => ([{
text: truncateButton(e.name || e.id),
callback_data: `${EFFORT_CB}${i}`,
}])),
[{ text: '← 返回模型列表', callback_data: BACK_MODEL_CB }],
],
}
await this.client.sendMessage(
chatId,
`已选 ${option.label}\n请选择 reasoning effort:`,
undefined,
keyboard,
)
}
private async applyModel(
chatId: number,
callbackId: string,
option: ModelOption,
reasoningEffort?: string,
): Promise<void> {
const binding = this.bindings.get(String(chatId))
if (!binding) {
await this.client.answerCallbackQuery(callbackId, '未绑定')
await this.client.sendMessage(chatId, MSG.NEED_BIND)
return
}
try {
const selected = await selectSessionModel(this.ctx, binding.sessionId, {
provider: option.provider,
model: option.model,
reasoningEffort,
})
await this.client.answerCallbackQuery(callbackId, '已切换')
await this.client.sendMessage(chatId, MSG.MODEL_SET(formatModel(selected)))
} catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: selectModel failed: ${this.redact(err)}`)
await this.client.answerCallbackQuery(callbackId, '切换失败')
await this.client.sendMessage(chatId, MSG.MODEL_FAILED)
}
}
private liveAgents(): Agent[] {
@@ -209,27 +505,19 @@ export class TelegramBridge {
return this.liveAgents().find((a) => String(a.id) === sessionId)
}
private async sendSessionPicker(chatId: number): Promise<void> {
const agents = this.liveAgents()
if (agents.length === 0) {
await this.client.sendMessage(chatId, MSG.NO_LIVE)
return
/** Resume cold sessions when needed; never dispose the returned handle. */
private async ensureLiveAgent(sessionId: string): Promise<Agent | undefined> {
const live = this.findLiveAgent(sessionId)
if (live) return live
const agents = this.ctx.agents as { resume?: (opts: { resumeSessionId: ReturnType<typeof SessionId> }) => Promise<{ agent: Agent }> }
if (typeof agents.resume !== 'function') return undefined
try {
const handle = await agents.resume({ resumeSessionId: SessionId(sessionId) })
return handle.agent
} catch (err) {
this.ctx.logger.warn(`dsh-telegram-channel: resume failed for ${sessionId}: ${this.redact(err)}`)
return undefined
}
const described = agents.map((agent, i) => describeAgent(agent, i, this.ctx))
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: described.map((parts) => ([{
text: buttonLabel(parts),
callback_data: `${BIND_CB_PREFIX}${parts.sessionId}`,
}])),
}
const body = [
`选择要遥控的本机会话(共 ${agents.length} 个):`,
'',
...described.map((parts) => detailLines(parts)),
'',
'点下方按钮附着对应会话。',
].join('\n')
await this.client.sendMessage(chatId, body, undefined, keyboard)
}
private async sendStatus(chatId: number): Promise<void> {
@@ -240,8 +528,7 @@ export class TelegramBridge {
}
const stillLive = this.findLiveAgent(binding.sessionId)
if (!stillLive) {
this.bindings.delete(String(chatId))
await this.client.sendMessage(chatId, MSG.GONE)
await this.client.sendMessage(chatId, MSG.STATUS_BOUND_COLD(binding.label))
return
}
await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label))
@@ -253,7 +540,7 @@ export class TelegramBridge {
await this.client.sendMessage(chatId, MSG.NEED_BIND)
return
}
const agent = this.findLiveAgent(binding.sessionId)
const agent = await this.ensureLiveAgent(binding.sessionId)
if (!agent) {
this.bindings.delete(String(chatId))
await this.client.sendMessage(chatId, MSG.GONE)
+178
View File
@@ -0,0 +1,178 @@
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { describeAgent, workspaceName } from './label.js'
export interface WorkspaceRow {
id: string
title: string
path: string
sessionIds: string[]
}
export interface SessionRow {
sessionId: string
title: string
cwd?: string
blank: boolean
running: boolean
origin?: string
updatedAt: number
}
export interface CatalogSnapshot {
workspaces: WorkspaceRow[]
sessionsById: Map<string, SessionRow>
archivedIds: Set<string>
}
type RpcOk<T> = { result: { ok: true; value: T } }
type ApiFn = (req: { rpcId: string; payload: unknown }) => Promise<unknown>
function rpcCall(fn: ApiFn | undefined, payload: unknown): Promise<unknown> | undefined {
if (typeof fn !== 'function') return undefined
return fn({ rpcId: randomUUID(), payload })
}
function unwrap<T>(res: unknown): T | undefined {
const r = res as RpcOk<T> | undefined
if (r?.result && (r.result as { ok?: boolean }).ok === true) {
return (r.result as { value: T }).value
}
// Some wrappers may return value directly
if (res && typeof res === 'object' && 'items' in (res as object)) return res as T
return undefined
}
function titleOf(summary: {
sessionId: string
cwd?: string
projections?: { values?: { title?: string | null } }
}): string {
const t = summary.projections?.values?.title
if (typeof t === 'string' && t.trim()) return t.trim()
const ws = workspaceName(summary.cwd)
if (ws) return ws
const id = String(summary.sessionId)
return id.length > 12 ? `…${id.slice(-12)}` : id
}
/** Load workspaces + sessions aligned with Web UI (via apiProxy when available). */
export async function loadCatalog(ctx: Context): Promise<CatalogSnapshot | undefined> {
const api = (ctx as {
apiProxy?: {
workspace?: { list?: ApiFn }
sessions?: { list?: ApiFn }
}
}).apiProxy
const wsPromise = rpcCall(api?.workspace?.list, {})
const sessPromise = rpcCall(api?.sessions?.list, {})
if (!wsPromise || !sessPromise) return undefined
const [wsRaw, sessRaw] = await Promise.all([wsPromise, sessPromise])
const wsVal = unwrap<{
items: Array<{ workspaceId: string; path: string; title: string; sessionIds: string[] }>
archivedSessionIds: string[]
}>(wsRaw)
const sessVal = unwrap<{
items: Array<{
sessionId: string
updatedAt: number
running: boolean
blank: boolean
origin?: string
cwd?: string
projections?: { values?: { title?: string | null } }
}>
}>(sessRaw)
if (!wsVal || !sessVal) return undefined
const archivedIds = new Set((wsVal.archivedSessionIds ?? []).map(String))
const sessionsById = new Map<string, SessionRow>()
for (const item of sessVal.items ?? []) {
const sessionId = String(item.sessionId)
sessionsById.set(sessionId, {
sessionId,
title: titleOf({ ...item, sessionId }),
cwd: item.cwd,
blank: Boolean(item.blank),
running: Boolean(item.running),
origin: item.origin,
updatedAt: Number(item.updatedAt) || 0,
})
}
const workspaces: WorkspaceRow[] = (wsVal.items ?? []).map((w) => ({
id: String(w.workspaceId),
title: w.title || workspaceName(w.path) || String(w.workspaceId),
path: w.path,
sessionIds: (w.sessionIds ?? []).map(String),
}))
return { workspaces, sessionsById, archivedIds }
}
/** Sessions visible under one workspace (Web-like filters). */
export function visibleSessionsForWorkspace(
catalog: CatalogSnapshot,
workspace: WorkspaceRow,
): SessionRow[] {
const rows: SessionRow[] = []
for (const id of workspace.sessionIds) {
if (catalog.archivedIds.has(id)) continue
const row = catalog.sessionsById.get(id)
if (!row) continue
if (row.origin === 'subagent') continue
if (row.blank) continue
rows.push(row)
}
return rows
}
export function workspacesWithVisibleSessions(catalog: CatalogSnapshot): WorkspaceRow[] {
return catalog.workspaces.filter((w) => visibleSessionsForWorkspace(catalog, w).length > 0)
}
export function truncateButton(text: string, max = 64): string {
const chars = [...text]
if (chars.length <= max) return text
return `${chars.slice(0, max - 1).join('')}…`
}
/** Fallback when apiProxy is unavailable: group live agents by cwd. */
export function catalogFromLiveAgents(agents: Agent[], ctx?: Context): CatalogSnapshot {
const sessionsById = new Map<string, SessionRow>()
const byPath = new Map<string, { title: string; path: string; sessionIds: string[] }>()
agents.forEach((agent, index) => {
const parts = describeAgent(agent, index, ctx)
const sessionId = parts.sessionId
const cwd = parts.cwd ?? '(unknown)'
sessionsById.set(sessionId, {
sessionId,
title: parts.title || parts.workspace || sessionId,
cwd: parts.cwd,
blank: false,
running: true,
updatedAt: Date.now() - index,
})
const bucket = byPath.get(cwd) ?? {
title: parts.workspace || cwd,
path: cwd,
sessionIds: [],
}
bucket.sessionIds.push(sessionId)
byPath.set(cwd, bucket)
})
const workspaces: WorkspaceRow[] = [...byPath.entries()].map(([path, bucket], i) => ({
id: `live:${i}`,
title: bucket.title,
path,
sessionIds: bucket.sessionIds,
}))
return { workspaces, sessionsById, archivedIds: new Set() }
}
+31 -6
View File
@@ -2,20 +2,28 @@ export const MSG = {
DENIED: '无权限。',
WELCOME: [
'你好,这是 DeepSeek Harness 的手机遥控器。',
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。',
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择工作区 → 会话并附着。',
'发送 /help 查看命令。',
].join('\n'),
HELP: [
'/sessions — 列出本机正在运行的会话并选择附着',
'/sessions — 按工作区列出本机会话(与 Web 对齐,排除归档)并附着',
'/model — 切换当前绑定会话的模型(下一回合生效)',
'/status — 查看当前绑定',
'/unbind — 断开绑定(不关闭本机会话)',
'/help — 显示帮助',
'',
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
'仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。',
'仅白名单用户可用。无会话时请先在 dsh web 开对话或保留历史会话。',
].join('\n'),
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
NO_LIVE: '当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
NO_SESSIONS: '当前没有可附着的本机会话(已排除归档与空白会话)。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
NO_SESSIONS_IN_WS(title: string): string {
return `工作区「${title}」下没有可附着的会话。`
},
/** @deprecated use NO_SESSIONS */
NO_LIVE: '当前没有可附着的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
PICKER_STALE: '列表已过期,请重新发送 /sessions。',
RESUME_FAILED: '无法附着该会话(resume 失败)。请确认会话存在于 Web,或先在电脑打开后再试。',
BOUND(label: string): string {
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`
},
@@ -24,7 +32,21 @@ export const MSG = {
STATUS_BOUND(label: string): string {
return `当前绑定:${label}`
},
GONE: '绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。',
STATUS_BOUND_COLD(label: string): string {
return `当前绑定:${label}\n(会话当前未在内存中运行;发消息时会自动 resume。)`
},
GONE: '绑定的会话已不可用。请重新 /sessions。',
MODEL_UNAVAILABLE: '无法读取模型列表(需要 dsh web / apiProxy)。请确认本机 Harness 已加载 host-apiproxy。',
MODEL_UNROUTABLE(current: string): string {
return `当前模型不可路由:${current}\n请在 Web 或本机配置可用 provider 后再试 /model。`
},
MODEL_EMPTY(current: string): string {
return `当前:${current}\n没有可切换的模型选项。`
},
MODEL_SET(selected: string): string {
return `已切换模型:${selected}\n下一回合生效。`
},
MODEL_FAILED: '切换模型失败。请稍后重试或在 Web 中切换。',
unknown(command: string): string {
return `未知命令 ${command}。发送 /help 查看可用命令。`
},
@@ -34,6 +56,7 @@ export type ParsedCommand =
| { type: 'start'; text: string }
| { type: 'help'; text: string }
| { type: 'sessions'; text: string }
| { type: 'model'; text: string }
| { type: 'status'; text: string }
| { type: 'unbind'; text: string }
| { type: 'unknown'; command: string; text: string }
@@ -51,6 +74,8 @@ export function parseCommand(text: string): ParsedCommand {
case '/sessions':
case '/list':
return { type: 'sessions', text }
case '/model':
return { type: 'model', text }
case '/status':
return { type: 'status', text }
case '/unbind':
@@ -61,5 +86,5 @@ export function parseCommand(text: string): ParsedCommand {
}
}
/** Callback data prefix for session bind buttons. */
/** @deprecated Prefer short index callbacks (ws:/sid:); kept for old messages. */
export const BIND_CB_PREFIX = 'bind:'
+91
View File
@@ -0,0 +1,91 @@
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
export interface ModelOption {
provider: string
model: string
label: string
efforts?: Array<{ id: string; name: string }>
}
export interface ModelSnapshot {
current: { provider: string; model: string; reasoningEffort?: string }
routable: boolean
options: ModelOption[]
}
type ApiFn = (req: { rpcId: string; payload: unknown }) => Promise<unknown>
function unwrap<T>(res: unknown): T | undefined {
const r = res as { result?: { ok?: boolean; value?: T } } | undefined
if (r?.result?.ok === true) return r.result.value
return undefined
}
async function call(fn: ApiFn | undefined, payload: unknown): Promise<unknown> {
if (typeof fn !== 'function') throw new Error('apiProxy sessions API unavailable')
return fn({ rpcId: randomUUID(), payload })
}
export async function loadSessionModels(ctx: Context, sessionId: string): Promise<ModelSnapshot> {
const api = (ctx as {
apiProxy?: { sessions?: { models?: ApiFn } }
}).apiProxy
const raw = await call(api?.sessions?.models, { sessionId })
const value = unwrap<{
current: { provider: string; model: string; reasoningEffort?: string }
routable: boolean
groups: Array<{
id: string
name: string
models: Array<{
id: string
name: string
reasoning?: { efforts?: Array<{ id: string; name: string }> }
}>
}>
}>(raw)
if (!value) throw new Error('failed to load session models')
const options: ModelOption[] = []
for (const group of value.groups ?? []) {
for (const m of group.models ?? []) {
options.push({
provider: group.id,
model: m.id,
label: `${group.name}/${m.name}`,
efforts: m.reasoning?.efforts?.map((e) => ({ id: e.id, name: e.name })),
})
}
}
return {
current: value.current,
routable: value.routable,
options,
}
}
export async function selectSessionModel(
ctx: Context,
sessionId: string,
selection: { provider: string; model: string; reasoningEffort?: string },
): Promise<{ provider: string; model: string; reasoningEffort?: string }> {
const api = (ctx as {
apiProxy?: { sessions?: { selectModel?: ApiFn } }
}).apiProxy
const raw = await call(api?.sessions?.selectModel, {
sessionId,
provider: selection.provider,
model: selection.model,
reasoningEffort: selection.reasoningEffort,
})
const value = unwrap<{ selected: { provider: string; model: string; reasoningEffort?: string } }>(raw)
if (!value?.selected) throw new Error('failed to select model')
return value.selected
}
export function formatModel(sel: { provider: string; model: string; reasoningEffort?: string }): string {
const base = `${sel.provider}/${sel.model}`
return sel.reasoningEffort ? `${base} (${sel.reasoningEffort})` : base
}