diff --git a/README.md b/README.md index a13e9eb2..b7863ebe 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Connect [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) to t Pages become structured text with a numbered inventory of interactive elements. Tools address elements by number or selector; `browser_screenshot` captures the controlled tab's viewport after approval. The side panel also accepts PNG, JPEG, WebP, and GIF attachments when the host advertises image support. > [!IMPORTANT] -> The workspace is pinned to DSH 0.2.1-rc.5. +> The workspace is pinned to DSH 0.2.2-rc.2. ## Quick install @@ -64,12 +64,13 @@ The paired Playwright / extension duration ratio was **1.24** (95% CI **1.16–1 | Wait for stability | `browser_wait` | Page-load and render-settle detection | | Send images | `session.prompt` / `session.attachment` | Host-capability-gated image drafts, image-only prompts, and durable history previews | | Quote a selection | side panel composer | Text you highlight in the page appears in the composer and is sent with your next message as fenced, attributed page content | +| Continue generating | side panel | Continue an interrupted response when the host offers it | Clicks, text entry, and scrolling run in the selected document, including cross-origin iframes. Chrome uses its debugger for native keyboard events, caret insertion, system paste, and file uploads. Screenshots require an active tab in a focused window; `browser_follow_tab { activate: true }` brings the controlled page to the front. ## Detailed installation and usage -Requirements: Node.js `>=24`, Corepack/pnpm, and Chrome 125+ or Firefox 140+. Windows additionally needs Windows PowerShell 5.1, which ships with Windows, or PowerShell 7+. +Requirements: Node.js `>=24.9.0`, Corepack/pnpm, and Chrome 125+ or Firefox 140+. Windows additionally needs Windows PowerShell 5.1, which ships with Windows, or PowerShell 7+. ### Install or update @@ -125,7 +126,7 @@ cd ~/.dsh/dsh-browser && pnpm start From a source checkout, run `pnpm start` in the repository root. The exact supported public runtime is: ```sh -npx @deepseek-ai/dsh@0.2.1-rc.5 web +npx @deepseek-ai/dsh@0.2.2-rc.2 web ``` Local Chrome use requires no configuration; Firefox requires the local bridge token described above. Open a page, click the DeepSeek whale icon, and wait for **Connected**. Existing HTTP(S) tabs are instrumented on the first action. On browser-protected pages and extension stores, the model can read tab metadata and use browser-level HTTP(S) navigation, back, forward, and reload, but it cannot inspect or operate the protected page DOM. diff --git a/README.zh.md b/README.zh.md index eaf7be6b..005147a4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,7 +11,7 @@ 页面会转换为结构化文本和带编号的交互元素清单。工具通过编号或选择器定位元素;`browser_screenshot` 经批准后截取受控标签页的可视区域。宿主声明图片能力时,侧栏也可发送 PNG、JPEG、WebP 和 GIF 附件。 > [!IMPORTANT] -> 工作区固定使用 DSH 0.2.1-rc.5。 +> 工作区固定使用 DSH 0.2.2-rc.2。 ## 快速安装 @@ -64,12 +64,13 @@ Playwright / 扩展的配对耗时比为 **1.24**(95% CI **1.16–1.34**): | 等待稳定 | `browser_wait` | 页面加载与渲染稳定检测 | | 发送图片 | `session.prompt` / `session.attachment` | 按宿主能力启用图片草稿、纯图片消息和持久历史预览 | | 引用选中内容 | 侧栏输入框 | 在页面里划选的文字会出现在输入框,随下一条消息一起发送,并带上来源与不可信内容边界 | +| 继续生成 | 侧栏 | 请求失败后,点击出现的“继续生成”按钮继续对话 | 点击、文本输入和滚动操作在指定文档中执行,支持跨域 iframe。Chrome 通过调试器执行原生键盘事件、光标位置插入、系统粘贴和文件上传。截图要求受控标签页处于活动状态且窗口聚焦,可以使用 `browser_follow_tab { activate: true }` 将页面切到前台。 ## 详细安装与使用 -前置要求:Node.js `>=24`、Corepack/pnpm,以及 Chrome 125+ 或 Firefox 140+。Windows 还需要系统自带的 Windows PowerShell 5.1,或 PowerShell 7+。 +前置要求:Node.js `>=24.9.0`、Corepack/pnpm,以及 Chrome 125+ 或 Firefox 140+。Windows 还需要系统自带的 Windows PowerShell 5.1,或 PowerShell 7+。 ### 安装或更新 @@ -125,7 +126,7 @@ cd ~/.dsh/dsh-browser && pnpm start 使用源码 checkout 时,请在仓库根目录运行 `pnpm start`。受支持的精确公开版本为: ```sh -npx @deepseek-ai/dsh@0.2.1-rc.5 web +npx @deepseek-ai/dsh@0.2.2-rc.2 web ``` Chrome 本机使用无需配置;Firefox 需要填写上述本地桥 token。打开页面,点击 DeepSeek 鲸鱼图标,等待侧边栏显示**已连接**。已有 HTTP(S) 标签页会在第一次操作时自动加载。在浏览器受保护页面和扩展商店中,模型可以读取标签页元数据,并通过浏览器级能力导航到 HTTP(S)、后退、前进和刷新,但不能读取或操作受保护页面的 DOM。 diff --git a/extensions/dsh-browser/src/background/transient-events.ts b/extensions/dsh-browser/src/background/transient-events.ts index 47f11cdd..9a061b3f 100644 --- a/extensions/dsh-browser/src/background/transient-events.ts +++ b/extensions/dsh-browser/src/background/transient-events.ts @@ -8,7 +8,7 @@ type EventFrame = Extract */ export class TransientEventCache { private readonly questions = new Map() - private readonly running = new Map() + private readonly states = new Map() ingest(frame: ServerFrame): void { if (frame.t !== 'event' || !isRecord(frame.frame.payload)) return @@ -26,21 +26,22 @@ export class TransientEventCache { } return } + if (frame.frame.method === 'session/state') { + this.states.set(sessionId, frame) + return + } if (frame.frame.method !== 'session/event' || !isRecord(payload.event)) return - if (payload.event.type === 'turn/start') { - this.running.set(sessionId, frame) - } else if (payload.event.type === 'turn/end') { - this.running.delete(sessionId) + if (payload.event.type === 'turn/end') { this.deleteSessionQuestions(sessionId) } } replay(): EventFrame[] { - return [...this.running.values(), ...this.questions.values()] + return [...this.states.values(), ...this.questions.values()] } clear(): void { - this.running.clear() + this.states.clear() this.questions.clear() } diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index 14be854b..bf72571e 100644 --- a/extensions/dsh-browser/src/panel/App.tsx +++ b/extensions/dsh-browser/src/panel/App.tsx @@ -66,6 +66,7 @@ import { } from './attachments.ts' import { imageErrorMessage } from './image-errors.ts' import { AssistantStreamView } from './assistant-stream.ts' +import { SessionObservations, type ProjectionChange, type SessionState } from './session-observations.ts' import { canAcceptImageSelection, emptyComposerDraft, @@ -764,6 +765,7 @@ export function App(): React.JSX.Element { const [addingImages, setAddingImages] = useState(false) const [busy, setBusy] = useState(false) const [working, setWorking] = useState(false) + const [continuable, setContinuable] = useState(false) const [stopping, setStopping] = useState(false) const [showSettings, setShowSettings] = useState(false) const [uiScale, setUiScale] = useState(DEFAULT_UI_SCALE) @@ -815,11 +817,9 @@ export function App(): React.JSX.Element { const addingImagesRef = useRef(false) const sendingRef = useRef(false) const fileInputRef = useRef(null) - const imageProjectionRef = useRef<{ - sessionId: string | null - seq: number - limits: ImageAttachmentLimits | null - }>({ sessionId: null, seq: Number.NEGATIVE_INFINITY, limits: null }) + const observationsRef = useRef(new SessionObservations()) + const modelRequestRef = useRef(0) + const permissionRequestRef = useRef(0) const sessionChangingRef = useRef(false) const sessionTransitionRef = useRef(0) const sessionInitializationRef = useRef(false) @@ -1029,6 +1029,7 @@ export function App(): React.JSX.Element { sessionRef.current = null setResumeHint({ ready: false, sessionId: null }) sessionRuntimeRef.current.clear() + observationsRef.current.clear() assistantStreamsRef.current.clear() followSnapshotsRef.current.clear() pendingHistoriesRef.current.clear() @@ -1038,7 +1039,7 @@ export function App(): React.JSX.Element { setRows([]) setDraft((current) => ({ ...current, images: [] })) setImageLimits(null) - imageProjectionRef.current = { sessionId: null, seq: Number.NEGATIVE_INFINITY, limits: null } + setContinuable(false) setSessionModels(null) setModelSelecting(false) setShowModelPicker(false) @@ -1179,48 +1180,31 @@ export function App(): React.JSX.Element { return () => { document.removeEventListener('pointerdown', onPointerDown, true) } }, [showSlashMenu]) - // A missed `turn/end` (dropped frame, panel opened mid-turn, sleep) must not - // disable the composer forever: re-read the Host's running flag on return. - useEffect(() => { - const onVisibilityChange = (): void => { - if (document.visibilityState !== 'visible') return - const sessionId = sessionRef.current - if (sessionId === null) return - void loadSessionCatalog().then(({ items }) => { - if (sessionRef.current !== sessionId) return - const entry = items.find((item) => item.sessionId === sessionId) - if (entry !== undefined) setWorking(entry.running) - }).catch(() => {}) - } - document.addEventListener('visibilitychange', onVisibilityChange) - return () => { document.removeEventListener('visibilitychange', onVisibilityChange) } - }, []) - - function applyImageProjection(sessionId: string, seq: number, value: unknown): void { - if (sessionRef.current !== sessionId || !Number.isSafeInteger(seq)) return - const previous = imageProjectionRef.current - if (previous.sessionId === sessionId && seq <= previous.seq) return - const limits = parseImageAttachmentLimits(value) - imageProjectionRef.current = { sessionId, seq, limits } - setImageLimits(limits) - } - - function applyHistoryImageProjection(sessionId: string, projections: HistoryPage['projections']): void { - if (projections === undefined) { - applyImageProjection(sessionId, -1, undefined) - return + function applyObservedSession(sessionId: string): void { + if (sessionRef.current !== sessionId) return + const observations = observationsRef.current + const observed = observations.state(sessionId) + if (observed !== undefined) { + setWorking(observed.running) + if (!observed.running) { + stoppingRef.current = false + setStopping(false) + } } - const values = projections.values - applyImageProjection( - sessionId, - projections.asOfSeq, - typeof values === 'object' && values !== null ? values.imageLimits : undefined, - ) + setImageLimits(parseImageAttachmentLimits(observations.value(sessionId, 'imageLimits'))) + setContinuable(observations.canContinue(sessionId)) } /** Live frame handling: session events append rows; turn/end reconciles with history. */ async function onFrame(frame: ServerFrame): Promise { if (frame.t !== 'event') return + if (frame.frame.method === 'session/state') { + const { sessionId, ...observed } = frame.frame.payload as SessionState & { sessionId: string } + observationsRef.current.setState(sessionId, observed) + sessionRuntimeRef.current.setRunning(sessionId, observed.running) + applyObservedSession(sessionId) + return + } if (frame.frame.method === 'session/assistant-stream') { const payload = frame.frame.payload as { sessionId?: unknown; snapshotId?: unknown; frame?: unknown } | undefined if (typeof payload?.sessionId !== 'string' || typeof payload.frame !== 'object' || payload.frame === null) return @@ -1264,15 +1248,19 @@ export function App(): React.JSX.Element { && typeof frame.frame.payload === 'object' && frame.frame.payload !== null) { const projection = frame.frame.payload as { - sessionId?: unknown - key?: unknown - value?: unknown - seq?: unknown + sessionId: string + changes: ProjectionChange[] + seq: number } - if (typeof projection.sessionId === 'string' - && projection.key === 'imageLimits' - && typeof projection.seq === 'number') { - applyImageProjection(projection.sessionId, projection.seq, projection.value) + observationsRef.current.change(projection.sessionId, projection.seq, projection.changes) + applyObservedSession(projection.sessionId) + if (projection.sessionId === sessionRef.current) { + if (projection.changes.some(change => change.key === 'modelSelection')) { + await refreshSessionModels(projection.sessionId) + } + if (projection.changes.some(change => change.key === 'permissions')) { + await refreshSessionPermissions(projection.sessionId) + } } return } @@ -1295,29 +1283,20 @@ export function App(): React.JSX.Element { return } if (payload.event.type === 'turn/start') { - sessionRuntimeRef.current.startTurn(payload.sessionId) if (payload.sessionId !== sessionRef.current) return stoppingRef.current = false setStopping(false) - setWorking(true) return } if (payload.event.type === 'turn/end') { - sessionRuntimeRef.current.finishTurn(payload.sessionId) + sessionRuntimeRef.current.clearQuestions(payload.sessionId) if (payload.sessionId !== sessionRef.current) return stoppingRef.current = false setStopping(false) - setWorking(false) clearQuestions() await refreshHistory(payload.sessionId) return } - if (payload.event.type === 'permission/preset') { - // Another panel (or the Web GUI) may have switched this Session's preset; - // the projection is the authority, so re-read it for every own Session. - if (payload.sessionId === sessionRef.current) void refreshSessionPermissions(payload.sessionId) - return - } if (payload.sessionId !== sessionRef.current) return const row = rowFromEvent(payload.event) if (row !== null) { @@ -1325,7 +1304,6 @@ export function App(): React.JSX.Element { return } if (payload.event.type === 'tool/call') { - setWorking(true) const summary = toolSummary(payload.event.data?.name ?? 'tool', payload.event.data?.arguments, locale) setRows((prev) => appendLiveRow(prev, 'tool', summary, nextSeq())) return @@ -1424,7 +1402,8 @@ export function App(): React.JSX.Element { followed.applied = true followed.suffix = [] } - applyHistoryImageProjection(id, history.projections) + observationsRef.current.baseline(id, history.projections ?? { asOfSeq: -1, values: {} }) + applyObservedSession(id) const historyTitle = latestSessionTitle(events) if (historyTitle !== undefined) setSessionTitle(historyTitle) setRows(mergeHistoryRows(events, nextSeq, locale)) @@ -1464,6 +1443,7 @@ export function App(): React.JSX.Element { /** Pull the advisory model directory for the active session into the composer menu. */ async function refreshSessionModels(sessionId: string | null = sessionRef.current): Promise { + const request = ++modelRequestRef.current if (sessionId === null) { setSessionModels(null) setShowModelPicker(false) @@ -1480,7 +1460,7 @@ export function App(): React.JSX.Element { models: Array<{ id: string; name: string; reasoning?: { efforts?: Array<{ id?: unknown; name?: unknown }> } }> }> }>('session.models', { sessionId }) - if (sessionRef.current !== sessionId) return + if (sessionRef.current !== sessionId || request !== modelRequestRef.current) return const sessionOptions = (result.groups ?? []).flatMap((group) => group.models.map((model) => ({ provider: group.id, providerName: group.name, @@ -1499,7 +1479,7 @@ export function App(): React.JSX.Element { options, }) } catch (cause) { - if (sessionRef.current !== sessionId) return + if (sessionRef.current !== sessionId || request !== modelRequestRef.current) return // Host-wide settings still list configured relays before a session exists. // An empty fallback is reported instead of a silently grey chip. setSessionModels(fromSettings === null @@ -1625,6 +1605,7 @@ export function App(): React.JSX.Element { /** Pull the Session's permission directory (catalog plus its current preset). */ async function refreshSessionPermissions(sessionId: string | null = sessionRef.current): Promise { + const request = ++permissionRequestRef.current if (sessionId === null) { setSessionPermissions(null) setShowPermissionPicker(false) @@ -1632,12 +1613,12 @@ export function App(): React.JSX.Element { } try { const result = await api.rpc('session.permissions', { sessionId }) - if (sessionRef.current !== sessionId) return + if (sessionRef.current !== sessionId || request !== permissionRequestRef.current) return const parsed = parseSessionPermissions(result) setSessionPermissions(parsed) if (parsed === null) setPermissionError(copy.app.permissionUnavailable) } catch (cause) { - if (sessionRef.current !== sessionId) return + if (sessionRef.current !== sessionId || request !== permissionRequestRef.current) return setSessionPermissions(null) setPermissionError(isUnavailableMethod(cause) ? copy.app.permissionUnavailable : describeCatalogFailure(cause)) } @@ -1979,7 +1960,7 @@ export function App(): React.JSX.Element { void api.clearSelection().catch(() => {}) } setImageLimits(null) - imageProjectionRef.current = { sessionId: null, seq: Number.NEGATIVE_INFINITY, limits: null } + setContinuable(false) setWorking(nextWorking) setStopping(false) stoppingRef.current = false @@ -2134,6 +2115,28 @@ export function App(): React.JSX.Element { } } + async function continueGeneration(): Promise { + const id = sessionRef.current + if (id === null || sendingRef.current || sessionChangingRef.current + || !observationsRef.current.canContinue(id)) return + sendingRef.current = true + setBusy(true) + setWorking(true) + setContinuable(false) + setError(null) + try { + await api.rpc('session.prompt', { sessionId: id, mode: 'queue', content: [] }) + } catch (cause) { + if (sessionRef.current === id) { + setError(describeCatalogFailure(cause)) + applyObservedSession(id) + } + } finally { + setBusy(false) + sendingRef.current = false + } + } + /** Cancel the active turn while keeping the sidebar session available. */ async function stopTurn(): Promise { const id = sessionRef.current @@ -2143,9 +2146,12 @@ export function App(): React.JSX.Element { setError(null) try { await api.rpc('session.cancel', { sessionId: id }) + const observed = await api.rpc('session.state', { sessionId: id }) + observationsRef.current.setState(id, observed) + sessionRuntimeRef.current.setRunning(id, observed.running) + sessionRuntimeRef.current.clearQuestions(id) if (sessionRef.current === id) { - sessionRuntimeRef.current.finishTurn(id) - setWorking(false) + applyObservedSession(id) clearQuestions() } } catch (cause) { @@ -2994,6 +3000,12 @@ export function App(): React.JSX.Element { {rows[rows.length - 1]?.kind === 'tool' ? copy.app.organizingResults : copy.app.thinking} )} + {continuable && !working && question === null && ( + + )} {!atBottom && (