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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ DeepSeek Harness 会自动调用工具并连续作答,虽然可以使用插话
- **可开关**:默认关闭,每个会话可独立开启。
- **仅在暂停点暂停**:只有这个位置会停,也就是模型完成一轮工具交互、正要发出下一次请求之前。你发消息触发的第一次请求不停,也不打断任何在途 API 调用。
- **保持 composer 可用**:暂停期间输入框照常可打字,不遮挡、不切换。
- **回车或按钮放行**:在主输入框按裸 Enter,或点「已暂停·回车或点此继续」按钮,都能放行暂停点。草稿为空无感放行,有字或图片则作为插话随本次请求一并发出。暂停点还没到时按回车也有效,消息留到下一个暂停点;这一轮已结束则作为新的一轮发出,按了就一定送达。
- **回车或按钮放行**:在主输入框按裸 Enter,或点「已暂停·回车或点此继续」按钮,都能放行暂停点。草稿为空无感放行,有字或图片则作为插话随本次请求一并发出。暂停点还没到时按回车也有效,消息立即以 steer 插话送出,不等下一个暂停点;这一轮已结束则由 steer 自己开新的一轮。
- **图片随插话**:按下回车时文字与图片一起立刻离开输入框,图片在后台序列化后随消息发出,不会只发文字而把图片留在输入框。发送失败时文字与已序列化的图片暂存到下次发送,序列化失败的图片需要重新选择。文件附件暂不支持插话,会被直接移出输入框并提示重新选择。
- **模型无感知**:上下文完整、不插入多余消息,模型察觉不到暂停存在。

Expand Down Expand Up @@ -61,7 +61,7 @@ dsh plugin --profile web remove dsh-pause

1. 点 composer 工具行右侧的「暂停」按钮,变「暂停开」即该会话已开启。
2. 之后该会话的工具续跑请求会停在暂停点等你。
3. 需要放行时在主输入框按裸 Enter,空为无感继续,有字或有图为插话。也可点「已暂停·回车或点此继续」。暂停点还没到时按回车同样有效,消息会留到下一个暂停点;这一轮已经结束则作为新的一轮发出。
3. 需要放行时在主输入框按裸 Enter,空为无感继续,有字或有图为插话。也可点「已暂停·回车或点此继续」。暂停点还没到时按回车同样有效,消息立即 steer 送出;这一轮已经结束则由 steer 开新的一轮。
4. 用完再点「暂停开」关掉。暂停中关掉会放行当前暂停点。

## 配置
Expand All @@ -82,10 +82,11 @@ dsh 把一次模型请求当作一个 step,agent 连续调用工具时会经

