From 21d4f67618c7c3c8cd4d25fc45c7a0ebab962cd9 Mon Sep 17 00:00:00 2001 From: betterer <54624517+better-er@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:53:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B8=A6=E5=9B=BE=E6=8F=92=E8=AF=9D?= =?UTF-8?q?=E6=8A=8A=E5=9B=BE=E7=89=87=E4=B8=80=E5=B9=B6=E5=87=86=E5=85=A5?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +-- package.json | 2 +- src/client/PauseBar.tsx | 107 ++++++++++++++++++++++++++++++++------ src/client/index.ts | 20 +++++-- src/controller.ts | 27 +++++----- src/index.ts | 102 +++++++++++++++++++++++++++++++----- src/rpc-channel.ts | 34 ++++++++---- tests/controller.spec.ts | 26 ++++----- tests/index.spec.ts | 67 +++++++++++++++++++++++- tests/rpc-channel.spec.ts | 14 ++--- 10 files changed, 326 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 6b5e914..53f91f7 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ DeepSeek Harness 会自动调用工具并连续作答,虽然可以使用插话 - **可开关**:默认关闭,每个会话可独立开启。 - **暂停在请求前**:模型完成一轮工具交互、正要发出下一次请求之前停下,不打断任何在途 API 调用。 - **保持 composer 可用**:暂停期间输入框照常可打字,不遮挡、不切换。 -- **回车即放行**:在主输入框按裸 Enter 继续。草稿为空为无感放行,草稿有字则把文字作为插话随本次请求一并发出,随后清空草稿。 -- **按钮同样放行**:也可直接点「已暂停·回车或点此继续」按钮,行为同上并读取当前草稿。 +- **回车即放行**:在主输入框按裸 Enter 继续。草稿为空为无感放行,草稿有字或图片则作为插话随本次请求一并发出,随后清空草稿与附件。 +- **按钮同样放行**:也可直接点「已暂停·回车或点此继续」按钮,行为同上并读取当前草稿与附件。 +- **图片随插话**:草稿里的图片会先经附件服务持久化,再与文字组成同一条用户消息,不会只发文字而把图片留在输入框。文件附件暂不支持插话,会提示先移除。 - **模型无感知**:上下文完整、不插入多余消息,模型察觉不到暂停存在。 ## 它长什么样 @@ -59,7 +60,7 @@ dsh plugin --profile web remove dsh-pause 1. 点 composer 工具行右侧的「暂停」按钮,变「暂停开」即该会话已开启。 2. 之后该会话的工具续跑请求会在发出前停下等你。 -3. 需要放行时在主输入框按裸 Enter,空为无感继续,有字为插话。也可点「已暂停·回车或点此继续」。 +3. 需要放行时在主输入框按裸 Enter,空为无感继续,有字或有图为插话。也可点「已暂停·回车或点此继续」。 4. 用完再点「暂停开」关掉。暂停中关掉会放行正挂着的门。 ## 配置 @@ -81,6 +82,7 @@ dsh 把一次模型请求当作一个 step,agent 连续调用工具时会经 - 暂停点设在请求真正发出之前,故无在途调用可打断。 - 续跑判定用同一 turn 内 step 大于 1 近似,首条纯文字请求永不暂停。 - 浏览器端经 `conversation.input.right` 槽位在 composer 工具行渲染控件。 +- 带图插话由浏览器把草稿图片序列化成 base64 随放行请求上传,主机经 `attachments.admitPromptContent` 换成持久引用后与文字并入同一条 user 消息;RPC 请求体上限据此放宽到 300 MiB。 - 因 dsh 的 composer Enter 由 InputBar 私有 keymap 处理、第三方无 hook 点,暂停态用 DOM capture 拦截裸 Enter 改走放行,不改 DSH 源码。 - 主机侧不经 `ctx.connection.rpc.handle`:该 API 自 0.1.2 起即不可用,改为向 webServer 自注册 `/dsh-pause` 前缀路由,复用 connection 的信任判定与浏览器鉴权,信封格式与官方一致。 diff --git a/package.json b/package.json index b3264a9..11fed4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-pause", - "version": "0.1.3", + "version": "0.1.4", "packageManager": "pnpm@9.15.9", "engines": { "node": ">=26.3.1" diff --git a/src/client/PauseBar.tsx b/src/client/PauseBar.tsx index 8bd9a33..c5f954a 100644 --- a/src/client/PauseBar.tsx +++ b/src/client/PauseBar.tsx @@ -13,14 +13,16 @@ import { memo, useEffect, useRef, useState } from 'react' import type { ReactElement } from 'react' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import type { InputActions, InputState } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { DraftAttachmentId, InputActions, InputState, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-conversation/client' /** 注册时注入读条组件、按钮所需的业务面。 */ export interface PauseBarInjected { /** Connection RPC 调用器,主机端提供 /dsh-pause。 */ readonly rpc: { - call(channel: string, endpoint: string, payload: unknown): Promise<{ ok: boolean; value?: unknown }> + call(channel: string, endpoint: string, payload: unknown): Promise<{ ok: boolean; value?: unknown; error?: { message?: string } }> } + /** 把草稿附件序列化成可随插话提交的载荷。 */ + readonly serializeAttachments?: (ids: readonly DraftAttachmentId[]) => Promise } /** conversation.input.right 的 session 作用域组件收到的标准 props + 注入面。 */ @@ -49,13 +51,25 @@ async function fetchStatus(rpc: PauseBarInjected['rpc'], sessionId: string): Pro } } -async function callRelease(rpc: PauseBarInjected['rpc'], sessionId: string, text: string): Promise { +/** 一次放行的结果;error 存在表示主机拒绝,门未放行。 */ +interface ReleaseOutcome { + readonly released: boolean + readonly error?: string +} + +async function callRelease( + rpc: PauseBarInjected['rpc'], + sessionId: string, + text: string, + images: readonly SubmitAttachment[], +): Promise { try { - const result = await rpc.call('/dsh-pause', 'release', { sessionId, text }) - const value = result.ok ? (result.value as { released?: boolean } | undefined) : undefined - return value?.released === true - } catch { - return false + const result = await rpc.call('/dsh-pause', 'release', { sessionId, text, images }) + if (!result.ok) return { released: false, error: result.error?.message ?? '放行失败' } + const value = result.value as { released?: boolean } | undefined + return { released: value?.released === true } + } catch (error) { + return { released: false, error: error instanceof Error ? error.message : String(error) } } } @@ -67,19 +81,51 @@ async function callSetEnabled(rpc: PauseBarInjected['rpc'], sessionId: string, e } } -/** 放行一扇门:draft 为空 = 无感继续;有字 = 插话并清空草稿。 */ +/** + * 放行一扇门:draft 与图片一起作为插话,空则无感继续。 + * 附件必须先序列化并随放行请求上传;主机拒绝时保持门与草稿原样,避免出现只发文字、图片残留的状态。 + */ async function releaseAndClear( rpc: PauseBarInjected['rpc'], sessionId: string, draft: string, + attachmentIds: readonly DraftAttachmentId[], + serializeAttachments: PauseBarInjected['serializeAttachments'], inputActions: InputActions | undefined, onDone: () => void, + onError: (message: string) => void, ): Promise { - const released = await callRelease(rpc, sessionId, draft) - if (released) { - if (draft !== '' && inputActions !== undefined) inputActions.setDraft('') + let attachments: readonly SubmitAttachment[] = [] + if (attachmentIds.length > 0) { + if (serializeAttachments === undefined) { + onError('当前环境不支持附件插话,请先发送或移除附件') + return + } + try { + attachments = await serializeAttachments(attachmentIds) + } catch (error) { + onError('附件无法随插话发送:' + (error instanceof Error ? error.message : String(error))) + return + } + } + if (attachments.some((part) => part.type !== 'image')) { + onError('暂停中只支持图片插话,请先移除文件附件') + return + } + const outcome = await callRelease(rpc, sessionId, draft, attachments) + if (outcome.error !== undefined) { + onError(outcome.error) + return + } + if (!outcome.released) { onDone() + return } + if (draft !== '' && inputActions !== undefined) inputActions.setDraft('') + if (inputActions !== undefined) { + for (const id of attachmentIds) inputActions.removeAttachment(id) + } + onDone() } /** @@ -102,19 +148,44 @@ export const PauseControl = memo(function PauseControl({ sessionId, useInput, inputActions, + serializeAttachments, }: PauseControlProps) { const [enabled, setEnabled] = useState(false) const [paused, setPaused] = useState(false) + const [error, setError] = useState(undefined) const rootRef = useRef(null) - // render 内 subscribe:草稿变化会触发本组件重渲染并更新 ref。 + // render 内 subscribe:草稿与附件变化会触发本组件重渲染并更新 ref。 // 注意:不能在 capture 事件处理器里调 useInput,因为 hook 只在 render 期合法,故存 ref。 const draft = useInput((state: InputState) => state.draft) + const attachmentIds = useInput((state: InputState) => state.attachmentIds) const draftRef = useRef(draft) draftRef.current = draft + const idsRef = useRef(attachmentIds) + idsRef.current = attachmentIds // capture 处理器要读最新 paused/rpc/sessionId/inputActions,也用 ref 规避闭包过期。 - const gateRef = useRef({ paused, release: () => releaseAndClear(rpc, sessionId, draftRef.current, inputActions, () => setPaused(false)) }) - gateRef.current = { paused, release: () => releaseAndClear(rpc, sessionId, draftRef.current, inputActions, () => setPaused(false)) } + const gateRef = useRef<{ paused: boolean; release: () => void }>({ paused: false, release: () => {} }) + // 放行中的重复 Enter 或连点不再发起第二次上传,避免同一批图片被提交两次。 + const releasingRef = useRef(false) + gateRef.current = { + paused, + release: () => { + if (releasingRef.current) return + releasingRef.current = true + setError(undefined) + const pending = releaseAndClear( + rpc, + sessionId, + draftRef.current, + idsRef.current, + serializeAttachments, + inputActions, + () => setPaused(false), + (message) => setError(message), + ) + void pending.finally(() => { releasingRef.current = false }) + }, + } // 轮询主机端状态。 useEffect(() => { @@ -153,6 +224,7 @@ export const PauseControl = memo(function PauseControl({ }, [paused]) const toggle = async (next: boolean): Promise => { + setError(undefined) setEnabled(next) await callSetEnabled(rpc, sessionId, next) } @@ -212,6 +284,11 @@ export const PauseControl = memo(function PauseControl({ 已暂停·回车或点此继续 )} + {error !== undefined && ( + + {error} + + )} ) }) diff --git a/src/client/index.ts b/src/client/index.ts index 91bc3d1..b0359dc 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -10,19 +10,21 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ClientConnectionRpc } from '@deepseek-ai/dsh-client-connection/client' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ConversationController, DraftAttachmentId, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { PauseControl } from './PauseBar.tsx' /** 插件名即配置项 id。 */ export const name = 'dsh-pause' -/** 本插件需要的客户端服务。 */ -export const inject = ['slots', 'connection'] +/** 本插件需要的客户端服务。conversation 用于把草稿附件序列化成可随插话提交的载荷。 */ +export const inject = ['slots', 'connection', 'conversation'] /** 注册时注入读条组件、按钮所需的业务面。 */ export interface PauseBarInjected { /** Connection RPC 调用器,主机端提供 /dsh-pause。 */ readonly rpc: ClientConnectionRpc + /** 把草稿附件序列化成图片 base64 或文件 receipt;图片在主机侧持久化后随插话发出。 */ + readonly serializeAttachments: (ids: readonly DraftAttachmentId[]) => Promise } /** @@ -31,13 +33,23 @@ export interface PauseBarInjected { */ export function apply(ctx: ClientContext): void { const connection = (ctx as unknown as { connection: ConnectionHandle }).connection + const conversation = (ctx as unknown as { conversation: ConversationController }).conversation ctx.slots.inject('conversation.input.right', () => ctx.slots.register( { name: 'conversation.input.right', id: 'dsh-pause', order: 30, - inject: (): PauseBarInjected => ({ rpc: connection.rpc }), + inject: (): PauseBarInjected => ({ + rpc: connection.rpc, + serializeAttachments: async (ids: readonly DraftAttachmentId[]) => { + // 文件附件走官方提交与 receipt 绑定,本插件暂不接管,提前给出可操作的提示。 + if (conversation.resolveDraftAttachments(ids).some((draft) => draft.kind !== 'image')) { + throw new Error('暂停中只支持图片插话,请先移除文件附件') + } + return (await conversation.serializeDraftAttachments(ids)).attachments + }, + }), }, PauseControl, ), diff --git a/src/controller.ts b/src/controller.ts index 357a621..3afba22 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -24,12 +24,10 @@ export interface PauseSessionState { waiting: ReleaseRequest | undefined } -/** 一次放行请求:人类选择继续,可携带一段补充文字。 */ +/** 一次放行请求:人类选择继续,可携带一条已构造好的补充用户消息。 */ export interface ReleaseRequest { - /** 放行时携带的补充文字,可为空串表示无感继续。 */ - text: string - /** 解析放行等待的入口,由安装器在注册 pre-step 时提供。 */ - resolve: (text: string) => void + /** 解析放行等待的入口,参数为待并入本步的补充消息,undefined 表示无感继续。 */ + resolve: (message: UserMessage | undefined) => void reject: (reason: unknown) => void } @@ -52,16 +50,17 @@ export function shouldPause(enabled: boolean, step: number): boolean { /** * 把一段补充文字转成要并进 step 的 user 消息。空文字不产生消息。 + * 带图插话由安装器先经附件服务持久化,再调用本函数无法表达,故只处理纯文字。 * @param text 人类输入的补充文字。 - * @returns 待并入的 user 消息数组;空文字返回空数组。 + * @returns 待并入的 user 消息;空白文字返回 undefined。 */ -export function textToMessages(text: string): UserMessage[] { +export function textToMessage(text: string): UserMessage | undefined { const trimmed = text.trim() - if (trimmed === '') return [] - return [createUserMessage({ + if (trimmed === '') return undefined + return createUserMessage({ content: [{ type: 'text', text: trimmed }], source: { kind: 'user' }, - })] + }) } /** 会话状态在安装器里的默认初始值。 */ @@ -105,15 +104,15 @@ export class PauseRegistry { } /** - * 放行一扇门。若该会话当前正挂着门,用给定文字放行并返回 true;否则返回 false。 + * 放行一扇门。若该会话当前正挂着门,用给定补充消息放行并返回 true;否则返回 false。 * 放行后状态复位为未暂停,因为这一步已经放行。 */ - release(sessionId: string, text: string): boolean { + release(sessionId: string, message: UserMessage | undefined): boolean { const s = this.stateFor(sessionId) if (s.waiting === undefined) return false const resolve = s.waiting.resolve this.states.set(sessionId, { enabled: s.enabled, paused: false, waiting: undefined }) - resolve(text) + resolve(message) return true } @@ -123,7 +122,7 @@ export class PauseRegistry { this.states.set(sessionId, { enabled: s.enabled, paused: true, - waiting: { text: '', resolve: resolvers.resolve, reject: resolvers.reject }, + waiting: { resolve: resolvers.resolve, reject: resolvers.reject }, }) } diff --git a/src/index.ts b/src/index.ts index 1be8b4d..d5a9e99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { UserMessage } from '@deepseek-ai/dsh-llm' -import { DEFAULT_ENABLED, PauseRegistry, textToMessages } from './controller.ts' +import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { DEFAULT_ENABLED, PauseRegistry, textToMessage } from './controller.ts' import { mountRpcChannel, type RpcChannelHandler, type RpcChannelResult } from './rpc-channel.ts' declare module '@deepseek-ai/cordis' { @@ -27,7 +28,32 @@ interface PreStepPayload { } type PreStepDecision = { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[]; startsRequestSeries?: true } -interface ReleaseBody { sessionId?: unknown; text?: unknown } +/** 浏览器可随插话提交的图片媒体类型,与附件服务的准入集合一致。 */ +type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' +const IMAGE_MEDIA_TYPES: ReadonlySet = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']) + +/** 浏览器提交的一张未准入图片,data 为规范 base64。 */ +interface PromptImagePart { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string +} + +/** 待准入的 prompt 片段:文字与未持久化图片。 */ +type PromptPart = { readonly type: 'text'; readonly text: string } | PromptImagePart + +/** 附件服务准入后的片段,图片已换成持久引用。 */ +type AdmittedPromptPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image' | 'file'; readonly attachment: unknown } + +/** 本插件用到的附件服务最小面,避免为类型引入额外包。 */ +interface AttachmentStoreFace { + admitPromptContent(content: readonly PromptPart[]): Promise +} + +interface ReleaseBody { sessionId?: unknown; text?: unknown; images?: unknown } interface EnableBody { sessionId?: unknown; enabled?: unknown } interface StatusBody { sessionId?: unknown } @@ -36,6 +62,54 @@ function rpcError(message: string): RpcChannelResult { return { ok: false, error: { code: 'internal', message, details: {} } } } +/** 读取附件服务。RPC 测试桩或裁剪运行环境可能没有它,此时带图插话会明确报错。 */ +function attachmentStore(ctx: Context): AttachmentStoreFace | undefined { + const get = (ctx as unknown as { get?: (name: string, strict?: boolean) => unknown }).get + if (typeof get !== 'function') return undefined + return get.call(ctx, 'attachments', false) as AttachmentStoreFace | undefined +} + +/** 校验并解包浏览器送来的图片列表,非法输入直接抛错,由 RPC 层转成错误信封。 */ +function readImages(raw: unknown): PromptImagePart[] { + if (raw === undefined || raw === null) return [] + if (!Array.isArray(raw)) throw new Error('release 的 images 必须是数组') + return raw.map((entry: unknown, index: number): PromptImagePart => { + if (entry === null || typeof entry !== 'object') throw new Error(`release 的 images[${index}] 必须是对象`) + const { mediaType, data, name } = entry as { mediaType?: unknown; data?: unknown; name?: unknown } + if (typeof mediaType !== 'string' || !IMAGE_MEDIA_TYPES.has(mediaType)) throw new Error(`release 的 images[${index}] 媒体类型不受支持`) + if (typeof data !== 'string' || data === '') throw new Error(`release 的 images[${index}] 缺少 base64 数据`) + if (name !== undefined && typeof name !== 'string') throw new Error(`release 的 images[${index}] 文件名必须是字符串`) + const part: PromptImagePart = { type: 'image', mediaType: mediaType as ImageMediaType, data } + return name === undefined ? part : { ...part, name } + }) +} + +/** + * 把放行时携带的文字与图片组成一条待并入的用户消息。 + * 图片先经附件服务持久化,得到的持久引用随消息进入会话日志,浏览器侧的临时草稿随后释放。 + * @param ctx 插件上下文,用于取附件服务。 + * @param textRaw 浏览器送来的补充文字,非字符串按空处理。 + * @param imagesRaw 浏览器送来的图片列表,可为空。 + * @returns 待并入的用户消息;文字与图片皆空时 undefined,表示无感继续。 + */ +async function buildReleaseMessage(ctx: Context, textRaw: unknown, imagesRaw: unknown): Promise { + const text = typeof textRaw === 'string' ? textRaw.trim() : '' + const images = readImages(imagesRaw) + if (text === '' && images.length === 0) return undefined + if (images.length === 0) return textToMessage(text) + + const store = attachmentStore(ctx) + if (store === undefined) throw new Error('带图放行需要附件服务,当前环境未提供') + const parts: PromptPart[] = [] + if (text !== '') parts.push({ type: 'text', text }) + parts.push(...images) + const admitted = await store.admitPromptContent(parts) + const content = admitted.map((part) => part.type === 'text' + ? { type: 'text', text: part.text } + : { type: part.type, attachment: part.attachment }) + return createUserMessage({ content: content as ContentBlock[], source: { kind: 'user' } }) +} + export function apply(ctx: Context, config: Config = {}): void { const defaultEnabled = config.defaultEnabled === true const registry = new PauseRegistry(defaultEnabled) @@ -48,15 +122,16 @@ export function apply(ctx: Context, config: Config = {}): void { if (typeof sessionId !== 'string' || typeof enabled !== 'boolean') return rpcError('setEnabled requires sessionId:string, enabled:boolean') registry.setEnabled(sessionId, enabled) // 关闭开关时应放行任何正挂着的门,避免 driver 卡在暂停态无法前进。 - if (!enabled && registry.isPaused(sessionId)) registry.release(sessionId, '') + if (!enabled && registry.isPaused(sessionId)) registry.release(sessionId, undefined) return ok({ paused: registry.isPaused(sessionId) }) } case 'release': { - const { sessionId, text } = payload as ReleaseBody + const { sessionId, text, images } = payload as ReleaseBody if (typeof sessionId !== 'string') return rpcError('release requires sessionId:string') - const carried = typeof text === 'string' ? text : '' - const released = registry.release(sessionId, carried) - return ok({ released }) + // 没有门时直接返回,避免白存图片留下无主附件。 + if (!registry.isPaused(sessionId)) return ok({ released: false }) + const message = await buildReleaseMessage(ctx, text, images) + return ok({ released: registry.release(sessionId, message) }) } case 'status': { const { sessionId } = payload as StatusBody @@ -77,25 +152,24 @@ export function apply(ctx: Context, config: Config = {}): void { const state = registry.stateFor(sessionId) if (payload.step <= 1 || !state.enabled) return next() - let resolveGate: (text: string) => void = () => {} + let resolveGate: (message: UserMessage | undefined) => void = () => {} let rejectGate: (reason: unknown) => void = () => {} - const gate = new Promise((resolve, reject) => { resolveGate = resolve; rejectGate = reject }) + const gate = new Promise((resolve, reject) => { resolveGate = resolve; rejectGate = reject }) registry.armGate(sessionId, { resolve: resolveGate, reject: rejectGate }) const onAbort = (): void => { registry.abortGate(sessionId, payload.signal.reason) } if (payload.signal.aborted) onAbort() else payload.signal.addEventListener('abort', onAbort, { once: true }) - let carried = '' + let carried: UserMessage | undefined try { carried = await gate } catch { /* aborted */ } finally { payload.signal.removeEventListener('abort', onAbort) } const decision = await next() if (decision.kind === 'reject') return decision - const extra = textToMessages(carried) - return extra.length === 0 ? decision : { ...decision, messages: [...decision.messages, ...extra] } + return carried === undefined ? decision : { ...decision, messages: [...decision.messages, carried] } }) } export { PauseRegistry } from './controller.ts' -export { shouldPause, textToMessages, initialSessionState, DEFAULT_ENABLED } from './controller.ts' +export { shouldPause, textToMessage, initialSessionState, DEFAULT_ENABLED } from './controller.ts' export type { PauseSessionState, ReleaseRequest } from './controller.ts' diff --git a/src/rpc-channel.ts b/src/rpc-channel.ts index 31e29d2..52c2ec5 100644 --- a/src/rpc-channel.ts +++ b/src/rpc-channel.ts @@ -6,7 +6,7 @@ * 自注册并非唯一出路。私有 register(owner, channel, handler) 显式接收 owner,不解析 this.ctx,在注入了 webServer 的作用域里可用,能完整复用官方 transport。代价是它标了 private,需要类型断言,且上游可能改名或移除。故这里选择自行实现同样的信封语义。 * * 与官方 transport 的有意差异: - * - 请求体上限 8 MiB;本插件载荷只有 sessionId 与短文字,远小于官方按 maxRequestBodyBytes 的默认 300 MiB。 + * - 请求体上限按官方 maxRequestBodyBytes 默认的 300 MiB 设置;带图插话需随请求传图片的 base64,单图编码后可达数十 MiB。 * - 信封校验手写,只认 type/method/rpcId,不用 clientRequestSchema 校验 payload 与 rpcId 形状。 * - handler 抛错时回 200 信封、code 为 internal,而非官方 500 纯文本;本通道 handler 自带 try,实际不可达。 * @@ -57,8 +57,14 @@ interface Scope { /** 端点路径段的合法字符集,与官方 ENDPOINT_SEGMENT_PATTERN 一致。 */ const SEGMENT = /^[A-Za-z0-9_$.-]+$/ -/** 请求体上限。本插件载荷极小,8 MiB 已远超实际,故不采用官方 300 MiB。 */ -const MAX_BODY_BYTES = 8 * 1024 * 1024 +/** 请求体上限,与官方 maxRequestBodyBytes 默认值一致;图片插话随请求传 base64,需给足空间。 */ +const MAX_BODY_BYTES = 300 * 1024 * 1024 + +/** 挂载选项,测试可传更小的请求体上限以免真的分配数百 MiB。 */ +export interface MountOptions { + /** 请求体字节上限,默认 300 MiB。 */ + maxBodyBytes?: number +} /** 唯一接受的 content-type,与官方信封契约一致。 */ const JSON_MIME = 'application/json' @@ -70,15 +76,17 @@ class PayloadTooLargeError extends Error {} * @param ctx - 插件上下文。 * @param channel - 绝对通道前缀,例如 /dsh-pause。 * @param handler - 端点处理器。 + * @param options - 挂载选项,目前只有请求体上限。 */ -export function mountRpcChannel(ctx: Injectable, channel: string, handler: RpcChannelHandler): void { +export function mountRpcChannel(ctx: Injectable, channel: string, handler: RpcChannelHandler, options: MountOptions = {}): void { + const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES ctx.inject(['connection', 'webServer'], (raw) => { const scope = raw as Scope scope.effect( () => scope.webServer.register({ kind: 'prefix', path: channel, - handler: (req, res) => serve(scope.connection, channel, handler, req, res), + handler: (req, res) => serve(scope.connection, channel, handler, maxBodyBytes, req, res), }), `${channel}: rpc channel`, ) @@ -90,6 +98,7 @@ export function mountRpcChannel(ctx: Injectable, channel: string, handler: RpcCh * @param connection - connection 服务,用其信任判定。 * @param channel - 本通道前缀。 * @param handler - 端点处理器。 + * @param maxBodyBytes - 请求体字节上限。 * @param req - node 请求。 * @param res - node 响应。 */ @@ -97,6 +106,7 @@ async function serve( connection: ConnectionFace, channel: string, handler: RpcChannelHandler, + maxBodyBytes: number, req: IncomingMessage, res: ServerResponse, ): Promise { @@ -120,11 +130,11 @@ async function serve( } // 声明了超限 content-length 时先快速拒绝,不必把整份超限请求读完。 const declared = Number(req.headers['content-length']) - if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return tooLarge(req, res) + if (Number.isFinite(declared) && declared > maxBodyBytes) return tooLarge(req, res) let raw: string try { - raw = await readBody(req) + raw = await readBody(req, maxBodyBytes) } catch (error) { if (error instanceof PayloadTooLargeError) return tooLarge(req, res) res.writeHead(400) @@ -191,14 +201,18 @@ function isJsonContentType(req: IncomingMessage): boolean { return mime !== undefined && mime.trim().toLowerCase() === JSON_MIME } -/** 读取并限长请求体,超限抛 PayloadTooLargeError,由调用方回写 413。 */ -async function readBody(req: IncomingMessage): Promise { +/** + * 读取并限长请求体,超限抛 PayloadTooLargeError,由调用方回写 413。 + * @param req - node 请求。 + * @param maxBodyBytes - 请求体字节上限。 + */ +async function readBody(req: IncomingMessage, maxBodyBytes: number): Promise { const chunks: Buffer[] = [] let size = 0 for await (const chunk of req) { const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer size += buffer.length - if (size > MAX_BODY_BYTES) throw new PayloadTooLargeError('rpc channel: request body exceeds limit') + if (size > maxBodyBytes) throw new PayloadTooLargeError('rpc channel: request body exceeds limit') chunks.push(buffer) } return Buffer.concat(chunks).toString('utf8') diff --git a/tests/controller.spec.ts b/tests/controller.spec.ts index 1db7141..81cdaa6 100644 --- a/tests/controller.spec.ts +++ b/tests/controller.spec.ts @@ -7,7 +7,7 @@ import { PauseRegistry, initialSessionState, shouldPause, - textToMessages, + textToMessage, } from '../src/controller.ts' describe('shouldPause', () => { @@ -23,15 +23,14 @@ describe('shouldPause', () => { }) }) -describe('textToMessages', () => { +describe('textToMessage', () => { it('空/空白文字不产生消息', () => { - expect(textToMessages('')).toHaveLength(0) - expect(textToMessages(' ')).toHaveLength(0) + expect(textToMessage('')).toBeUndefined() + expect(textToMessage(' ')).toBeUndefined() }) it('非空文字产生一条 user 文本消息', () => { - const msgs = textToMessages('先别动手,我再想想') - expect(msgs).toHaveLength(1) - expect(msgs[0]?.content).toMatchObject([{ type: 'text', text: '先别动手,我再想想' }]) + const msg = textToMessage('先别动手,我再想想') + expect(msg?.content).toMatchObject([{ type: 'text', text: '先别动手,我再想想' }]) }) }) @@ -46,25 +45,26 @@ describe('PauseRegistry', () => { reg.setEnabled('s', true) expect(reg.enabled('s')).toBe(true) }) - it('arm/release 门控:release 携带文字并复位', async () => { + it('arm/release 门控:release 携带补充消息并复位', async () => { const reg = new PauseRegistry(false) reg.setEnabled('s', true) - const gate = new Promise((resolve) => { + const message = textToMessage('补一句') + const gate = new Promise((resolve) => { reg.armGate('s', { resolve, reject: () => {} }) }) expect(reg.isPaused('s')).toBe(true) - const released = reg.release('s', '补一句') + const released = reg.release('s', message) expect(released).toBe(true) - await expect(gate).resolves.toBe('补一句') + await expect(gate).resolves.toBe(message) expect(reg.isPaused('s')).toBe(false) }) it('无门时 release 返回 false', () => { const reg = new PauseRegistry(false) - expect(reg.release('s', '')).toBe(false) + expect(reg.release('s', undefined)).toBe(false) }) it('abort 中止门并复位', async () => { const reg = new PauseRegistry(false) - const gate = new Promise((_, reject) => { + const gate = new Promise((_, reject) => { reg.armGate('s', { resolve: () => {}, reject }) }) reg.abortGate('s', new Error('cancelled')) diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 7e3095e..be1894f 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -34,6 +34,22 @@ async function rpc(method: string, payload: unknown): Promise { return body.result } +/** 附件服务桩:记录准入输入,把图片换成固定的持久引用。 */ +function fakeAttachments() { + const state: { input: unknown[] } = { input: [] } + return { + state, + store: { + async admitPromptContent(parts: any[]) { + state.input = parts + return parts.map((part) => part.type === 'text' + ? part + : { type: 'image', attachment: { attachmentId: 'sha256:test', mediaType: part.mediaType, bytes: 3, width: 1, height: 1 } }) + }, + }, + } +} + /** 造一个桩 ctx:捕获 apply 注册的 pre-step 监听,并接住自注册的 prefix 路由。 */ function harness(enabledDefault = false) { let preStepListener: PreStepListener | undefined @@ -47,15 +63,17 @@ function harness(enabledDefault = false) { }, effect(cb: () => unknown) { return cb() }, } + const attachments = fakeAttachments() const ctx = { on(_name: string, listener: any) { if (_name === 'agent/pre-step') preStepListener = listener as PreStepListener return () => true }, inject(_deps: string[], cb: (s: unknown) => void) { cb(scope) }, + get(name: string) { return name === 'attachments' ? attachments.store : undefined }, } apply(ctx as any, { defaultEnabled: enabledDefault }) - return { get preStep() { return preStepListener! } } + return { get preStep() { return preStepListener! }, attachments } } describe('apply host wiring', () => { @@ -118,6 +136,53 @@ describe('apply host wiring', () => { expect(decision.messages[0].content).toMatchObject([{ type: 'text', text: '先别跑,我再想想' }]) }) + it('带图放行:图片经附件服务准入后与文字并入同一条 user 消息', async () => { + const h = harness(true) + const base = { kind: 'enter' as const, messages: [] } + const p = h.preStep( + { agent: { id: 's4' }, turn: 1, step: 2, signal: new AbortController().signal, messages: [] }, + async () => base, + ) + await Promise.resolve() + const rel = await rpc('release', { + sessionId: 's4', + text: ' 看这张图 ', + images: [{ mediaType: 'image/png', data: 'AAAA', name: 'a.png' }], + }) + expect(rel.ok).toBe(true) + expect(rel.value.released).toBe(true) + expect(h.attachments.state.input).toEqual([ + { type: 'text', text: '看这张图' }, + { type: 'image', mediaType: 'image/png', data: 'AAAA', name: 'a.png' }, + ]) + const decision = await p + expect(decision.messages).toHaveLength(1) + expect(decision.messages[0].content).toMatchObject([ + { type: 'text', text: '看这张图' }, + { type: 'image', attachment: { mediaType: 'image/png' } }, + ]) + }) + + it('图片类型非法时拒绝放行且门保持', async () => { + const h = harness(true) + const base = { kind: 'enter' as const, messages: [] } + const p = h.preStep( + { agent: { id: 's5' }, turn: 1, step: 2, signal: new AbortController().signal, messages: [] }, + async () => base, + ) + await Promise.resolve() + const rel = await rpc('release', { + sessionId: 's5', + text: '', + images: [{ mediaType: 'image/svg+xml', data: 'AAAA' }], + }) + expect(rel.ok).toBe(false) + expect((await rpc('status', { sessionId: 's5' })).value.paused).toBe(true) + // 收尾:关闭开关放行,避免测试留下悬挂的 Promise。 + await rpc('setEnabled', { sessionId: 's5', enabled: false }) + await p + }) + it('无门时 release 返回 released:false', async () => { harness(true) const rel = await rpc('release', { sessionId: 'nobody', text: '' }) diff --git a/tests/rpc-channel.spec.ts b/tests/rpc-channel.spec.ts index 27db0cf..5105e50 100644 --- a/tests/rpc-channel.spec.ts +++ b/tests/rpc-channel.spec.ts @@ -78,7 +78,7 @@ function makeResponse(): FakeResponse { /** 挂载一条通道,返回 webServer 收到的路由;服务缺席时返回 undefined。 */ function mount( handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise, - options: { rejection?: 401 | 403; connection?: boolean; webServer?: boolean } = {}, + options: { rejection?: 401 | 403; connection?: boolean; webServer?: boolean; maxBodyBytes?: number } = {}, ): RegisteredRoute | undefined { let route: RegisteredRoute | undefined const scope = { @@ -101,7 +101,7 @@ function mount( return undefined }, } - mountRpcChannel(ctx, '/dsh-pause', handler) + mountRpcChannel(ctx, '/dsh-pause', handler, options.maxBodyBytes === undefined ? {} : { maxBodyBytes: options.maxBodyBytes }) return route } @@ -198,10 +198,11 @@ describe('mountRpcChannel', () => { }) it('请求体超限回 413、带 connection: close 并断开请求', async () => { - const route = mount(okHandler)! + const limit = 64 * 1024 + const route = mount(okHandler, { maxBodyBytes: limit })! const res = makeResponse() let destroyed = false - const req = makeRequest({ body: Buffer.alloc(8 * 1024 * 1024 + 1) }) + const req = makeRequest({ body: Buffer.alloc(limit + 1) }) req.destroy = (): void => { destroyed = true } await route.handler(req, res) expect(res.statusCode).toBe(413) @@ -210,9 +211,10 @@ describe('mountRpcChannel', () => { }) it('content-length 预声明超限即快速 413', async () => { - const route = mount(okHandler)! + const limit = 64 * 1024 + const route = mount(okHandler, { maxBodyBytes: limit })! const res = makeResponse() - await route.handler(makeRequest({ headers: { 'content-type': 'application/json', 'content-length': String(8 * 1024 * 1024 + 1) } }), res) + await route.handler(makeRequest({ headers: { 'content-type': 'application/json', 'content-length': String(limit + 1) } }), res) expect(res.statusCode).toBe(413) })