diff --git a/README.ja-JP.md b/README.ja-JP.md index b85185a..73223f8 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -54,7 +54,7 @@ OpenGUIはDSH `0.1.0-rc.7`、`0.1.0-rc.8`、`0.1.1-rc.1`、`0.1.1-rc.2`を正式 @OpenGUI Open Settings and report the Android version ``` -このプラグインは、OpenGUIバックエンド一式を必要とせず、DSHにスマートフォンとブラウザの操作機能を追加します。[ユースケース](./deepseek-harness-plugin/docs/use-cases.md)を確認するか、[v0.1.13リリースパッケージ](https://github.com/Core-Mate/OpenGUI/releases/tag/dsh-coremate-mobile-v0.1.13)をダウンロードできます。 +このプラグインは、OpenGUIバックエンド一式を必要とせず、DSHにスマートフォンとブラウザの操作機能を追加します。現在のソース実装はDSH Sessionごとに1つのルートタスクを受け付け、デバイスが競合しない別のTabも受け付けます。管理対象ブラウザは引き続きグローバルに直列実行されます。これはソースの動作説明であり、リリース済みであることを示すものではありません。[ユースケース](./deepseek-harness-plugin/docs/use-cases.md)を確認するか、[v0.1.13リリースパッケージ](https://github.com/Core-Mate/OpenGUI/releases/tag/dsh-coremate-mobile-v0.1.13)をダウンロードできます。 主なユースケース: diff --git a/README.md b/README.md index 7293cd3..c6148bd 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ After installation, add or select a DSH workspace, connect and select an authori @OpenGUI Open Settings and report the Android version ``` -The plugin adds phone and browser operation to DSH without requiring the full OpenGUI backend stack. See more [use cases](./deepseek-harness-plugin/docs/use-cases.md) or download the [v0.1.13 release package](https://github.com/Core-Mate/OpenGUI/releases/tag/dsh-coremate-mobile-v0.1.13). +The plugin adds phone and browser operation to DSH without requiring the full OpenGUI backend stack. The current source implementation admits one OpenGUI task per DSH session and separate tabs on non-conflicting phone sets; the managed browser remains globally serial. This source behavior is not a release claim. See more [use cases](./deepseek-harness-plugin/docs/use-cases.md) or download the [v0.1.13 release package](https://github.com/Core-Mate/OpenGUI/releases/tag/dsh-coremate-mobile-v0.1.13). Good fits include: diff --git a/README.zh-CN.md b/README.zh-CN.md index ff9252b..695be5c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -54,7 +54,7 @@ OpenGUI 正式支持 DSH `0.1.0-rc.7`、`0.1.0-rc.8`、`0.1.1-rc.1` 和 `0.1.1-r @OpenGUI 打开设置并报告 Android 版本 ``` -插件可以直接为 DSH 增加手机与浏览器操作能力,不需要部署完整的 OpenGUI 后端。你还可以查看更多[使用场景](./deepseek-harness-plugin/docs/use-cases.zh.md),或下载 [v0.1.13 安装包](https://github.com/Core-Mate/OpenGUI/releases/tag/dsh-coremate-mobile-v0.1.13)。 +插件可以直接为 DSH 增加手机与浏览器操作能力,不需要部署完整的 OpenGUI 后端。当前源码按 DSH Session 接纳根任务,允许不同 Tab 使用不冲突的手机集合;托管浏览器仍保持全局串行。这是源码行为说明,不代表已发布。你还可以查看更多[使用场景](./deepseek-harness-plugin/docs/use-cases.zh.md),或下载 [v0.1.13 安装包](https://github.com/Core-Mate/OpenGUI/releases/tag/dsh-coremate-mobile-v0.1.13)。 适合的使用场景包括: diff --git a/deepseek-harness-plugin/README.i18n.yaml b/deepseek-harness-plugin/README.i18n.yaml index 4cbabc2..7e9a503 100644 --- a/deepseek-harness-plugin/README.i18n.yaml +++ b/deepseek-harness-plugin/README.i18n.yaml @@ -1,3 +1,3 @@ # Bilingual-pair consistency record. Both languages carry equal authority. -README.md: 1e6181df4e9a161e6eb20e05f4d3425456b83c01 -README.zh.md: 857edcf990cda065eb3dccad83abc883418d6d9d +README.md: 9c72c82b532e590e6dc0b650e59da32759e40ff9 +README.zh.md: 5f651c223e3e192fcabc619e46e5cd0842b9ad67 diff --git a/deepseek-harness-plugin/README.md b/deepseek-harness-plugin/README.md index 63f8795..9c72c82 100644 --- a/deepseek-harness-plugin/README.md +++ b/deepseek-harness-plugin/README.md @@ -208,25 +208,25 @@ Keep the phone unlocked and authorized for USB debugging, then send: /opengui Open Settings and report the Android version ``` -The native **OpenGUI** tab owns phone selection and status. One authorized phone is selected automatically. With multiple phones, check any subset in that workbench before sending `/opengui` or `@OpenGUI`. A non-empty task resolves the current-model choice or dedicated fallback setup before it waits for a phone, but it sends no provider request until the selected-device snapshot is ready. Device selection remains editable while waiting, then locks after detection until the whole batch finishes. +The native **OpenGUI** tab owns phone selection and status for its DSH session. One authorized phone is selected automatically. With multiple phones, check any subset in that workbench before sending `/opengui` or `@OpenGUI`. A non-empty task resolves the current-model choice or dedicated fallback setup before it waits for a phone, but it sends no provider request until the selected-device snapshot is ready. OpenGUI acquires the entire selected set atomically when execution starts; if another session owns any selected phone, the task reports that phone as busy without retaining a partial lease. Selection remains editable while waiting, then locks only for its owning session until the whole batch finishes. The native **OpenGUI** tab is a full-width device wall. It renders every visible phone in Host order and appends one connection guide, without reserving empty slots. A connected phone shows an uncropped JPEG preview immediately while OpenGUI prepares its low-latency H.264 view in the background, then switches automatically. Unsupported browsers and stream failures keep the screenshot preview available with a retry action. Each phone can also be opened in an optional independent mirror window. After startup, the plugin checks the public `Core-Mate/OpenGUI` GitHub Releases list in the background, at most once every six hours. It ignores Android APK, draft, and prerelease entries and considers only stable `dsh-coremate-mobile-v*` releases with a version-matched `.tgz` and `.sha256`. The OpenGUI tab offers **Update** only for a newer verified package. Installation is blocked while an OpenGUI task is active, keeps the current version intact on failure, and takes effect after Harness restarts. -Selecting `@OpenGUI` opens the native input menu with free-form, QA, operations, and game-assistant choices. A scene only fills the composer and never submits automatically. A non-empty OpenGUI submission releases the composer immediately while the task continues in its owner session. Starting another DSH session opens a genuinely blank conversation; the original task keeps running there, with a compact link back from the new session. +Selecting `@OpenGUI` opens the native input menu with free-form, QA, operations, and game-assistant choices. A scene only fills the composer and never submits automatically. A non-empty OpenGUI submission releases the composer immediately while the task continues in its owner session. Switching, hiding, or unmounting a conversation view does not stop that task. Each tab renders only its own OpenGUI status and errors; deleting the owning session cancels its task and clears its device preference. -`/opengui` starts a restricted routing child that can call only `phone_agent`, `browser_agent`, or both sequentially when the task truly spans both targets. Phone work still creates one fixed-device child per selected phone. Only one OpenGUI task runs at a time, including tasks started through the legacy `/coremate` alias. +`/opengui` starts a restricted routing child that can call only `phone_agent`, `browser_agent`, or both sequentially when the task truly spans both targets. Phone work still creates one fixed-device child per selected phone. Each DSH session admits only one root OpenGUI task, including tasks started through the legacy `/coremate` alias; different sessions may run concurrently when their phone leases do not conflict. Task stop and cleanup use the exact session, task, and attempt identity, so a delayed request from an older task cannot affect its replacement. While it runs, the outer `phone_agent` / `browser_agent` card streams nested `phone_control` / `browser_control` calls and their visible results. Hidden reasoning, system prompts, and model configuration are not projected into the parent conversation. -While an OpenGUI task is active, a square **Stop OpenGUI operation** button appears at the right side of the composer. It cancels routing, phone or browser children, in-flight ADB work, browser downloads, and browser operations. Manually opened mirrors remain independent. +While the current session has an active OpenGUI task, a square **Stop OpenGUI operation** button appears at the right side of its composer. It cancels only that exact task, including routing, phone or browser children, in-flight ADB work, browser downloads, and browser operations. Tasks in other tabs and manually opened mirrors remain independent. After a successful direct OpenGUI task, one turn-tail card may show 2–3 validated follow-up prompts. Clicking one only fills an `@OpenGUI` draft. The same card links to the [use-case guide](docs/use-cases.md) and OpenGUI GitHub. Failed, cancelled, empty, configuration-only, and ordinary tasks do not show it. ## On-demand browser control -The plugin checks for its managed Chromium only when a task actually calls `browser_agent`. If absent, the **OpenGUI** tab shows the pinned version, download size, approval actions, and installation progress; Chat only directs the user to that tab. After approval, the same task downloads the archive, verifies its SHA-256, installs it atomically, and opens a visible browser. Declining or pressing Stop ends that task. Later tasks reuse `$DSH_HOME/cache/coremate-mobile/browser` and its isolated profile. +The plugin checks for its managed Chromium only when a task actually calls `browser_agent`. If absent, only the owning session's **OpenGUI** tab shows the pinned version, download size, approval actions, and installation progress; Chat only directs that session to the tab. After approval, the same task downloads the archive, verifies its SHA-256, installs it atomically, and opens a visible browser. Declining or pressing Stop ends that exact task. Later tasks reuse `$DSH_HOME/cache/coremate-mobile/browser` and its isolated profile. The managed browser remains one global, precisely owned resource: a concurrent browser task reports busy in its own session, while unrelated phone tasks may continue. `browser_control` is intentionally limited to HTTP/HTTPS navigation, observation, clicking, Unicode text insertion, selected keys, scrolling, back, reload, and bounded waits. It does not expose arbitrary CDP or JavaScript execution. The binary lifecycle and control implementation are wholly contained in this plugin. diff --git a/deepseek-harness-plugin/README.zh.md b/deepseek-harness-plugin/README.zh.md index ccefb2e..5f651c2 100644 --- a/deepseek-harness-plugin/README.zh.md +++ b/deepseek-harness-plugin/README.zh.md @@ -210,25 +210,25 @@ Usage: /opengui /opengui 打开设置并报告 Android 版本 ``` -手机选择和状态统一放在原生 **OpenGUI** Tab。只有一台已授权手机时会自动选中;有多台时,可在工作台中勾选任意一台或多台后再发送 `/opengui` 或 `@OpenGUI`。非空任务会先确定当前模型或完成专用回退配置,再等待手机;在选定设备快照就绪前不会向 provider 发起模型请求。等待期间仍可连接、选择和投屏,检测成功后才锁定本次设备快照,直到整批结束。 +每个 DSH 会话的手机选择和状态统一放在其原生 **OpenGUI** Tab。只有一台已授权手机时会自动选中;有多台时,可在工作台中勾选任意一台或多台后再发送 `/opengui` 或 `@OpenGUI`。非空任务会先确定当前模型或完成专用回退配置,再等待手机;在选定设备快照就绪前不会向 provider 发起模型请求。真正开始执行时会原子获取全部已选手机;若其中任意一台已被其他会话占用,本次任务会明确报忙且不保留部分租约。等待期间仍可连接、选择和投屏,检测成功后只锁定所属会话的设备选择,直到整批结束。 原生 **OpenGUI** Tab 是全宽设备照片墙。界面按 Host 顺序展示全部可见手机,并在末尾追加唯一的连接说明卡,不会为凑列数预留空卡。新检测到的手机先立即展示完整截图,后台自动准备低延迟实时画面,完成后无缝切换;普通用户不需要理解或批准底层组件。浏览器不支持或视频流失败时会继续显示截图,并提供重试。每台手机还可按需打开独立投屏窗口。 插件启动后会在后台检查公开的 `Core-Mate/OpenGUI` GitHub Releases 列表,并将频率限制为每 6 小时最多一次。它会忽略 Android APK、草稿和预发布条目,只识别带版本匹配 `.tgz` 与 `.sha256` 的稳定 `dsh-coremate-mobile-v*` Release。只有发现更高版本时,OpenGUI Tab 才显示**更新**按钮;OpenGUI 任务运行期间禁止安装,失败时保留当前版本,成功后重启 Harness 生效。 -选中 `@OpenGUI` 后,原生输入菜单会显示自由描述、QA、运营和手游四个选项;场景只填入草稿,不会自动发送。提交非空 OpenGUI 任务后,输入框会立即恢复,任务继续写入所属会话。此时新建 DSH 会话会进入真正的空白对话,原任务仍在原会话运行,新会话中会提供紧凑的返回入口。 +选中 `@OpenGUI` 后,原生输入菜单会显示自由描述、QA、运营和手游四个选项;场景只填入草稿,不会自动发送。提交非空 OpenGUI 任务后,输入框会立即恢复,任务继续写入所属会话。切换、隐藏或卸载会话视图不会停止任务;每个 Tab 只显示自己的 OpenGUI 状态和错误。只有真正删除所属 Session 才会取消其任务并清理设备偏好。 -`/opengui` 会启动一个受限的任务路由子任务,只能选择 `phone_agent`、`browser_agent`,或在确有必要时顺序调用两者。手机任务仍为每台已选手机创建一个绑定固定设备的子任务;普通对话也可以直接使用两个委派工具。同一时间只允许一个 OpenGUI 任务,包括通过旧 `/coremate` 别名启动的任务。 +`/opengui` 会启动一个受限的任务路由子任务,只能选择 `phone_agent`、`browser_agent`,或在确有必要时顺序调用两者。手机任务仍为每台已选手机创建一个绑定固定设备的子任务;普通对话也可以直接使用两个委派工具。同一 DSH Session 同时只允许一个根 OpenGUI 任务,包括通过旧 `/coremate` 别名启动的任务;设备租约不冲突时,不同 Session 可以并行。停止和清理会精确校验 Session、任务与 attempt 身份,旧任务的延迟请求不会影响替代它的新任务。 执行期间,外层 `phone_agent` / `browser_agent` 卡片会实时展示内部 `phone_control` / `browser_control` 调用及其可见结果。内部推理、系统提示词和模型配置不会投影到父对话。 -OpenGUI 任务运行时,输入框右侧会出现方形“停止 OpenGUI 操作”按钮。点击会取消路由、手机或浏览器子任务,以及正在运行的 ADB、浏览器下载和浏览器操作;手动打开的投屏窗口保持独立。 +当前 Session 有 OpenGUI 任务运行时,其输入框右侧会出现方形“停止 OpenGUI 操作”按钮。点击只会取消该精确任务,包括路由、手机或浏览器子任务,以及正在运行的 ADB、浏览器下载和浏览器操作;其他 Tab 的任务与手动打开的投屏窗口保持独立。 成功的直接 OpenGUI 任务会在对应 Turn 尾部最多显示一次组合卡片,其中可包含 2–3 个经过校验的动态追问。点击建议只会填入 `@OpenGUI` 草稿。卡片同时提供[用例页](docs/use-cases.zh.md)与 OpenGUI GitHub 入口;失败、取消、空命令、仅配置和普通任务均不展示。 ## 按需浏览器控制 -只有任务实际调用 `browser_agent` 时,插件才检查托管 Chromium。若尚未安装,**OpenGUI** Tab 会显示固定版本、下载大小、确认操作和安装进度,Chat 只提示前往该 Tab;确认后当前任务继续下载、校验 SHA-256、原子解压并打开可见浏览器。取消安装或点击停止按钮会终止本次任务。后续任务复用 `$DSH_HOME/cache/coremate-mobile/browser` 中的缓存和独立浏览器配置目录。 +只有任务实际调用 `browser_agent` 时,插件才检查托管 Chromium。若尚未安装,只有所属 Session 的 **OpenGUI** Tab 会显示固定版本、下载大小、确认操作和安装进度,Chat 也只在该 Session 提示前往对应 Tab;确认后当前任务继续下载、校验 SHA-256、原子解压并打开可见浏览器。取消安装或点击停止按钮会终止该精确任务。后续任务复用 `$DSH_HOME/cache/coremate-mobile/browser` 中的缓存和独立浏览器配置目录。托管浏览器仍是全局单实例并带精确 owner:并发浏览器任务会在自己的 Session 明确报忙,但不影响其他独立手机任务继续执行。 浏览器工具只允许 HTTP/HTTPS 导航,以及观察、点击、Unicode 文本输入、有限按键、滚动、后退、刷新和等待。中文通过 CDP 直接写入当前焦点字段。浏览器二进制、生命周期和控制代码均由插件负责,不会查找或调用 CoreMateDesktop2 或系统 Chrome。 diff --git a/deepseek-harness-plugin/docs/implementation-plan.zh.md b/deepseek-harness-plugin/docs/implementation-plan.zh.md index 828ed62..51e9280 100644 --- a/deepseek-harness-plugin/docs/implementation-plan.zh.md +++ b/deepseek-harness-plugin/docs/implementation-plan.zh.md @@ -4,13 +4,15 @@ ## 结论 -采用“插件级任务管理器 + root-task lease”作为唯一任务所有权模型,统一 `/opengui`、`@OpenGUI`、`phone_agent` 和 `browser_agent`。任务在进入模型前冻结设备快照;只有被 root lease 显式绑定的嵌套 agent 才能复用本次任务;会话、建议卡、推广卡、视频流和 ADB forward 都按同一个任务生命周期归属和回收。 +采用“Session 级任务管理器 + 精确资源 lease”作为唯一任务所有权模型,统一 `/opengui`、`@OpenGUI`、`phone_agent` 和 `browser_agent`。`OpenGuiTaskManager` 以 `sessionId` 建表,每个根任务生成独立 `taskId` 和 `attemptId`;只有被完整身份绑定的嵌套 agent 才能复用本次任务。设备偏好按 Session 保存,任务开始时原子获取全部已选设备;托管浏览器继续使用全局单 owner lease。 这轮不采用只修单个 UI 症状的最小方案。只修 QA 路由、视频 fallback 或会话跳转,仍会留下多入口并发、设备漂移和资源泄漏,因此不能形成可信的完整任务闭环。 ## 目标与成功标准 -- 任意时刻一个插件实例只允许一个 root OpenGUI 任务;同一任务内显式绑定的 `phone_agent` / `browser_agent` 可以继续执行。 +- 同一 Session 任意时刻只允许一个 root OpenGUI 任务;不同 Session 的根任务可同时等待或执行,同一任务内显式绑定的 `phone_agent` / `browser_agent` 可以继续执行。 +- 设备租约按 `{sessionId, taskId, attemptId, deviceId}` 精确归属,多设备抢占全有或全无;旧 stop 与旧 attempt 释放都不能影响新任务。 +- 托管浏览器保持全局单实例;第二个 Session 的浏览器任务明确报忙,但不阻塞该 Session 的独立手机任务。 - 路由、手机操作和浏览器操作始终使用任务开始时冻结的设备身份,不受运行期间 UI 选择变化影响。 - QA/手机类任务默认进入手机路径;只有明确的网页意图才进入浏览器路径。 - 直接命令、原生 `@OpenGUI` 和父 Agent 委派具有一致的模型能力检查、取消、错误恢复和结果归属。 @@ -31,17 +33,16 @@ ## 总体架构 ```text -/opengui ───────┐ -@OpenGUI ───────┼──> OpenGuiTaskManager ──> root-task lease -phone_agent ────┤ │ │ -browser_agent ──┘ │ ├─ 冻结模型与设备上下文 - │ ├─ 绑定允许重入的嵌套 agent - │ ├─ 统一取消与 capability failure - │ └─ 记录 owner session / result owner - │ - ├──> phone workers ──> phone_control ──> ADB - ├──> browser worker ─> browser_control ─> managed Chromium - └──> task state ─────> OpenGUI Tab / Chat / Turn tail +DSH scoped sessionId + │ + ├── Client Map + │ └── 当前 Session 才投影到 UI + │ + └── OpenGuiTaskManager Map + │ + ├── DeviceFleet preferenceBySession + ├── DeviceFleet leaseByDevice + └── 全局 BrowserLease OpenGUI Tab ──> scrcpy install state ──> shared H.264 source ──> WebCodecs canvas │ └─ failure ─> JPEG fallback @@ -50,21 +51,25 @@ OpenGUI Tab ──> scrcpy install state ──> shared H.264 source ──> Web ## 关键设计 -### 1. 单一任务所有权 +### 1. Session 级任务所有权 -`OpenGuiTaskManager` 是插件实例内唯一 admission gate。root 入口通过 `runRoot()` 获取 lease;第二个 root 任务必须在访问设备或启动模型前失败。路由器创建嵌套 agent 后用 `bindAgent()` 显式登记,只有登记过的 agent 才能通过 `nestedLease()` 复用当前上下文。 +`OpenGuiTaskManager` 是插件实例内唯一 admission gate,内部保存 `Map`。root 入口必须从 `parent.session.id` 取得明确归属;缺失时直接拒绝,不能回退到当前 UI Tab 或常驻 agent id。同一 Session 的第二个 root 任务必须在访问设备或启动模型前失败,不同 Session 可并行。路由器创建嵌套 agent 后用 `bindAgent()` 显式登记,只有仍属于当前完整任务身份的 agent 才能通过 `nestedLease()` 复用上下文。 + +模型设置仍是 Host 共享资源,准备阶段通过可取消的队列避免配置互相覆盖;第二个 Session 保持自己的等待状态,不因其他 Session 正在准备而被拒绝。 lease 统一保存: -- `ownerSessionId`:使用真实 session id,不以常驻 agent id 代替。 +- `sessionId`、`taskId`、`attemptId`:停止、嵌套重入和资源回收共同使用的完整身份。 - `phase`:`waiting-for-device`、`routing`、`running`、`stopping`。 -- 冻结后的模型、设备和呈现上下文。 +- 冻结后的模型、`deviceIds[]`、设备和呈现上下文。 - capability failure:继承模型不支持图片或工具时,只产生一次安全恢复提示,不自动重跑有副作用的任务。 - 组合后的取消信号:用户停止、调用方取消和插件卸载任一发生时,都向下游传播。 ### 2. 路由和设备快照 -非空直接任务先等待至少一台已授权手机,再把 UI 中选中的 opaque device id 解析为当前已连接设备并冻结。进入 `routing` 后选择器锁定;每个手机 worker 只拿到自己的固定目标,不在执行中重新读取全局选择。 +`DeviceFleet` 分离偏好与占用:`Map>` 保存选择偏好,`Map` 保存实际租约。不同 Session 可以预选同一台手机;真正执行时才一次性校验并获取该 Session 选中的全部设备。任一设备冲突都会明确报忙且不留下部分租约。进入 `routing` 后只锁定所属 Session 的选择器;每个手机 worker 只拿到自己的固定目标,不在执行中重新读取选择。 + +完成、失败、停止、断连和 Session 删除最终都按完整 lease 身份释放。释放请求只有在四元组仍匹配时才删除当前租约,因此旧 attempt 的延迟清理是无副作用失败。偏好与租约均只保存在进程内;Host 重启后自然释放,不引入 Redis、数据库或持久化假锁。 路由提示词遵循以下顺序: @@ -77,9 +82,11 @@ lease 统一保存: ### 3. 会话与结果归属 -提交 `@OpenGUI` 后 composer 立即释放,任务继续写入 owner session。运行中从 blank owner session 新建会话时,Client 使用 DSH 的 `sessions.create({ workspaceId })` 和 `sessions.open(id)`,不直接调用 Host RPC。 +提交 `@OpenGUI` 后 composer 立即释放,任务继续写入所属 Session。Client 将 `launching`、任务状态、launch/bridge error 和 consumed 标记存入 `Map`;后台更新只写自己的缓存,只有当前 `coremateSessionId` 投影到错误、通知和停止按钮。缺少 `sessionId` 的异步记录直接丢弃。 + +session bridge 对异步创建使用递增 generation 检查:如果用户在创建完成前已经导航到其他会话,旧回调不再自动抢焦点或覆盖新 Session;同一 workspace 的重复请求合并,不同 workspace 的最新请求排队。切换、隐藏或视图卸载不停止任务;`agent/disposed` 只取消真正删除的所属 Session,插件卸载则取消并等待全部任务。 -session bridge 对异步创建使用 generation 检查:如果用户在创建完成前已经导航到其他会话,不再自动抢焦点;同一 workspace 的重复请求合并,不同 workspace 的最新请求排队。卸载时只恢复自己安装的 wrapper。 +本地状态接口固定为批量 `GET /coremate-mobile/task/status -> { tasks: [...] }`,每条活动记录都带 `sessionId`、`taskId`、`attemptId`、`phase` 和 `deviceIds`。`POST /coremate-mobile/task/stop` 只接受 `{sessionId, taskId}`,过期身份返回 `409`。设备选择请求携带 `{sessionId, deviceIds}`,Mirror 状态读取也必须携带 `sessionId`。浏览器安装状态只向 owner Session 暴露,approve/decline 必须提交匹配的 `{sessionId, taskId}`。 建议块在进入父对话前从可见正文中清理并结构化保存。推广卡只由最终成功的直接 OpenGUI assistant message 产生;失败、取消、空命令、配置流程和普通聊天不展示。 @@ -115,24 +122,27 @@ JPEG fallback 只在同一组可见性条件成立时轮询;切换设备、收 ## 对外变化 -- 新增配置 `maxParallelDevices`,默认 4,允许 1–16。 +- 保留已有配置 `maxParallelDevices`,默认 4,允许 1–16;本轮不新增配置。 - `/opengui` 与 `@OpenGUI` 仍是主入口;`/coremate` 暂保留兼容并共享同一 admission gate。 - 不增加新的用户凭据、外部服务或长期运行进程。 -- 新增的持久文件仅为 `$DSH_HOME/cache/coremate-mobile/owned-forwards.json`,权限为 `0600`,只记录设备 serial、端口、scid 和用途,不包含密钥或屏幕内容。 +- 现有 `$DSH_HOME/cache/coremate-mobile/owned-forwards.json` 继续记录插件自有 forward;本轮 Session 偏好与任务租约不新增持久文件、配置、依赖或数据迁移。 ## 验证门禁 自动验证: ```sh -pnpm run check -git diff --check -npm pack --dry-run +rtk pnpm run check +rtk npm pack --dry-run +rtk git diff --check ``` 必须覆盖的回归: -- 四个入口争抢同一 root lease;仅绑定的 nested agent 可以重入。 +- 四个入口在同一 Session 内争抢 root lease,不同 Session 可并行;仅绑定当前完整身份的 nested agent 可以重入。 +- 多设备全有或全无抢占、设备冲突无部分租约、精确 stop、旧 stop 延迟、旧 attempt 释放无副作用。 +- A/B 快速切换、新建 C、后台完成和错误隔离、当前 Session 缓存即时投影、批量 Host 状态对账、缺失身份丢弃。 +- Session 删除与插件卸载清理;浏览器 owner 验证和全局串行。 - 等待设备、设备断开、选择锁定、多设备并发上限、批次失败取消。 - 当前模型兼容、明确不兼容、运行时 capability failure,且失败后不自动重放任务。 - session create 延迟期间手动导航、跨 workspace 排队、卸载恢复 wrapper。 @@ -142,23 +152,25 @@ npm pack --dry-run - scrcpy 多等待方取消、共享 source、慢消费者、关闭后重订阅。 - forward 只回收插件自有记录,正常退出、信号退出和下次启动都可清理。 -实机验收使用当前 DSH Web 与至少一台授权 Android 手机: +实机验收必须使用受支持 DSH Web 与至少两台授权 Android 手机: 1. 从 `/opengui` 和 `@OpenGUI` 各完成一次手机任务,确认设备、owner session、停止和最终结果一致。 2. 运行中创建新会话、切换到其他会话再返回,确认不会抢焦点或丢失原任务。 3. 完成一次明确网页任务和一次 QA 手机任务,确认路由分别为 `browser_agent` 与 `phone_agent`。 4. 验证实时画面、收起/展开、隐藏/恢复、断开/重连及 WebCodecs 失败后的 JPEG 降级。 -5. 验证至少两台真实设备的选择冻结、并发执行、逐设备结果和资源回收;只有一台实机时,不能把模拟布局当作多机验收。 -6. 任务结束后检查无遗留插件自有 ADB forward、scrcpy source 或 Chromium 操作。 +5. A/B 各占一台手机并行;反复切换不串消息、状态和错误;新建 C 不影响 A/B;停止 A 不影响 B。 +6. A 开始下一任务后旧 stop 无效,旧 attempt 释放无效,后台错误不弹到前台;同一手机竞争明确报忙,单 Session 多手机仍能全量执行。 +7. A 使用浏览器期间,B 的浏览器任务明确报忙,但 B 的独立手机任务仍可运行。 +8. 任务结束后检查无遗留插件自有 ADB forward、scrcpy source 或 Chromium 操作。只有真实双设备门禁通过才宣称多 Tab 并行完成,模拟测试不能替代。 ## 发布与回滚 本轮需要作为一个原子实现合并,因为任务所有权、设备快照、会话归属和资源回收共享同一生命周期;拆成互相依赖的半成品阶段会让旧入口绕过新 gate。 -合并后仍需分别确认:commit、push、CI、package archive、真实 DSH/模型/设备验收、tag、GitHub Release。任何一项通过都不能替代下一项。 +本 PR 不包含版本号、tag、GitHub Release 或发布动作。commit、push、CI、package archive 与真实 DSH/模型/双设备验收分别记录;只有实机门禁通过后才可合并,发布流程在合并后单独执行。 代码回滚可整体 revert 本轮提交;`maxParallelDevices` 有默认值,不要求配置迁移。回滚或异常退出后,先由 forward registry 清理插件登记的端口,再删除已经为空的登记文件;不得扫描并删除其他工具创建的 ADB forward。 ## 最脆弱假设 -本方案假设 DSH 在一个 root 任务内传递的 parent/nested agent 对象身份稳定,可用于 lease 绑定,同时 `sessions.create()` 完成时已把新会话投影到 Client store。如果任一假设不成立,禁止退回“按当前全局状态猜归属”;应把稳定 task/session identity 提升为 DSH 提供的显式上下文字段,再由插件消费。 +本方案假设受支持 DSH 允许不同 Agent/Session 同时运行,并且一个 root 任务内的 parent/nested agent 对象身份稳定,可用于 lease 绑定;`sessions.create()` 完成时已把新会话投影到 Client store。如果 Host 实测仍全局串行,隔离能力仍成立,但双 Tab 真并行验收失败,PR 不应合并或发布。任何归属缺失都禁止退回“按当前全局状态猜归属”。 diff --git a/deepseek-harness-plugin/docs/install-for-beginners.zh.md b/deepseek-harness-plugin/docs/install-for-beginners.zh.md index e855892..7393498 100644 --- a/deepseek-harness-plugin/docs/install-for-beginners.zh.md +++ b/deepseek-harness-plugin/docs/install-for-beginners.zh.md @@ -110,7 +110,7 @@ Usage: /opengui 也可以在输入框键入 `@`,选择唯一的 `@OpenGUI` 候选,再描述任务。裸 `@OpenGUI` 只显示用法,不会检查手机或模型。 -执行期间不要拔掉 USB 线,也不要同时发送第二个 `/opengui` 任务。 +执行期间不要拔掉 USB 线,也不要在同一个 Session 同时发送第二个 `/opengui` 根任务。不同 Session 可以使用不冲突的手机集合;同一台手机或托管浏览器被占用时,新任务会明确报忙。 OpenGUI 默认复用当前 DSH 会话模型。当前模型明确支持图片时会直接执行;自定义模型遗漏能力声明时,只会询它是否支持图片和工具调用,确认后自动补全当前模型并继续;明确不支持图片时,才会引导配置专用视觉模型。只有选择专用模型后才会出现 Base URL、协议、模型 ID 和 API Key。API Key 只保存到 Harness 凭据存储,不会成为聊天消息。 diff --git a/deepseek-harness-plugin/docs/model-experience.md b/deepseek-harness-plugin/docs/model-experience.md index 510150e..9038e71 100644 --- a/deepseek-harness-plugin/docs/model-experience.md +++ b/deepseek-harness-plugin/docs/model-experience.md @@ -62,4 +62,4 @@ Every mutation must cite the latest `observationId` and auto-observes as soon as The `text` action accepts up to 500 Unicode characters. Safe ASCII uses `adb input text`; Chinese, emoji, and other Unicode content uses scrcpy's standard UTF-8 clipboard control message and waits for the matching device ACK before reporting success. The protocol path is independent of the phone vendor and active input method. Text is sent as raw process or socket data and is never interpolated through a shell. The stop button cancels the whole OpenGUI task. -The Web UI discovers authorized devices before task admission. It ignores offline and unauthorized rows and exposes only process-local opaque ids and display labels to the browser. One phone is selected automatically; with several phones, the user selects a subset below the input. Task admission freezes that subset and binds one Host-private serial to each child before its first tool call. Every subsequent device command explicitly carries that child's locked serial. +Device preferences belong to individual DSH sessions. The Web UI ignores offline and unauthorized rows and exposes only process-local opaque ids and display labels to the browser. One phone is selected automatically; with several phones, the user selects a subset in the OpenGUI tab. After detection, the task atomically leases the entire subset and locks only its session's selector. A conflicting device rejects the whole acquisition. Each child is bound to one Host-private serial before its first tool call, and every subsequent device command carries that child's locked serial. diff --git a/deepseek-harness-plugin/docs/model-experience.zh.md b/deepseek-harness-plugin/docs/model-experience.zh.md index 72b0c9e..afd2078 100644 --- a/deepseek-harness-plugin/docs/model-experience.zh.md +++ b/deepseek-harness-plugin/docs/model-experience.zh.md @@ -62,4 +62,4 @@ You control exactly one fixed Android phone, labeled {device label}. Never try t `text` action 最多接受 500 个 Unicode 字符。安全 ASCII 走 `adb input text`;中文、emoji 和其他 Unicode 内容通过 scrcpy 标准控制协议发送 UTF-8 剪贴板消息,并在收到对应的设备 ACK 后才报告成功。该协议路径与手机厂商和当前输入法无关。文本以进程参数或原始 socket 数据发送,不经过 shell 拼接。输入框右侧的停止按钮会取消整个 OpenGUI 任务。 -Web 界面会在任务接纳前发现已授权设备。插件忽略 offline 和 unauthorized 行,只向浏览器暴露进程内 opaque id 与显示名。单台自动选择;多台时由用户在 OpenGUI Tab 选择子集。任务接纳后会冻结该子集,并在每个子任务首次调用工具前绑定一个 Host 私有 serial。后续每条设备命令都会明确携带该子任务锁定的 serial。 +设备偏好按 DSH Session 独立保存。插件忽略 offline 和 unauthorized 行,只向浏览器暴露进程内 opaque id 与显示名。单台自动选择;多台时由用户在 OpenGUI Tab 选择子集。检测完成后,任务会原子租用整个子集并只锁定所属 Session 的选择器;任一设备冲突都会拒绝整次获取。每个子任务首次调用工具前都会绑定一个 Host 私有 serial,后续每条设备命令都明确携带该子任务锁定的 serial。 diff --git a/deepseek-harness-plugin/scripts/check-dsh-compatibility.mjs b/deepseek-harness-plugin/scripts/check-dsh-compatibility.mjs index 5542e5d..b8f6b98 100644 --- a/deepseek-harness-plugin/scripts/check-dsh-compatibility.mjs +++ b/deepseek-harness-plugin/scripts/check-dsh-compatibility.mjs @@ -203,7 +203,7 @@ allowBuilds: }) if (!taskResponse.ok) throw new Error(`Task status returned HTTP ${taskResponse.status}`) const task = await taskResponse.json() - if (task.active !== false || task.phase !== 'idle') throw new Error(`Unexpected initial task state: ${JSON.stringify(task)}`) + if (!Array.isArray(task.tasks) || task.tasks.length !== 0) throw new Error(`Unexpected initial task state: ${JSON.stringify(task)}`) const pageErrors = [] const consoleErrors = [] diff --git a/deepseek-harness-plugin/scripts/dev-auto-reload.mjs b/deepseek-harness-plugin/scripts/dev-auto-reload.mjs index ea469b0..fbdc7ef 100755 --- a/deepseek-harness-plugin/scripts/dev-auto-reload.mjs +++ b/deepseek-harness-plugin/scripts/dev-auto-reload.mjs @@ -100,6 +100,10 @@ async function taskStatus() { } } +function hasActiveTask(status) { + return Array.isArray(status?.tasks) ? status.tasks.length > 0 : status?.active === true +} + async function gitConfig(key) { try { return (await run('git', ['config', '--get', key])).stdout.trim() || undefined @@ -176,7 +180,7 @@ async function deferredReload(head) { try { for (let attempt = 0; attempt < 8_640; attempt += 1) { const status = await taskStatus() - if (status === undefined || status.active !== true) { + if (status === undefined || !hasActiveTask(status)) { await reload(head) return } @@ -195,7 +199,7 @@ async function afterPull() { await build(files) const head = (await run('git', ['rev-parse', '--short', 'HEAD'])).stdout.trim() const status = await taskStatus() - if (status?.active === true) { + if (hasActiveTask(status)) { const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'wait-reload', head], { detached: true, stdio: 'ignore', diff --git a/deepseek-harness-plugin/src/client/BrowserInstallPrompt.tsx b/deepseek-harness-plugin/src/client/BrowserInstallPrompt.tsx index f2e217b..02c1716 100644 --- a/deepseek-harness-plugin/src/client/BrowserInstallPrompt.tsx +++ b/deepseek-harness-plugin/src/client/BrowserInstallPrompt.tsx @@ -40,8 +40,8 @@ const progressTrackStyle: CSSProperties = { background: 'var(--dsw-alias-bg-layer-2, rgba(128, 128, 128, 0.16))', } -export async function readBrowserInstallStatus(signal?: AbortSignal): Promise { - const response = await fetch(BROWSER_INSTALL_STATUS_PATH, { +export async function readBrowserInstallStatus(sessionId: string, signal?: AbortSignal): Promise { + const response = await fetch(`${BROWSER_INSTALL_STATUS_PATH}?sessionId=${encodeURIComponent(sessionId)}`, { cache: 'no-store', ...(signal === undefined ? {} : { signal }), }) @@ -49,6 +49,16 @@ export async function readBrowserInstallStatus(signal?: AbortSignal): Promise
{ + try { + const body = await response.json() as { error?: unknown } + if (typeof body.error === 'string' && body.error.length > 0) return body.error + } catch { + // Fall through to the status-based message. + } + return `浏览器安装操作失败 (${response.status})` +} + function megabytes(bytes?: number): string { return bytes === undefined ? '' : `${Math.ceil(bytes / 1024 / 1024)} MB` } @@ -78,18 +88,30 @@ export function browserInstallPresentation(status?: BrowserInstallStatus): Brows } /** Inline first-use consent and progress for the plugin-managed browser. */ -export function BrowserInstallPrompt(): JSX.Element | null { +export function BrowserInstallPrompt({ coremateSessionId }: { readonly coremateSessionId?: string }): JSX.Element | null { + const currentSessionId = useRef(coremateSessionId) + currentSessionId.current = coremateSessionId const panel = useRef(null) - const [status, setStatus] = useState() - const [pending, setPending] = useState(false) - const [error, setError] = useState() + const [snapshot, setStatus] = useState() + const status = snapshot?.owner?.sessionId === coremateSessionId ? snapshot : undefined + const [decision, setDecision] = useState<{ + sessionId: string, + taskId: string, + pending: boolean, + message: string | undefined, + }>() useEffect(() => { + setStatus(undefined) + setDecision(undefined) const controller = new AbortController() + if (coremateSessionId === undefined) return () => controller.abort() let timer: ReturnType | undefined const poll = async (): Promise => { try { - setStatus(await readBrowserInstallStatus(controller.signal)) + const next = await readBrowserInstallStatus(coremateSessionId, controller.signal) + if (controller.signal.aborted || currentSessionId.current !== coremateSessionId) return + setStatus(next.owner?.sessionId === coremateSessionId ? next : undefined) } catch { // The Host route is optional; an absent route leaves this control hidden. } finally { @@ -101,25 +123,40 @@ export function BrowserInstallPrompt(): JSX.Element | null { controller.abort() if (timer !== undefined) clearTimeout(timer) } - }, []) + }, [coremateSessionId]) const decide = useCallback(async (approve: boolean): Promise => { - setPending(true) - setError(undefined) + const owner = status?.owner + if (owner === undefined || owner.sessionId !== coremateSessionId) return + setDecision({ sessionId: owner.sessionId, taskId: owner.taskId, pending: true, message: undefined }) try { const response = await fetch(approve ? BROWSER_INSTALL_APPROVE_PATH : BROWSER_INSTALL_DECLINE_PATH, { method: 'POST', - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: owner.sessionId, taskId: owner.taskId }), }) - if (!response.ok && response.status !== 409) throw new Error(`浏览器安装操作失败 (${response.status})`) - setStatus(await readBrowserInstallStatus()) + if (!response.ok) throw new Error(await browserInstallError(response)) + const next = await readBrowserInstallStatus(owner.sessionId) + if (currentSessionId.current !== owner.sessionId) return + setStatus(current => current?.owner?.taskId === owner.taskId + ? next.owner?.sessionId === owner.sessionId ? next : undefined + : current) } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)) + setDecision(current => current?.sessionId === owner.sessionId && current.taskId === owner.taskId + ? { ...current, message: reason instanceof Error ? reason.message : String(reason) } + : current) } finally { - setPending(false) + setDecision(current => current?.sessionId === owner.sessionId && current.taskId === owner.taskId + ? { ...current, pending: false } + : current) } - }, []) + }, [coremateSessionId, status]) + const currentDecision = decision?.sessionId === status?.owner?.sessionId && decision?.taskId === status?.owner?.taskId + ? decision + : undefined + const pending = currentDecision?.pending ?? false + const error = currentDecision?.message const presentation = browserInstallPresentation(status) useEffect(() => { if (presentation.kind === 'confirmation') panel.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) diff --git a/deepseek-harness-plugin/src/client/CoremateTaskNotice.tsx b/deepseek-harness-plugin/src/client/CoremateTaskNotice.tsx index 0c89ab3..ad9ea3a 100644 --- a/deepseek-harness-plugin/src/client/CoremateTaskNotice.tsx +++ b/deepseek-harness-plugin/src/client/CoremateTaskNotice.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react' import type { CSSProperties } from 'react' -import type { ISessions } from '@deepseek-ai/dsh-client-runtime/client' import { readBrowserInstallStatus } from './BrowserInstallPrompt.tsx' import { useCoremateTaskStatus } from './task-status-store.ts' @@ -25,25 +24,28 @@ const noticeStyle: CSSProperties = { export interface CoremateTaskNoticeProps { readonly coremateSessionId?: string - readonly coremateSessions?: ISessions } -export function CoremateTaskNotice({ coremateSessionId, coremateSessions }: CoremateTaskNoticeProps): JSX.Element | null { - const { task, launching, launchError, bridgeError } = useCoremateTaskStatus() - const elsewhere = task.active && task.ownerSessionId !== undefined && task.ownerSessionId !== coremateSessionId - const [browserApproval, setBrowserApproval] = useState(false) +export function CoremateTaskNotice({ coremateSessionId }: CoremateTaskNoticeProps): JSX.Element | null { + const { task, launching, launchError, bridgeError } = useCoremateTaskStatus(coremateSessionId) + const [approval, setApproval] = useState<{ sessionId: string, taskId: string }>() useEffect(() => { - if (!task.active || elsewhere) { - setBrowserApproval(false) + if (!coremateSessionId || !task.active || !task.taskId) { + setApproval(undefined) return } const controller = new AbortController() let timer: ReturnType | undefined const poll = async (): Promise => { try { - const status = await readBrowserInstallStatus(controller.signal) - setBrowserApproval(status.phase === 'awaiting-confirmation') + const status = await readBrowserInstallStatus(coremateSessionId, controller.signal) + if (controller.signal.aborted) return + setApproval(status.phase === 'awaiting-confirmation' && + status.owner?.sessionId === coremateSessionId && + status.owner.taskId === task.taskId + ? { sessionId: coremateSessionId, taskId: task.taskId } + : undefined) } catch { // The optional Host route must not replace the existing task notice. } finally { @@ -55,20 +57,16 @@ export function CoremateTaskNotice({ coremateSessionId, coremateSessions }: Core controller.abort() if (timer !== undefined) clearTimeout(timer) } - }, [elsewhere, task.active]) + }, [coremateSessionId, task.active, task.taskId]) - const message = launchError ?? bridgeError ?? (elsewhere - ? 'OpenGUI 正在另一会话执行' - : browserApproval + const browserApproval = approval !== undefined && approval.sessionId === coremateSessionId && approval.taskId === task.taskId + const message = launchError ?? bridgeError ?? (browserApproval ? 'OpenGUI 需要确认浏览器安装,请前往 OpenGUI Tab。' : launching ? 'OpenGUI 已接收任务,正在启动…' : undefined) if (message === undefined) return null return (
{message} - {!elsewhere || coremateSessions === undefined ? null : ( - - )}
) } diff --git a/deepseek-harness-plugin/src/client/CoremateView.tsx b/deepseek-harness-plugin/src/client/CoremateView.tsx index 7209ec0..7c86003 100644 --- a/deepseek-harness-plugin/src/client/CoremateView.tsx +++ b/deepseek-harness-plugin/src/client/CoremateView.tsx @@ -188,15 +188,22 @@ function mirrorActive(device: MirrorDeviceStatus): boolean { } export function CoremateView({ coremateSessionId }: { readonly coremateSessionId?: string }): JSX.Element { - const [status, setStatus] = useState() + const currentSessionId = useRef(coremateSessionId) + currentSessionId.current = coremateSessionId + const sessionGeneration = useRef(0) + const mutationGeneration = useRef(0) + const [snapshot, setStatus] = useState() const [expanded, setExpanded] = useState>(() => new Set()) const seenDevices = useRef(new Set()) - const [pending, setPending] = useState(false) - const [error, setError] = useState() + const [pendingSessionId, setPendingSessionId] = useState() + const [failure, setFailure] = useState<{ sessionId: string | undefined, message: string }>() const [streamStatus, setStreamStatus] = useState() const [streamStatusError, setStreamStatusError] = useState() const [runtime, setRuntime] = useState() const [runtimeFailed, setRuntimeFailed] = useState(false) + const status = snapshot?.sessionId === coremateSessionId ? snapshot : undefined + const pending = coremateSessionId !== undefined && pendingSessionId === coremateSessionId + const error = failure?.sessionId === coremateSessionId ? failure?.message : undefined useEffect(() => { const controller = new AbortController() @@ -211,11 +218,25 @@ export function CoremateView({ coremateSessionId }: { readonly coremateSessionId }, []) useEffect(() => { + const generation = ++sessionGeneration.current + mutationGeneration.current += 1 + setStatus(undefined) + setExpanded(new Set()) + seenDevices.current.clear() + setPendingSessionId(undefined) + setFailure(undefined) const controller = new AbortController() + if (coremateSessionId === undefined) return () => controller.abort() let timer: ReturnType | undefined const poll = async (): Promise => { try { - const next = await readMirrorStatus(controller.signal) + const next = await readMirrorStatus(coremateSessionId, controller.signal) + if ( + next === undefined || + controller.signal.aborted || + generation !== sessionGeneration.current || + currentSessionId.current !== coremateSessionId + ) return setStatus(next) setExpanded(current => { const connectedIds = new Set(next.devices.map(device => device.id)) @@ -228,11 +249,23 @@ export function CoremateView({ coremateSessionId }: { readonly coremateSessionId } return value }) - setError(undefined) + setFailure(undefined) } catch (reason) { - if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : String(reason)) + if ( + !controller.signal.aborted && + generation === sessionGeneration.current && + currentSessionId.current === coremateSessionId + ) { + setFailure({ sessionId: coremateSessionId, message: reason instanceof Error ? reason.message : String(reason) }) + } } finally { - if (!controller.signal.aborted) timer = setTimeout(poll, document.hidden ? 10_000 : 1_500) + if ( + !controller.signal.aborted && + generation === sessionGeneration.current && + currentSessionId.current === coremateSessionId + ) { + timer = setTimeout(poll, document.hidden ? 10_000 : 1_500) + } } } void poll() @@ -240,7 +273,7 @@ export function CoremateView({ coremateSessionId }: { readonly coremateSessionId controller.abort() if (timer !== undefined) clearTimeout(timer) } - }, []) + }, [coremateSessionId]) useEffect(() => { const controller = new AbortController() @@ -269,16 +302,23 @@ export function CoremateView({ coremateSessionId }: { readonly coremateSessionId }, []) const mutate = useCallback(async (path: string, ids: readonly string[]): Promise => { - setPending(true) + const sessionId = coremateSessionId + const generation = ++mutationGeneration.current + setPendingSessionId(sessionId) try { - setStatus(await postMirrorStatus(path, ids)) - setError(undefined) + if (sessionId === undefined) throw new Error('当前会话不可用,请刷新后重试。') + const next = await postMirrorStatus(path, sessionId, ids) + if (next === undefined || generation !== mutationGeneration.current || currentSessionId.current !== sessionId) return + setStatus(next) + setFailure(undefined) } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)) + if (generation === mutationGeneration.current && currentSessionId.current === sessionId) { + setFailure({ sessionId, message: reason instanceof Error ? reason.message : String(reason) }) + } } finally { - setPending(false) + if (generation === mutationGeneration.current && currentSessionId.current === sessionId) setPendingSessionId(undefined) } - }, []) + }, [coremateSessionId]) const selected = status?.devices.filter(device => device.selected).map(device => device.id) ?? [] const wallItems = buildDeviceWallItems(status?.devices ?? []) @@ -321,7 +361,7 @@ export function CoremateView({ coremateSessionId }: { readonly coremateSessionId
- +
{error === undefined ? null : ( @@ -375,6 +415,9 @@ export function CoremateView({ coremateSessionId }: { readonly coremateSessionId /> {device.label} + {device.occupied && !device.occupiedByCurrentSession + ? 占用中 + : null}