Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ DeepSeek Harness 会自动调用工具并连续作答,虽然可以使用插话
- **可开关**:默认关闭,每个会话可独立开启。
- **暂停在请求前**:模型完成一轮工具交互、正要发出下一次请求之前停下,不打断任何在途 API 调用。
- **保持 composer 可用**:暂停期间输入框照常可打字,不遮挡、不切换。
- **回车即放行**:在主输入框按裸 Enter 继续。草稿为空为无感放行,草稿有字则把文字作为插话随本次请求一并发出,随后清空草稿。
- **按钮同样放行**:也可直接点「已暂停·回车或点此继续」按钮,行为同上并读取当前草稿。
- **回车即放行**:在主输入框按裸 Enter 继续。草稿为空为无感放行,草稿有字或图片则作为插话随本次请求一并发出,随后清空草稿与附件。
- **按钮同样放行**:也可直接点「已暂停·回车或点此继续」按钮,行为同上并读取当前草稿与附件。
- **图片随插话**:草稿里的图片会先经附件服务持久化,再与文字组成同一条用户消息,不会只发文字而把图片留在输入框。文件附件暂不支持插话,会提示先移除。
- **模型无感知**:上下文完整、不插入多余消息,模型察觉不到暂停存在。

## 它长什么样
Expand Down Expand Up @@ -59,7 +60,7 @@ dsh plugin --profile web remove dsh-pause

1. 点 composer 工具行右侧的「暂停」按钮,变「暂停开」即该会话已开启。
2. 之后该会话的工具续跑请求会在发出前停下等你。
3. 需要放行时在主输入框按裸 Enter,空为无感继续,有字为插话。也可点「已暂停·回车或点此继续」。
3. 需要放行时在主输入框按裸 Enter,空为无感继续,有字或有图为插话。也可点「已暂停·回车或点此继续」。
4. 用完再点「暂停开」关掉。暂停中关掉会放行正挂着的门。

## 配置
Expand All @@ -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 的信任判定与浏览器鉴权,信封格式与官方一致。

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
107 changes: 92 additions & 15 deletions src/client/PauseBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly SubmitAttachment[]>
}

/** conversation.input.right 的 session 作用域组件收到的标准 props + 注入面。 */
Expand Down Expand Up @@ -49,13 +51,25 @@ async function fetchStatus(rpc: PauseBarInjected['rpc'], sessionId: string): Pro
}
}

async function callRelease(rpc: PauseBarInjected['rpc'], sessionId: string, text: string): Promise<boolean> {
/** 一次放行的结果;error 存在表示主机拒绝,门未放行。 */
interface ReleaseOutcome {
readonly released: boolean
readonly error?: string
}

async function callRelease(
rpc: PauseBarInjected['rpc'],
sessionId: string,
text: string,
images: readonly SubmitAttachment[],
): Promise<ReleaseOutcome> {
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) }
}
}

Expand All @@ -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<void> {
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()
}

/**
Expand All @@ -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<string | undefined>(undefined)
const rootRef = useRef<HTMLDivElement | null>(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(() => {
Expand Down Expand Up @@ -153,6 +224,7 @@ export const PauseControl = memo(function PauseControl({
}, [paused])

const toggle = async (next: boolean): Promise<void> => {
setError(undefined)
setEnabled(next)
await callSetEnabled(rpc, sessionId, next)
}
Expand Down Expand Up @@ -212,6 +284,11 @@ export const PauseControl = memo(function PauseControl({
已暂停·回车或点此继续
</button>
)}
{error !== undefined && (
<span style={{ color: 'var(--dsw-alias-label-error, #d9534f)', fontSize: '12px' }}>
{error}
</span>
)}
</div>
)
})
Expand Down
20 changes: 16 additions & 4 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly SubmitAttachment[]>
}

/**
Expand All @@ -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,
),
Expand Down
27 changes: 13 additions & 14 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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' },
})]
})
}

/** 会话状态在安装器里的默认初始值。 */
Expand Down Expand Up @@ -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
}

Expand All @@ -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 },
})
}

Expand Down
Loading
Loading