- 暂停点设在请求真正发出之前,故无在途调用可打断。
- 续跑判定用同一 turn 内 step 大于 1 近似,每个 turn 的第一次请求永不暂停。
- 已有插话时不拦:本步已领取的批次带用户消息、插话还在 inbox 等认领、下一轮队列已有消息,任一条成立就跳过这次暂停点。
- 浏览器端经 `conversation.input.right` 槽位在 composer 工具行渲染控件。
- 带图插话由浏览器把草稿图片序列化成 base64 随放行请求上传,主机经 `attachments.admitPromptContent` 换成持久引用后与文字并入同一条 user 消息;RPC 请求体上限据此放宽到 300 MiB。
- 因 dsh 的 composer Enter 由 InputBar 私有 keymap 处理、第三方无 hook 点,开关开启期间用 DOM capture 常驻拦截裸 Enter,改走主机裁决,不改 DSH 源码。
- 有没有暂停点由主机权威判定:有就放行,没有但 `agent/status` 为 running 就排进队列等下一个,idle 则直接 `followup` 成为新 turn;主机暂时发不出去时交回客户端暂存,不静默丢弃。
- 有没有暂停点由主机权威判定:有就放行并把消息并入本步,没有就立即 `agent.steer`,空闲时 steer 自己开新 turn;插件不缓存插话,主机暂时发不出去时交回客户端暂存,不静默丢弃。
- 主机侧不经 `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.5",
"version": "0.1.6",
"packageManager": "pnpm@9.15.9",
"engines": {
"node": ">=26.3.1"
Expand Down
35 changes: 11 additions & 24 deletions src/client/PauseBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* 目标交互:
* - 开关按钮,本会话总开关。
* - 开关开启期间,在 composer 主输入框按裸 Enter 一律交主机裁决:有暂停点就继续并带插话,没有但在跑就排进下一个暂停点,没有且空闲则由主机 followup,失败内容暂存而不退回输入框。
* - 开关开启期间,在 composer 主输入框按裸 Enter 一律交主机裁决:有暂停点就继续并带插话,没有暂停点则立即 steer,失败内容暂存而不退回输入框。
* - 开关关闭时不拦截任何按键,完全透明。
*
* dsh 的 composer Enter 由 InputBar 私有 keymap 处理,第三方没有正式 hook 点,故本组件在开关开启期间对所属 composer 挂 document 级 capture-phase keydown,抢在 Lexical 之前拦截裸 Enter。
Expand Down Expand Up @@ -41,10 +41,6 @@ export interface PauseControlProps extends PauseBarInjected {
interface PauseStatus {
enabled?: boolean
paused?: boolean
/** agent 是否在跑;决定没有暂停点时插话该排队还是直接 followup。 */
busy?: boolean
/** 已排队、等下一个暂停点或 idle 补发的插话条数。 */
pendingMessageCount?: number
}

async function fetchStatus(rpc: PauseBarInjected['rpc'], sessionId: string): Promise<PauseStatus | undefined> {
Expand All @@ -61,10 +57,8 @@ async function fetchStatus(rpc: PauseBarInjected['rpc'], sessionId: string): Pro
/** 一次放行的结果;error 存在表示主机拒绝,暂停点未放行。 */
interface ReleaseOutcome {
readonly released: boolean
/** 主机裁决时该会话的 agent 是否在跑。 */
readonly busy: boolean
/** 主机已接住这条插话,草稿可以清空。 */
readonly queued: boolean
/** 主机已把插话送进 dsh,草稿可以清空。 */
readonly delivered: boolean
readonly error?: string
}

Expand All @@ -76,11 +70,11 @@ async function callRelease(
): Promise<ReleaseOutcome> {
try {
const result = await rpc.call('/dsh-pause', 'release', { sessionId, text, images })
if (!result.ok) return { released: false, busy: false, queued: false, error: result.error?.message ?? '放行失败' }
const value = result.value as { released?: boolean; busy?: boolean; queued?: boolean } | undefined
return { released: value?.released === true, busy: value?.busy === true, queued: value?.queued === true }
if (!result.ok) return { released: false, delivered: false, error: result.error?.message ?? '放行失败' }
const value = result.value as { released?: boolean; delivered?: boolean } | undefined
return { released: value?.released === true, delivered: value?.delivered === true }
} catch (error) {
return { released: false, busy: false, queued: false, error: error instanceof Error ? error.message : String(error) }
return { released: false, delivered: false, error: error instanceof Error ? error.message : String(error) }
}
}

Expand All @@ -93,10 +87,10 @@ async function callSetEnabled(rpc: PauseBarInjected['rpc'], sessionId: string, e
}

/**
* 把一次回车交给主机裁决:有暂停点就放行,没有但在跑就排进下一个暂停点,没有且空闲且有 agent 引用就由主机 followup。
* 把一次回车交给主机裁决:有暂停点就放行,没有暂停点则由主机立即 steer。
* 按下回车时文字与图片已经离开输入框,所以任何失败都不回填,改为暂存到下次发送时优先带出。
*/
async function releaseOrQueue(
async function releaseInterjection(
rpc: PauseBarInjected['rpc'],
sessionId: string,
draft: string,
Expand Down Expand Up @@ -128,7 +122,7 @@ async function releaseOrQueue(
onError(outcome.error + ',已保留,下次发送时一并送出')
return
}
if (outcome.released || outcome.queued) {
if (outcome.released || outcome.delivered) {
onDone()
return
}
Expand Down Expand Up @@ -167,7 +161,6 @@ export const PauseControl = memo(function PauseControl({
}: PauseControlProps) {
const [enabled, setEnabled] = useState(false)
const [paused, setPaused] = useState(false)
const [pendingCount, setPendingCount] = useState(0)
const [error, setError] = useState<string | undefined>(undefined)
const rootRef = useRef<HTMLDivElement | null>(null)
// composer 卡标记缺失时只报一次,避免每次按键都刷控制台。
Expand Down Expand Up @@ -229,7 +222,7 @@ export const PauseControl = memo(function PauseControl({
draftRef.current = ''
inputActions?.setDraft('')
for (const id of ids) inputActions?.removeAttachment(id)
const pendingRelease = releaseOrQueue(
const pendingRelease = releaseInterjection(
rpc,
sessionId,
sent,
Expand Down Expand Up @@ -267,7 +260,6 @@ export const PauseControl = memo(function PauseControl({
if (disposed || s === undefined) return
setEnabled(s.enabled ?? false)
setPaused(s.paused ?? false)
setPendingCount(s.pendingMessageCount ?? 0)
}
void poll()
timer = setInterval(() => void poll(), 400)
Expand Down Expand Up @@ -364,11 +356,6 @@ export const PauseControl = memo(function PauseControl({
已暂停·回车或点此继续
</button>
)}
{pendingCount > 0 && (
<span style={{ color: 'var(--dsw-alias-label-secondary)', fontSize: '12px' }}>
{pendingCount} 条已排入下一步
</span>
)}
{error !== undefined && (
<span style={{ color: 'var(--dsw-alias-label-error, #d9534f)', fontSize: '12px' }}>
{error}
Expand Down
2 changes: 1 addition & 1 deletion src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* dsh-pause 浏览器半身:暂停控件挂在 composer 工具行右侧 conversation.input.right。
*
* 目标:开关开启期间在 composer 主输入框里按 Enter 即继续。有暂停点则放行并带上草稿,没有但在跑则排进下一个暂停点,没有且空闲则由主机 followup;主机发不出去时交回客户端暂存,失败内容不退回输入框。
* 目标:开关开启期间在 composer 主输入框里按 Enter 即继续。有暂停点则放行并带上草稿,没有暂停点则立即 steer;主机发不出去时交回客户端暂存,失败内容不退回输入框。
* 因为 dsh 的 composer Enter 由 InputBar 私有 keymap 处理,第三方没有正式 hook 点,所以本插件用 DOM capture 在开关开启期间常驻拦截裸 Enter,阻止默认送信并改走主机裁决。
* 控件本身在 composer 卡内渲染,因此暂停条可见且可点。capture 监听只在开关开启期间激活,关闭时不拦截任何按键。
*
Expand Down
50 changes: 3 additions & 47 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* 放行时可带补充文字,作为一条 user 消息并入本 step 的 messages,让模型在紧接着的请求里看到它。
*
* 暂停点只卡在请求真正发出之前,因此不打断任何在途 API 调用。
* 人类在暂停点挂上之前按回车时,插话先排队,下一个暂停点不再等回车而是直接带头并入本步,即预放行;
* turn 若在暂停点出现之前结束,队列由主机作为新的 followup 发出去;followup 抛错时放回队列等下一次重试,任何失败都不静默丢弃。
* 暂停点还没挂上时按回车发出的插话一律立即交给 agent.steer(),由 dsh 在最近一步认领;空闲时 steer 自己开新 turn。
* 插件不缓存插话,也不做补发。
* driver 组装好的上下文原样放行,不插入、不删除、不改写任何模型可见的痕迹,模型无感知暂停。
*
* @module dsh-pause
Expand All @@ -20,12 +20,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
export interface PauseSessionState {
/** 用户开关:该会话是否启用暂停。false 时所有请求直接放行。 */
enabled: boolean
/** 该会话的 agent 是否在跑。idle 时暂停点不会再来,队列要当场发出去。 */
busy: boolean
/** 当前挂着的暂停点,放行时若带文字则一并返回。 */
pausePoint: PausePoint | undefined
/** 暂停点还没挂上时人类已按回车发出的插话,等下一个暂停点并入或 turn 结束时 followup。 */
pendingMessages: UserMessage[]
}

/** 一个暂停点:人类选择继续时用它放行,可携带一条已构造好的补充用户消息。 */
Expand Down Expand Up @@ -69,7 +65,7 @@ export function textToMessage(text: string): UserMessage | undefined {

/** 会话状态在安装器里的默认初始值。 */
export function initialSessionState(): PauseSessionState {
return { enabled: false, busy: false, pausePoint: undefined, pendingMessages: [] }
return { enabled: false, pausePoint: undefined }
}

/** 默认每会话开关。 */
Expand Down Expand Up @@ -107,44 +103,6 @@ export class PauseRegistry {
return this.stateFor(sessionId).pausePoint !== undefined
}

/** 该会话的 agent 是否在跑。 */
isBusy(sessionId: string): boolean {
return this.stateFor(sessionId).busy
}

/** 待插话队列长度。 */
pendingMessageCount(sessionId: string): number {
return this.stateFor(sessionId).pendingMessages.length
}

/** 更新 agent 的运行状态。转为 idle 时暂停点不会再挂上,由安装器负责把队列当场发出去。 */
setBusy(sessionId: string, busy: boolean): void {
const s = this.stateFor(sessionId)
if (s.busy === busy) return
this.states.set(sessionId, { ...s, busy })
}

/** 把一条还没送达的插话排进队列,等下一个暂停点并入,或等 turn 结束时 followup。 */
queueMessage(sessionId: string, message: UserMessage): void {
const s = this.stateFor(sessionId)
this.states.set(sessionId, { ...s, pendingMessages: [...s.pendingMessages, message] })
}

/** 取走全部待插话。 */
takePendingMessages(sessionId: string): UserMessage[] {
const s = this.stateFor(sessionId)
if (s.pendingMessages.length === 0) return []
this.states.set(sessionId, { ...s, pendingMessages: [] })
return s.pendingMessages
}

/** 把没能并入的待插话放回队首,用于该步被 reject 或补发失败的情形。 */
requeueMessages(sessionId: string, messages: readonly UserMessage[]): void {
if (messages.length === 0) return
const s = this.stateFor(sessionId)
this.states.set(sessionId, { ...s, pendingMessages: [...messages, ...s.pendingMessages] })
}

/**
* 放行暂停点。若该会话当前正挂着暂停点,用给定补充消息放行并返回 true;否则返回 false。
* 放行后状态复位为未暂停,因为这一步已经放行。
Expand All @@ -161,10 +119,8 @@ export class PauseRegistry {
/** 挂起一个暂停点,并把放行入口写入状态。 */
armPausePoint(sessionId: string, resolvers: Pick<PausePoint, 'resolve' | 'reject'>): void {
const s = this.stateFor(sessionId)
// pre-step 一定发生在 running 内,插件热加载可能错过 idle→running 推送,这里顺手补上。
this.states.set(sessionId, {
...s,
busy: true,
pausePoint: { resolve: resolvers.resolve, reject: resolvers.reject },
})
}
Expand Down
Loading
Loading