Merge remote-tracking branch 'origin/master' into worktree/sleepy-grothendieck-64df7b
This commit is contained in:
commit
32c47d6eae
197 files changed
+4306
-1373
No files matched your search
+6
-6
@@ -21,8 +21,8 @@
|
||||
en: 774c3bf80a48ffbe
|
||||
zh: c513245789aa8efd
|
||||
/agent-note-right-sidebar-tab-types-and-navigation/decision/navigation-ctx-sidebarright:
|
||||
en: b32bad4c3b8c2cc8
|
||||
zh: 57ba7b8d3451e478
|
||||
en: c52363f57001b7ac
|
||||
zh: bbcbdbc038cbf316
|
||||
/agent-note-right-sidebar-tab-types-and-navigation/decision/addresses:
|
||||
en: eeb1e7e7478a2f2c
|
||||
zh: bddbc5bc78245f63
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 6386bde2654f9a0a
|
||||
zh: 700439be6a01e162
|
||||
/agent-note-right-sidebar-tab-types-and-navigation/alternatives-considered:
|
||||
en: 91fbca006e8de5bc
|
||||
zh: f48d3ce1dd7b63bf
|
||||
en: c47eb4e2f3c307f9
|
||||
zh: fd4515b8be88506e
|
||||
/agent-note-right-sidebar-tab-types-and-navigation/consequences:
|
||||
en: 3cdb1159ee55d851
|
||||
zh: dcd068005e678a89
|
||||
@@ -39,5 +39,5 @@
|
||||
en: 49449a6c8465c098
|
||||
zh: 1f476cc1226aa9cd
|
||||
/agent-note-right-sidebar-tab-types-and-navigation/deferred:
|
||||
en: fa27b7684e38329b
|
||||
zh: d270e0cdcab73732
|
||||
en: 5b2416692028bf89
|
||||
zh: 65930102aa253bf2
|
||||
+4
-2
@@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
|
||||
|
||||
`openResource` accepts the union of every declared resource shape and `openTab<K>` the shape declared for `K`; a body narrows `navigation.params` by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims `file` addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone.
|
||||
|
||||
Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The seat publishes its binding — its session, its store's actions, and its surface — while mounted; a command on the public face acts on the mounted session and throws with no mounted session surface. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted.
|
||||
Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The plugin names the Session on screen — the selected Session while the Conversation fills the main column — from the Session selection and the main panel, before React renders either change, and publishes it as `mounted`; a command on the public face acts on that Session through its adopted store and throws when no Session is on screen or its store has not been minted. The seat reports only what it renders with: the room its docking kit measured and whether its frame width presents the panel fullscreen automatically. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted.
|
||||
|
||||
### Addresses
|
||||
|
||||
@@ -100,6 +100,8 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced
|
||||
|
||||
**Slot priorities to express an extension taking over a builtin, then registry-minted slot keys.** The first attempt had the overriding type register its body at a lower slot priority through an exported constant — a value import across dynamic plugins, and a second rule system (slot priority) standing in for the registry's. The second attempt had the registry mint a key per registration and return it from `register()`, which made registration a two-step dance whose ordering mattered. Letting the implementation declare its own `id` — required, unique, the same string it registers its seats under — needs no constant, no minting, and no ordering, and gives the registry the identity it needs to reject duplicates.
|
||||
|
||||
**A binding the seat publishes from its own effects.** The first form had the mounted seat publish its session, actions, and surface after each commit and release them when it left. React runs a commit's passive effects in tree order, and the frame renders the Conversation column before the right column, so in a commit that switched Sessions or returned from a global panel a Conversation component opening content from its effect ran while no seat was bound — after the departing seat's release and before the arriving seat's bind — and the open threw. The Session selection and the main panel change before React renders, so deriving the on-screen Session from them removes that ordering dependency. The seat still measures pane room, because only its rendered kit can.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A type is one static object plus one or two keyed seat registrations; its occurrence information is read through injected `useTabInfo()`. The framework grows no per-type API surface, and a type shipped from outside this repository imports only types from the Sidebar package.
|
||||
@@ -116,5 +118,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced
|
||||
## Deferred
|
||||
|
||||
- A navigation protocol beyond `sidebar://<kind>`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes.
|
||||
- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session.
|
||||
- Opening into a session other than the one on screen from the public face, which acts on the on-screen Session only; a tab's own actions already act on their tab's session.
|
||||
- A localized message when an open fails from the conversation; the failure is currently the thrown error's text.
|
||||
+4
-2
@@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
|
||||
|
||||
`openResource` 接受所有已声明资源形状的联合,`openTab<K>` 接受为 `K` 声明的形状;正文按自己所服务的协议或 kind 收窄 `navigation.params`。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 `file` 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。
|
||||
|
||||
除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。座位挂载期间发布其绑定——自己的会话、其 store 的 action 与其面;公开面上的命令作用于已挂载会话,没有已挂载会话面时抛错。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。
|
||||
除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。插件依据会话选择与主面板确定屏上会话——Conversation 占据主栏时的选中会话——并在 React 渲染这两者的变化之前将其发布为 `mounted`;公开面上的命令经该会话被收养的 store 作用于它,没有会话在屏上或其 store 尚未铸出时抛错。座位只上报它渲染时依据的事实:停靠套件测得的空间,以及框架宽度是否让面板自动全屏。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。
|
||||
|
||||
### 地址
|
||||
|
||||
@@ -100,6 +100,8 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
|
||||
|
||||
**用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。** 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 `register()` 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 `id`——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。
|
||||
|
||||
**由座位在自己的 effect 里发布绑定。** 最初的形式由已挂载的座位在每次提交后发布自己的会话、action 与面,离开时释放。React 按树序运行一次提交的被动 effect,而框架先渲染 Conversation 列、后渲染右栏,于是在切换会话或从全局面板返回的那次提交里,从自己 effect 打开内容的 Conversation 组件运行时没有任何座位已绑定——在离开的座位释放之后、到来的座位绑定之前——打开随之抛错。会话选择与主面板都在 React 渲染之前变化,从它们推导屏上会话便消除了这一顺序依赖。分栏空间仍由座位测量,因为只有它渲染出的套件能测。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 `useTabInfo()` 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。
|
||||
@@ -116,5 +118,5 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
|
||||
## Deferred
|
||||
|
||||
- `sidebar://<kind>` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。
|
||||
- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。
|
||||
- 从公开面往屏上会话之外的会话里打开;公开面只作用于屏上会话,而 tab 自己的动作已作用于其所在会话。
|
||||
- 从会话区打开失败时的本地化提示;目前是抛错文本本身。
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 36bf235f0821ef6a
|
||||
zh: a86bf07af03d54a3
|
||||
/agent-note-desktop-primary-runtime/decision:
|
||||
en: b3b104e314762322
|
||||
zh: 77478c007893201f
|
||||
en: 3d2241842f5a2a83
|
||||
zh: 8617717f0337d76c
|
||||
/agent-note-desktop-primary-runtime/alternatives-considered:
|
||||
en: 566fd91c30cb0a0a
|
||||
zh: 70758ae2617f6ebb
|
||||
|
||||
@@ -16,7 +16,7 @@ The application version, top-level interpreter/package-manager versions, Python
|
||||
|
||||
The shared Node build entry downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload.
|
||||
|
||||
macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
|
||||
macOS grants `com.apple.security.cs.allow-jit` to the standalone Node executable and LibreOffice helpers. The x64 Node executable additionally requires `com.apple.security.cs.allow-unsigned-executable-memory`: with JIT-only signing, the bundled x64 Node fails during startup under Rosetta in V8 `OS::SetPermissions`. ARM64 Node and LibreOffice helpers retain JIT-only permissions; the additional permission is confined to the interpreter that requires it. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
|
||||
|
||||
Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为
|
||||
|
||||
共享 Node 构建入口下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。
|
||||
|
||||
macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
|
||||
macOS 向独立 Node 可执行文件和 LibreOffice 辅助程序授予 `com.apple.security.cs.allow-jit`。x64 Node 可执行文件还需要 `com.apple.security.cs.allow-unsigned-executable-memory`:仅授予 JIT 权限时,内置 x64 Node 在 Rosetta 下启动会在 V8 `OS::SetPermissions` 中失败。ARM64 Node 和 LibreOffice 辅助程序保持仅有 JIT 权限;额外权限仅授予需要它的解释器。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
|
||||
|
||||
Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: e22ee243b1503319
|
||||
zh: b53c1a932d547f55
|
||||
/agent-note-shared-developer-tool-settings/decision:
|
||||
en: 62f71b49af7b9971
|
||||
zh: f0f12da4cb0c7fc0
|
||||
en: 6b94e4c856dbd92a
|
||||
zh: e923b1118dfad7b6
|
||||
/agent-note-shared-developer-tool-settings/alternatives-considered:
|
||||
en: df1b80e5b8d2a178
|
||||
zh: 0136d16fc44d57bf
|
||||
|
||||
@@ -12,9 +12,9 @@ Diagnostic views, preset selection and changed-file summaries add complexity to
|
||||
|
||||
The settings-owned `ctx.configForms.developerTools` preference uses a shared entry form and exposes one accepted `ui-settings.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
|
||||
|
||||
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and refuses the Agent-preset cards' selection, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
|
||||
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
|
||||
|
||||
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, the Agent-preset surfaces' `developerTools`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
|
||||
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, the new-session preset chip's `developerTools`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ Status: implemented
|
||||
|
||||
设置域内的 `ctx.configForms.developerTools` 偏好对象使用共享条目表单,提供一个已接受的 `ui-settings.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
|
||||
|
||||
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口并拒绝 Agent 预设卡片的选择操作,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
|
||||
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
|
||||
|
||||
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、Agent 预设各界面读取的 `developerTools`,以及 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
|
||||
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、新会话预设入口读取的 `developerTools`,以及 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 5853599207f6b3bb
|
||||
zh: d58b5e8e19d93a4f
|
||||
/agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/decision:
|
||||
en: 721ea003ebaef8c7
|
||||
zh: e17be423bc4c7568
|
||||
en: f2f641fe1f271edf
|
||||
zh: 321df1f4ea9e783d
|
||||
/agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/alternatives-considered:
|
||||
en: 3ad30b2574818a61
|
||||
zh: d323adddd5065ae4
|
||||
|
||||
@@ -12,7 +12,7 @@ The Windows coverage run (run 34085536250, job 101628739668) reports two teardow
|
||||
|
||||
The [ACP test](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) awaits disposal under the execution lane’s test budget, then checks the actual child outcome. Failure cleanup awaits disposal and child completion before removing the private directory. A deferred exit observation proves that disposal cannot finish merely because termination was requested. The production EOF and termination grace periods remain unchanged.
|
||||
|
||||
The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted.
|
||||
The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget, which `vitest.config.ts` applies to every inline project from `DSH_COVERAGE_TEST_TIMEOUT_MS` ([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) owns why a CLI flag cannot carry it). Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted.
|
||||
|
||||
The [native Windows CI decision](../process/2026-08-08-native-windows-pull-request-ci.md) continues to own lane scheduling and budgets. This change only removes conflicting local deadlines and strengthens resource-lifetime assertions; it does not establish a Windows process-kill or filesystem defect.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Windows 覆盖率运行 (run 34085536250, job 101628739668) 为测试和钩子
|
||||
|
||||
[ACP 测试](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) 在执行通道的测试预算内等待清理完成,然后检查真实子进程的结果。失败清理先等待释放和子进程完成,再删除私有目录。延迟的退出观察证明,清理不能仅因已请求终止而完成。生产环境的 EOF 与终止宽限期保持不变。
|
||||
|
||||
[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。
|
||||
[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算;`vitest.config.ts` 从 `DSH_COVERAGE_TEST_TIMEOUT_MS` 把该预算施加到每个内联 project([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) 负责说明 CLI 标志为何无法承载它)。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。
|
||||
|
||||
[原生 Windows CI 决策](../process/2026-08-08-native-windows-pull-request-ci.zh.md) 继续负责通道调度和预算。本次改动仅移除冲突的局部期限并加强资源生命周期断言;它并不证明 Windows 进程终止或文件系统存在缺陷。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 3f8862080b8c3c89
|
||||
zh: 0e83c485f02c10f1
|
||||
/agent-note-user-patch-tests-control-filesystem-event-delivery/decision:
|
||||
en: a49b4a6e76725b7e
|
||||
zh: 9813f60c40a19f83
|
||||
en: 601a3437a4332b69
|
||||
zh: 2b6bcc4636314c31
|
||||
/agent-note-user-patch-tests-control-filesystem-event-delivery/alternatives-considered:
|
||||
en: 5d559ee291e2e15e
|
||||
zh: 828e7def587d71e8
|
||||
|
||||
@@ -12,7 +12,7 @@ The macOS Sandbox run (run 34238200206, job 102101292119) times out while waitin
|
||||
|
||||
The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
|
||||
|
||||
The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees.
|
||||
The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. Before their first write they close a second `fs.watch` directory handle, which on darwin blocks until libuv has rebuilt the FSEvents stream that Chokidar readiness does not wait for, and their polling deadline is the case's effective budget. The refresh test does not establish operating-system delivery guarantees.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ macOS Sandbox 运行 (run 34238200206, job 102101292119) 在等待首次用户 p
|
||||
|
||||
[用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
|
||||
|
||||
独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。
|
||||
独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。它们在首次写入前关闭第二个 `fs.watch` 目录句柄,该操作在 darwin 上会阻塞到 libuv 重建 FSEvents 流为止(Chokidar 的就绪信号并不等待该流建立);它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: dsh-create-upgrade-guide
|
||||
description: Use when a deepseek-harness change breaks an externally perceptible surface (CLI, profiles, cordis.yml or settings keys, persisted user data, SDK or wire APIs, published package entry points), or when changing a surface that an unreleased upgrade guide already covers, to write or update docs/upgrade-guide/v<version>/<item>/guide.md.
|
||||
---
|
||||
|
||||
# DSH Create Upgrade Guide
|
||||
|
||||
An upgrade guide tells a reader who runs release vT what to change to run the next release. Write it in the change that introduces the break, as soon as the break is recognized; do not defer it to release preparation.
|
||||
|
||||
## Scope
|
||||
|
||||
Write a guide only for breaks that someone outside the repository can observe after upgrading:
|
||||
|
||||
- `dsh` commands, flags, and profile names;
|
||||
- `cordis.yml`, patch, overlay, and settings keys or their accepted values;
|
||||
- persisted user data: Session logs, SQLite stores, and user data directories;
|
||||
- TypeScript and Python SDK exports, JSON-RPC, HTTP, and ACP messages;
|
||||
- published package names and entry points.
|
||||
|
||||
Pre-stable internal APIs whose consumers are all updated in the same change need no guide. A Session-format change still follows [type acknowledgements](../../../docs/cookbook/reviewing-persistence-type-changes.md); its guide states only the reader's actions and links that record.
|
||||
|
||||
## Location
|
||||
|
||||
`docs/upgrade-guide/v<version>/<item>/guide.md`
|
||||
|
||||
- `<version>` is the `version` field of the root `package.json` when you write the guide, for example `v0.1.7-rc.2`. The guide describes the upgrade from that release to the next one.
|
||||
- `<item>` is a kebab-case name of the changed surface, for example `profile-flag-rename`.
|
||||
- Each item directory holds exactly `guide.md`, its Chinese counterpart `guide.zh.md`, and the pairing record `guide.i18n.yaml`: no index, no attachments.
|
||||
|
||||
## Maintenance
|
||||
|
||||
While the root version is unchanged, the current version directory describes the delta from vT to current `master`. When a later change touches a surface a guide there covers, update that guide in the same change; delete the guide when the break is reverted. After the version bump, guides in older version directories are frozen; a new break goes into the new version directory. Never write a guide spanning more than one release.
|
||||
|
||||
## Format
|
||||
|
||||
Start from the [upgrade-guide template](../dsh-doc/templates/upgrade-guide.md). `verify-upgrade-guides` enforces:
|
||||
|
||||
- frontmatter with exactly `kind: upgrade-guide` and a one-sentence `description` naming what breaks; add a key only together with its gate check, then backfill every guide;
|
||||
- one `#` title, then exactly the `## Change` and `## Migration` sections in `guide.md`, and `## 变更` and `## 迁移` in `guide.zh.md`, in that order;
|
||||
- at most 500 words in `guide.md`, frontmatter included.
|
||||
|
||||
`Change` states the old and new behavior of the surface and who is affected. `Migration` lists ordered steps that name exact files, keys, commands, or symbols, and states how to confirm the migration worked.
|
||||
|
||||
When a guide needs more than 500 words, the extra text belongs elsewhere: link source files or symbols instead of restating code, move rationale into an [Agent Note](../../notes/README.md), and turn repeated mechanical steps into a script or `dsh` command that the guide invokes.
|
||||
|
||||
Follow [dsh-prose-standard](../dsh-prose-standard/SKILL.md). Write the Chinese counterpart in the same change under the [bilingual pairing rules](../../../docs/AGENTS.md), then record the pair with `pnpm run verify-translation-pairing --write docs/upgrade-guide/v<version>/<item>/guide.md`.
|
||||
|
||||
## Verify
|
||||
|
||||
```sh
|
||||
pnpm run verify-upgrade-guides
|
||||
pnpm run verify-translation-pairing docs/upgrade-guide/v<version>/<item>/guide.md
|
||||
pnpm run verify-md-links
|
||||
```
|
||||
@@ -57,6 +57,7 @@ The `kind` frontmatter field selects exactly one README template. Every kind in
|
||||
- `persistence-change` → [templates/persistence-change.md](templates/persistence-change.md): a dated record in `docs/persistence-changes/` — acknowledge a detected type transition with per-root predecessors and generated after schemas.
|
||||
- `persistence-release` → [templates/persistence-release.md](templates/persistence-release.md): a pinned tag in `docs/persistence-changes/releases/` — compare reconstructed historical types without claiming compatibility acknowledgement.
|
||||
- `persistence-format` → [templates/persistence-format.md](templates/persistence-format.md): a historical Session format checkpoint in `docs/persistence-changes/historical-formats/`, with complete schemas; the current writer uses the existing catalog.
|
||||
- `upgrade-guide` → [templates/upgrade-guide.md](templates/upgrade-guide.md): `docs/upgrade-guide/v<version>/<item>/guide.md` and its Chinese sibling — migration steps for one externally perceptible break, owned by [dsh-create-upgrade-guide](../dsh-create-upgrade-guide/SKILL.md).
|
||||
|
||||
Open the template before writing and follow its skeleton and rules; it states what the kind is, how the page is structured, and the fact checks each section owes. Add a new kind only together with a distinct template file, a documented repository position or declared owner, and a focused check that maps documents to it.
|
||||
|
||||
@@ -67,7 +68,7 @@ These rules decide what a section may say. They apply to every authored human-fa
|
||||
- **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. In a package Summary, “what it is” means only its reader-visible capability, not its Cordis role, registrations, or internal components. Omit source identifiers unless the reader directly uses them in configuration, a command, or a public API. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does.
|
||||
- **Developer sections explain, never enumerate.** Folded implementation content covers the overall design concept, architecture, and hand-waving dataflow — enough to understand how the package works — and links code for exact detail. No full API catalogs, exhaustive column lists, event-payload enumerations, or JSDoc restatement inside the folds.
|
||||
- **Dev Note is the only slop zone.** Partial ideas, scratches, undecided directions, measured artifacts, and working hypotheses live only in the final Dev Note, marked explicitly non-authoritative. Every other section is polished, current-state prose.
|
||||
- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. Agent Notes and postmortems retain their own historical scope.
|
||||
- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. An `upgrade-guide` describes the change from its version directory's release to the next one. Agent Notes and postmortems retain their own historical scope.
|
||||
- **Use controlled technical English.** Give each sentence an explicit actor and one main action when ambiguity can change behavior. Reuse one term per concept, prefer direct verbs, split stacked instructions and conditions, and preserve modality and exceptions. Apply the non-certified, ASD-STE100-inspired discipline in [the page-style reference](references/style.md#controlled-technical-english). Do not force a shorter sentence when precision would fall.
|
||||
|
||||
## Quality criteria
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
## The kind system
|
||||
|
||||
`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order:
|
||||
`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Files named `docs/upgrade-guide/v<version>/<item>/guide.md` and their `guide.zh.md` siblings use `upgrade-guide`. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order:
|
||||
|
||||
1. The README is `packages/README.md` or `packages/<group>/README.md` → `package-group`.
|
||||
2. The package manifest declares `dsh.bundle.patch` → `package-bundle`.
|
||||
@@ -45,6 +45,7 @@ kind: "package-reference"
|
||||
| `persistence-change` | `docs/persistence-changes/YYYY-MM-DD-slug.md` and its Chinese sibling | [persistence-change.md](../templates/persistence-change.md) | Historical acknowledgement: mechanically detected type changes, per-root predecessor references, compatibility decision, and generated after schemas. |
|
||||
| `persistence-release` | `docs/persistence-changes/releases/dsh-v*.md` and its Chinese sibling | [persistence-release.md](../templates/persistence-release.md) | Retrospective observation: pinned tag, adjacent before/after digests, actual version constants, and changed after schemas; no compatibility acknowledgement. |
|
||||
| `persistence-format` | `docs/persistence-changes/historical-formats/vN.md` and its Chinese sibling | [persistence-format.md](../templates/persistence-format.md) | Selected historical format: source evidence, complete root schemas and reachable types; the current writer uses the generated catalog. |
|
||||
| `upgrade-guide` | `docs/upgrade-guide/v<version>/<item>/guide.md` and its Chinese sibling | [upgrade-guide.md](../templates/upgrade-guide.md) | Upgrade path: one externally perceptible break from the directory's release to the next, with `Change` and `Migration` sections in at most 500 English words. |
|
||||
|
||||
Before assigning `package-library` or `package-bundle`, inspect the facts: read `package.json` for `dsh.bundle.patch` and `src/index.ts` for the entry shape (`apply` export or a default service export is a plugin; a plain module API is a library). `dsh plugin --profile <name> add <package>` installs any npm dependency, but the profile reconcile activates a layer only for a package that declares `dsh.bundle`; never present that command as an install path for a library or a plain plugin. The documentation check derives the expected kind from these same facts, rejects another value, and rejects `name`, `audience`, `tags`, and README-local `i18n` metadata. Add a new kind only with a distinct template, an unambiguous repository position or declared owner, and a focused check that maps documents to it.
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Template: upgrade-guide
|
||||
|
||||
Use this kind for `docs/upgrade-guide/v<version>/<item>/guide.md` and its Chinese sibling `guide.zh.md`, whose sections are `## 变更` and `## 迁移`. The tree has no folder index. [dsh-create-upgrade-guide](../../dsh-create-upgrade-guide/SKILL.md) owns scope, placement, maintenance, and length rules; `pnpm run verify-upgrade-guides` enforces them.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
```yaml
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The externally perceptible surface that breaks and what replaces it."
|
||||
---
|
||||
```
|
||||
|
||||
## Skeleton
|
||||
|
||||
````markdown
|
||||
# <Specific break, for example: `--profile` replaces `--preset`>
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
State the old and new behavior of the surface and who observes it.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Name the exact file, key, command, or symbol to change, with a before/after snippet when it is shorter than prose.
|
||||
2. State how to confirm the migration worked.
|
||||
````
|
||||
@@ -8,6 +8,8 @@ Public APIs are pre-stable; update every consumer. Follow [version/status](docs/
|
||||
|
||||
Acknowledge [declared persistence-type changes](docs/cookbook/reviewing-persistence-type-changes.md).
|
||||
|
||||
Record each externally perceptible breaking change immediately in an [upgrade guide](.agents/skills/dsh-create-upgrade-guide/SKILL.md).
|
||||
|
||||
**Application launch.** Only `dsh` profiles launch supported Node apps; package bins, demos, and public SDK argv escapes are forbidden ([rule](docs/architecture.md#application-launch)).
|
||||
|
||||
## Repository layout
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@
|
||||
en: 819e3f7c144d0d64
|
||||
zh: de815f040975dba7
|
||||
/deepseek-harness/community-and-support:
|
||||
en: 42848f543aff6e02
|
||||
zh: d46a07bdc6f36629
|
||||
en: 20acf6451fdc869b
|
||||
zh: d06246dd019ecef7
|
||||
/deepseek-harness/contributing:
|
||||
en: cabc2d3e9f8898bd
|
||||
zh: 83b6d238bae29ce3
|
||||
|
||||
@@ -44,7 +44,7 @@ pnpm dsh web
|
||||
|
||||
- Submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions).
|
||||
- Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability.
|
||||
- Join <a href="https://discord.gg/Ycq5dCaS4">DeepSeek Harness Discord community</a>.
|
||||
- Join <a href="https://discord.gg/4MrtZUhpxg">DeepSeek Harness Discord community</a>.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+1
-3
@@ -48,19 +48,17 @@ pnpm dsh web
|
||||
|
||||
- 通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。
|
||||
- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。
|
||||
- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。
|
||||
- 欢迎加入 DeepSeek Harness 企微群!扫描下方二维码填写入群问卷,小助手会定期发送入群邀请。
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="center">企微小助手</th>
|
||||
<th align="center">入群问卷</th>
|
||||
<th align="center">微信公众号</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center"><img src="https://cdn.deepseek.com/harness/readme/community-wecom-assistant.png" alt="DeepSeek Harness 企微小助手二维码" width="180" height="180"></td>
|
||||
<td align="center"><a href="https://trtgsjkv6r.feishu.cn/share/base/form/shrcnIt5twSVdLGD52KJBckGCgg"><img src="https://cdn.deepseek.com/harness/readme/community-wecom-survey.png" alt="DeepSeek Harness 入群问卷二维码" width="180" height="180"></a></td>
|
||||
<td align="center"><img src="https://cdn.deepseek.com/harness/readme/community-wechat-official-account.png" alt="DeepSeek Harness 团队微信公众号二维码" width="180" height="180"></td>
|
||||
</tr>
|
||||
|
||||
@@ -139,7 +139,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
|
||||
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
|
||||
|
||||
- `@earendil-works/[email protected]5.1` — [`patches/@[email protected]5.1.patch`](patches/@[email protected]5.1.patch)
|
||||
- `@earendil-works/[email protected]7.1` — [`patches/@[email protected]7.1.patch`](patches/@[email protected]7.1.patch)
|
||||
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
|
||||
reasoning: low
|
||||
modelOverrides:
|
||||
deepseek-v4-flash:
|
||||
deepseek-flash:
|
||||
maxTokens: 1024
|
||||
streamIdleTimeoutMs: 1000
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
agents:
|
||||
- id: main
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
model: deepseek-flash
|
||||
cwd: !!js process.cwd()
|
||||
|
||||
- id: agent-instructions
|
||||
|
||||
@@ -715,7 +715,7 @@ describe('headless stream-json snapshots', () => {
|
||||
expect(header?.config).toMatchInlineSnapshot(`
|
||||
{
|
||||
"maxTokens": 1024,
|
||||
"model": "deepseek-v4-flash",
|
||||
"model": "deepseek-flash",
|
||||
"provider": "deepseek",
|
||||
"reasoningEffort": "low",
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 069fdfd72d2ea26a
|
||||
zh: 76ae549d43c99bb4
|
||||
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
|
||||
en: 31cbb7452be8e1ad
|
||||
zh: 502ec556e2ec301e
|
||||
en: 320733f93ede61a4
|
||||
zh: 33206c3c4753c802
|
||||
/deepseek-harness-desktop/installation-ownership:
|
||||
en: 863ebc136313b73b
|
||||
zh: 6aa9a9925ceaa80a
|
||||
@@ -39,8 +39,8 @@
|
||||
en: f0e345fd89868803
|
||||
zh: fd2c775ad23fa2f0
|
||||
/deepseek-harness-desktop/package/upload-updates:
|
||||
en: c64d7fe2fe2534b7
|
||||
zh: 2188e1ae99ab4b33
|
||||
en: 72073b2e72590067
|
||||
zh: 63b1f9790395c37d
|
||||
/deepseek-harness-desktop/package/unsigned-windows-test-installer:
|
||||
en: cfd5780dd26a8e46
|
||||
zh: f7a16b1ede793b7b
|
||||
|
||||
@@ -60,7 +60,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers
|
||||
|
||||
The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.
|
||||
|
||||
Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
|
||||
Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
|
||||
|
||||
| Decision | Why | Direct consequence |
|
||||
|---|---|---|
|
||||
@@ -235,9 +235,11 @@ When extraction fails, the installer writes a report with the 7-Zip result and i
|
||||
|
||||
### Upload updates
|
||||
|
||||
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
|
||||
|
||||
Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/<environment>-<target>-*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result.
|
||||
|
||||
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
|
||||
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds/<target>/` and binaries use `dsh-desk/bin/<target>/`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk/<release-id>/feeds/<target>/` and `dsh-desk/<release-id>/bin/<target>/`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技
|
||||
|
||||
Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
|
||||
|
||||
Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
|
||||
Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
|
||||
|
||||
| 决策 | 原因 | 直接结果 |
|
||||
|---|---|---|
|
||||
@@ -237,9 +237,11 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运
|
||||
|
||||
### 上传更新
|
||||
|
||||
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
|
||||
|
||||
test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/<environment>-<target>-*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。
|
||||
|
||||
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
|
||||
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds/<target>/`,安装包位于 `dsh-desk/bin/<target>/`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk/<release-id>/feeds/<target>/` 和 `dsh-desk/<release-id>/bin/<target>/`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。
|
||||
|
||||
|
||||
@@ -35,7 +35,10 @@
|
||||
"package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir",
|
||||
"upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64",
|
||||
"upload:mac:x64": "tsx scripts/upload-target.ts mac-x64",
|
||||
"upload:win:x64": "tsx scripts/upload-target.ts win-x64"
|
||||
"upload:win:x64": "tsx scripts/upload-target.ts win-x64",
|
||||
"upload:latest:mac:arm64": "tsx scripts/upload-target.ts mac-arm64 --latest",
|
||||
"upload:latest:mac:x64": "tsx scripts/upload-target.ts mac-x64 --latest",
|
||||
"upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9",
|
||||
|
||||
@@ -38,11 +38,12 @@ export interface DesktopUploadArtifact {
|
||||
readonly contents?: string
|
||||
}
|
||||
|
||||
/** A fully validated upload operation with channel metadata ordered last. */
|
||||
/** A validated installer or update upload, with any channel metadata ordered last. */
|
||||
export interface DesktopUploadPlan {
|
||||
readonly environment: 'test' | 'production'
|
||||
readonly target: DesktopPackageTargetName
|
||||
readonly version: string
|
||||
/** Update feed directory URL, or the single installer URL for a fixed download. */
|
||||
readonly publicUrl: string
|
||||
readonly bucket: string
|
||||
readonly secretIdEnvName: string
|
||||
@@ -56,6 +57,8 @@ export interface DesktopUploadPlan {
|
||||
|
||||
/** Filesystem and environment inputs used to validate one upload. */
|
||||
export interface DesktopUploadPlanOptions {
|
||||
/** Publish only the installer at its fixed download URL, replacing the previous object. */
|
||||
readonly latest?: boolean
|
||||
readonly environment?: NodeJS.ProcessEnv
|
||||
readonly repositoryRoot?: string
|
||||
readonly appRoot?: string
|
||||
@@ -171,7 +174,7 @@ function uploadArtifact(
|
||||
* Validate the completed package record, dsh version, update metadata, hashes, and target files.
|
||||
* @param targetName - Fixed platform and architecture selected by the upload command.
|
||||
* @param options - Optional filesystem roots and environment for tests or release automation.
|
||||
* @returns An upload plan whose mutable channel metadata is the final entry.
|
||||
* @returns A fixed installer upload or an update plan with channel metadata ordered last.
|
||||
*/
|
||||
export async function createDesktopUploadPlan(
|
||||
targetName: DesktopPackageTargetName,
|
||||
@@ -237,23 +240,26 @@ export async function createDesktopUploadPlan(
|
||||
const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo)
|
||||
const artifacts: DesktopUploadArtifact[] = []
|
||||
const binaryPrefix = update.binaryKeyPrefix
|
||||
let installerArtifact: DesktopUploadArtifact
|
||||
|
||||
if (target.platform === 'darwin') {
|
||||
const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`)
|
||||
const blockmapPath = await requireArtifact(artifactsRoot, `${base}.zip.blockmap`)
|
||||
installerArtifact = uploadArtifact(dmgPath, binaryPrefix, 'application/x-apple-diskimage')
|
||||
artifacts.push(
|
||||
uploadArtifact(dmgPath, binaryPrefix, 'application/x-apple-diskimage'),
|
||||
installerArtifact,
|
||||
uploadArtifact(updaterPath, binaryPrefix, 'application/zip'),
|
||||
uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'),
|
||||
)
|
||||
}
|
||||
else {
|
||||
const blockmapPath = await requireArtifact(artifactsRoot, `${base}.exe.blockmap`)
|
||||
artifacts.push(uploadArtifact(
|
||||
installerArtifact = uploadArtifact(
|
||||
updaterPath,
|
||||
binaryPrefix,
|
||||
'application/vnd.microsoft.portable-executable',
|
||||
))
|
||||
)
|
||||
artifacts.push(installerArtifact)
|
||||
artifacts.push(uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'))
|
||||
}
|
||||
|
||||
@@ -272,15 +278,17 @@ export async function createDesktopUploadPlan(
|
||||
const stableFilename = metadataFilename.replace('nightly', 'latest')
|
||||
artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` })
|
||||
}
|
||||
const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}`
|
||||
const latestKey = `desktop/${latestFilename}`
|
||||
return {
|
||||
environment: update.environment,
|
||||
target: targetName,
|
||||
version: buildVersion,
|
||||
publicUrl: update.publicUrl,
|
||||
publicUrl: options.latest ? `${update.origin}/${latestKey}` : update.publicUrl,
|
||||
bucket: update.bucket,
|
||||
secretIdEnvName: update.secretIdEnvName,
|
||||
secretKeyEnvName: update.secretKeyEnvName,
|
||||
artifacts,
|
||||
artifacts: options.latest ? [{ ...installerArtifact, filename: latestFilename, key: latestKey }] : artifacts,
|
||||
...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {},
|
||||
...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Durable, credential-free evidence for test and production release uploads. */
|
||||
/** Durable, credential-free evidence for test and production Desktop uploads. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
@@ -52,7 +52,7 @@ function streamedBody(artifact: DesktopUploadArtifact): Readable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an already validated release, flushing intent and response evidence around every PUT.
|
||||
* Upload an already validated installer or update plan, flushing intent and response evidence around every PUT.
|
||||
*
|
||||
* Each object is sent as one streamed PUT with an explicit length and Content-MD5, which is also
|
||||
* what keeps the COS SDK's internal retry path unreachable: it repeats a request only when the
|
||||
|
||||
@@ -24,11 +24,12 @@ function magic(path: string): string {
|
||||
* @param root - Self-contained production runtime without symlinks.
|
||||
* @param appId - Release application identifier.
|
||||
* @param expected - Required signing identity.
|
||||
* @param cacheDirectory Optional content-addressed cache; requires the keychain-owned signing probe.
|
||||
* @param arch - Target runtime architecture, independent of the signing host.
|
||||
* @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe.
|
||||
* @returns Number of signed native files.
|
||||
*/
|
||||
export async function signMacOSRuntime(
|
||||
root: string, appId: string, expected: MacOSSigningEnvironment, cacheDirectory?: string,
|
||||
root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string,
|
||||
): Promise<number> {
|
||||
const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path))))
|
||||
const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '')
|
||||
@@ -40,9 +41,12 @@ export async function signMacOSRuntime(
|
||||
const path = files[next++]
|
||||
if (path === undefined) return
|
||||
const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}`
|
||||
const needsJit = path === 'dependencies/node/bin/node'
|
||||
const isNode = path === 'dependencies/node/bin/node'
|
||||
const needsJit = isNode
|
||||
|| /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path)
|
||||
const entitlements = needsJit ? join(import.meta.dirname, 'jit-entitlements.plist') : undefined
|
||||
const entitlementsFile = isNode && arch === 'x64'
|
||||
? 'node-x64-entitlements.plist' : 'jit-entitlements.plist'
|
||||
const entitlements = needsJit ? join(import.meta.dirname, entitlementsFile) : undefined
|
||||
const file = join(root, path)
|
||||
const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file))
|
||||
if (cacheDirectory !== undefined && policy !== undefined && thin) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict><key>com.apple.security.cs.allow-jit</key><true/><key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/></dict></plist>
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import {
|
||||
signMacOSRuntime,
|
||||
} from './macos-runtime.ts'
|
||||
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { desktopTargetPlatform, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts'
|
||||
import { selectOfficeEngine } from '../../../scripts/libreoffice-packages.mjs'
|
||||
|
||||
@@ -130,7 +130,7 @@ async function main(): Promise<void> {
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']))
|
||||
const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version)
|
||||
const targetName = resolveDesktopBuildTarget()
|
||||
const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' }
|
||||
const target = { platform: process.platform, arch: desktopTargetPlatform(targetName).arch }
|
||||
const modules = join(BUILD_ROOT, 'node_modules')
|
||||
const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8'))
|
||||
const officeEngine = selectOfficeEngine(officeManifest, target)
|
||||
@@ -154,8 +154,8 @@ async function main(): Promise<void> {
|
||||
throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache')))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache')))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache')))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache')))
|
||||
}
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Upload one validated Desktop release to its Tencent COS update directory. */
|
||||
/** Upload validated Desktop update artifacts or a fixed installer to Tencent COS. */
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
@@ -58,8 +58,14 @@ export function resolveCredentialUploadEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { positionals, values } = parseArgs({ args: process.argv.slice(2), allowPositionals: true, options: {
|
||||
/**
|
||||
* Upload the selected target and record a release tag only for production update uploads.
|
||||
* @param args Target and upload options from the command line.
|
||||
* @returns Resolves after upload and any release-tag attempt complete.
|
||||
*/
|
||||
export async function uploadDesktopTarget(args: string[]): Promise<void> {
|
||||
const { positionals, values } = parseArgs({ args, allowPositionals: true, options: {
|
||||
latest: { type: 'boolean' },
|
||||
'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' },
|
||||
} })
|
||||
const target = positionals[0]
|
||||
@@ -79,14 +85,14 @@ async function main(): Promise<void> {
|
||||
const environment = launcher
|
||||
? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name)
|
||||
: fileEnvironment
|
||||
const plan = await createDesktopUploadPlan(name, { environment })
|
||||
const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true })
|
||||
const cos = createDesktopCos({
|
||||
secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName),
|
||||
secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName),
|
||||
})
|
||||
process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)
|
||||
await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records'))
|
||||
if (plan.environment === 'production') recordProductionRelease(plan)
|
||||
if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +122,7 @@ function recordProductionRelease(plan: DesktopUploadPlan): void {
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
|
||||
main().catch(() => {
|
||||
uploadDesktopTarget(process.argv.slice(2)).catch(() => {
|
||||
process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n')
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ Deployment that owns the credential pair; never inferred from the filename.
|
||||
Completed Desktop target to upload when Upload is explicitly selected.
|
||||
.PARAMETER Bucket
|
||||
COS bucket for an explicit upload. Not needed for the local credential check.
|
||||
.PARAMETER Latest
|
||||
Publish only the installer at its fixed download URL. Requires Upload.
|
||||
.PARAMETER Upload
|
||||
Authorize the existing target upload entry. Omit to check credentials locally.
|
||||
#>
|
||||
@@ -32,7 +34,9 @@ param(
|
||||
[ValidatePattern('^[a-z0-9-]+$')]
|
||||
[string]$Bucket,
|
||||
[Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
|
||||
[switch]$Upload
|
||||
[switch]$Upload,
|
||||
[Parameter(ParameterSetName = 'Publish')]
|
||||
[switch]$Latest
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -87,6 +91,7 @@ try {
|
||||
if ($Upload) {
|
||||
$startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket
|
||||
$startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket"
|
||||
if ($Latest) { $startInfo.Arguments += " --latest" }
|
||||
Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
|
||||
} else {
|
||||
$probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
@@ -8,12 +8,15 @@ import { load } from 'js-yaml'
|
||||
import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
|
||||
import { desktopUpdateMetadataFilename } from '../scripts/desktop-auto-update-environment.mjs'
|
||||
import type { DesktopPackageTargetName } from '../scripts/package-target.ts'
|
||||
import { createDesktopCos } from '../scripts/desktop-cos.ts'
|
||||
import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts'
|
||||
import { startCosLoopback } from './cos-loopback.ts'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const TEST_ORIGIN = 'https://desktop-updates.example.com'
|
||||
const TEST_BUCKET = 'test-download-bucket'
|
||||
const TEST_BUCKET = 'test-download-bucket-1250000000'
|
||||
const RELEASE_ID = '0123456789abcdef0123456789abcdef'
|
||||
const PRODUCTION_BUCKET = 'production-download-bucket'
|
||||
const PRODUCTION_BUCKET = 'production-download-bucket-1250000000'
|
||||
const require = createRequire(import.meta.url)
|
||||
const { createBlockmap } = require('app-builder-lib/out/targets/differentialUpdateInfoBuilder.js') as {
|
||||
createBlockmap: (file: string, target: object, packager: { info: { emitArtifactBuildCompleted(event: object): Promise<void> } },
|
||||
@@ -110,6 +113,64 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('desktop upload plan', () => {
|
||||
it('uploads only the selected latest installer to each deployment using the existing COS transport', async () => {
|
||||
const published = []
|
||||
for (const environment of ['production', 'test'] as const) {
|
||||
for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) {
|
||||
const paths = await fixture(target, '1.2.3', environment)
|
||||
const plan = await createDesktopUploadPlan(target, { ...paths, latest: true })
|
||||
const loopback = await startCosLoopback()
|
||||
try {
|
||||
const cos = createDesktopCos({ secretId: 'fixture-id', secretKey: 'fixture-secret' })
|
||||
loopback.redirect(cos)
|
||||
const directory = await uploadDesktopRelease(plan, cos, join(paths.appRoot, 'records'))
|
||||
expect(plan.artifacts).toHaveLength(1)
|
||||
const artifact = plan.artifacts[0]!
|
||||
expect(loopback.requests).toHaveLength(1)
|
||||
const request = loopback.requests[0]!
|
||||
expect(request.method).toBe('PUT')
|
||||
expect(request.path).toBe(`/${artifact.key}`)
|
||||
expect(request.body).toEqual(await readFile(artifact.path))
|
||||
expect(JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))).toMatchObject({
|
||||
success: true, confirmedPuts: 1, publicReadback: 'not-performed',
|
||||
})
|
||||
published.push({ environment, target, bucket: plan.bucket, publicUrl: plan.publicUrl,
|
||||
filename: artifact.filename, key: artifact.key, contentType: artifact.contentType,
|
||||
channelMetadata: artifact.channelMetadata })
|
||||
} finally {
|
||||
await loopback.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
await expect(`${JSON.stringify(published, null, 2)}\n`).toMatchFileSnapshot('./expected/latest-installer-uploads.json')
|
||||
})
|
||||
|
||||
it('allows an explicitly selected production prerelease at the fixed installer URL', async () => {
|
||||
const paths = await fixture('win-x64', '1.2.3-alpha.4', 'production')
|
||||
const plan = await createDesktopUploadPlan('win-x64', { ...paths, latest: true })
|
||||
expect(plan.version).toBe('1.2.3-alpha.4')
|
||||
expect(plan.artifacts).toHaveLength(1)
|
||||
expect(plan.artifacts[0]).toMatchObject({
|
||||
path: join(paths.artifactsRoot, 'deepseek-harness-1.2.3-alpha.4-win-x64.exe'),
|
||||
key: 'desktop/dsh-latest-windows-x64.exe', channelMetadata: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['completion', 'deployment', 'checksum'] as const)('rejects invalid %s before planning a latest upload', async (failure) => {
|
||||
const paths = await fixture('win-x64', '1.2.3', 'production')
|
||||
if (failure === 'completion') await rm(join(paths.artifactsRoot, 'win-x64-release.json'))
|
||||
if (failure === 'deployment') Object.assign(paths.environment, {
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN,
|
||||
DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET,
|
||||
})
|
||||
if (failure === 'checksum') {
|
||||
const path = join(paths.artifactsRoot, 'deepseek-harness-1.2.3-win-x64.exe')
|
||||
await writeFile(path, Buffer.alloc((await readFile(path)).length))
|
||||
}
|
||||
await expect(createDesktopUploadPlan('win-x64', { ...paths, latest: true }))
|
||||
.rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u)
|
||||
})
|
||||
|
||||
it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => {
|
||||
const paths = await fixture('win-x64', '1.2.3', 'production')
|
||||
const plan = await createDesktopUploadPlan('win-x64', paths)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"environment": "production",
|
||||
"target": "mac-arm64",
|
||||
"bucket": "production-download-bucket-1250000000",
|
||||
"publicUrl": "https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg",
|
||||
"filename": "dsh-latest-macos-arm64.dmg",
|
||||
"key": "desktop/dsh-latest-macos-arm64.dmg",
|
||||
"contentType": "application/x-apple-diskimage",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "production",
|
||||
"target": "mac-x64",
|
||||
"bucket": "production-download-bucket-1250000000",
|
||||
"publicUrl": "https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg",
|
||||
"filename": "dsh-latest-macos-x64.dmg",
|
||||
"key": "desktop/dsh-latest-macos-x64.dmg",
|
||||
"contentType": "application/x-apple-diskimage",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "production",
|
||||
"target": "win-x64",
|
||||
"bucket": "production-download-bucket-1250000000",
|
||||
"publicUrl": "https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe",
|
||||
"filename": "dsh-latest-windows-x64.exe",
|
||||
"key": "desktop/dsh-latest-windows-x64.exe",
|
||||
"contentType": "application/vnd.microsoft.portable-executable",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "test",
|
||||
"target": "mac-arm64",
|
||||
"bucket": "test-download-bucket-1250000000",
|
||||
"publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-macos-arm64.dmg",
|
||||
"filename": "dsh-latest-macos-arm64.dmg",
|
||||
"key": "desktop/dsh-latest-macos-arm64.dmg",
|
||||
"contentType": "application/x-apple-diskimage",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "test",
|
||||
"target": "mac-x64",
|
||||
"bucket": "test-download-bucket-1250000000",
|
||||
"publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-macos-x64.dmg",
|
||||
"filename": "dsh-latest-macos-x64.dmg",
|
||||
"key": "desktop/dsh-latest-macos-x64.dmg",
|
||||
"contentType": "application/x-apple-diskimage",
|
||||
"channelMetadata": false
|
||||
},
|
||||
{
|
||||
"environment": "test",
|
||||
"target": "win-x64",
|
||||
"bucket": "test-download-bucket-1250000000",
|
||||
"publicUrl": "https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe",
|
||||
"filename": "dsh-latest-windows-x64.exe",
|
||||
"key": "desktop/dsh-latest-windows-x64.exe",
|
||||
"contentType": "application/vnd.microsoft.portable-executable",
|
||||
"channelMetadata": false
|
||||
}
|
||||
]
|
||||
@@ -51,7 +51,9 @@ it('includes certificate, identifier and entitlement contents in the cache polic
|
||||
const create = macOSCachePolicy('probe')
|
||||
const original = create('com.example.file', identity).policy
|
||||
expect(create('com.other.file', identity).policy).not.toBe(original)
|
||||
expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy).not.toBe(original)
|
||||
const jit = create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy
|
||||
expect(jit).not.toBe(original)
|
||||
expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/node-x64-entitlements.plist')).policy).not.toBe(jit)
|
||||
certificate = 'renewed-certificate'
|
||||
expect(macOSCachePolicy('probe')('com.example.file', identity).policy).not.toBe(original)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
@@ -21,7 +21,7 @@ it('signs Mach-O files in their final locations and verifies each signature', as
|
||||
const path = root()
|
||||
writeFileSync(join(path, 'addon.node'), Buffer.from('cffaedfe00000000', 'hex'))
|
||||
writeFileSync(join(path, 'source.js'), 'export {}')
|
||||
await expect(signMacOSRuntime(path, 'com.example.app', identity)).resolves.toBe(1)
|
||||
await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).resolves.toBe(1)
|
||||
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity, undefined)
|
||||
expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity)
|
||||
})
|
||||
@@ -38,7 +38,7 @@ it('awaits other signers before rejecting and permitting output cleanup', async
|
||||
await barrier
|
||||
})
|
||||
let completed = false
|
||||
const result = signMacOSRuntime(path, 'com.example.app', identity).catch((error: unknown) => { completed = true; return error })
|
||||
const result = signMacOSRuntime(path, 'com.example.app', identity, 'arm64').catch((error: unknown) => { completed = true; return error })
|
||||
try {
|
||||
await ready
|
||||
expect(completed).toBe(false)
|
||||
@@ -47,16 +47,21 @@ it('awaits other signers before rejecting and permitting output cleanup', async
|
||||
expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity)
|
||||
})
|
||||
|
||||
it('grants JIT only to the standalone Node interpreter and native Office helpers', async () => {
|
||||
it.each(['arm64', 'x64'] as const)('selects %s Node entitlements and keeps helpers JIT-only', async (arch) => {
|
||||
const path = root()
|
||||
mkdirSync(join(path, 'dependencies/node/bin'), { recursive: true })
|
||||
const node = join(path, 'dependencies/node/bin/node')
|
||||
const addon = join(path, 'addon.node')
|
||||
const helpers = ['arm64', 'x64'].map(arch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${arch}`, 'bin/libreoffice-kit'))
|
||||
const helpers = ['arm64', 'x64'].map(helperArch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${helperArch}`, 'bin/libreoffice-kit'))
|
||||
for (const helper of helpers) mkdirSync(join(helper, '..'), { recursive: true })
|
||||
for (const file of [node, addon, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex'))
|
||||
await signMacOSRuntime(path, 'com.example.app', identity)
|
||||
for (const file of [node, ...helpers]) {
|
||||
await signMacOSRuntime(path, 'com.example.app', identity, arch)
|
||||
const nodePlist = join(import.meta.dirname, '../scripts', arch === 'x64' ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist')
|
||||
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(node, expect.any(String), identity, nodePlist)
|
||||
const xml = readFileSync(nodePlist, 'utf8')
|
||||
expect(xml).toMatch(/<key>com\.apple\.security\.cs\.allow-jit<\/key>\s*<true\s*\/>/u)
|
||||
expect(/<key>com\.apple\.security\.cs\.allow-unsigned-executable-memory<\/key>\s*<true\s*\/>/u.test(xml)).toBe(arch === 'x64')
|
||||
for (const file of helpers) {
|
||||
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(file, expect.any(String), identity,
|
||||
join(import.meta.dirname, '../scripts/jit-entitlements.plist'))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createDesktopUploadPlan, type DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
|
||||
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
import { tagDesktopRelease } from '../scripts/desktop-release-tag.ts'
|
||||
import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts'
|
||||
import { uploadDesktopTarget } from '../scripts/upload-target.ts'
|
||||
|
||||
vi.mock('../scripts/desktop-package-environment.mjs', () => ({ loadDesktopPackageEnvironment: vi.fn() }))
|
||||
vi.mock('../scripts/desktop-upload-plan.ts', () => ({ createDesktopUploadPlan: vi.fn() }))
|
||||
vi.mock('../scripts/desktop-cos.ts', () => ({ createDesktopCos: vi.fn() }))
|
||||
vi.mock('../scripts/desktop-upload-run.ts', () => ({ uploadDesktopRelease: vi.fn() }))
|
||||
vi.mock('../scripts/desktop-release-tag.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../scripts/desktop-release-tag.ts')>(),
|
||||
tagDesktopRelease: vi.fn(),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('desktop upload command', () => {
|
||||
it.each([
|
||||
{ environment: 'production', latest: true, tagged: false },
|
||||
{ environment: 'production', latest: false, tagged: true },
|
||||
{ environment: 'test', latest: true, tagged: false },
|
||||
{ environment: 'test', latest: false, tagged: false },
|
||||
] as const)('uploads $environment latest=$latest with tagged=$tagged', async ({ environment, latest, tagged }) => {
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
const credentials = { DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' }
|
||||
const plan: DesktopUploadPlan = {
|
||||
environment, target: 'win-x64', version: '1.2.3-alpha.4',
|
||||
publicUrl: 'https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe',
|
||||
bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_TEST_COS_SECRET_ID',
|
||||
secretKeyEnvName: 'DOWNLOAD_TEST_COS_SECRET_KEY', artifacts: [], commit: '0'.repeat(40), dirty: false,
|
||||
}
|
||||
vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(credentials)
|
||||
vi.mocked(createDesktopUploadPlan).mockResolvedValue(plan)
|
||||
vi.mocked(uploadDesktopRelease).mockResolvedValue('fixture-records')
|
||||
vi.mocked(tagDesktopRelease).mockReturnValue({ status: 'created', tag: 'desktop-v1.2.3-alpha.4' })
|
||||
|
||||
await uploadDesktopTarget(['win-x64', ...latest ? ['--latest'] : []])
|
||||
|
||||
expect(createDesktopUploadPlan).toHaveBeenCalledWith('win-x64', { environment: credentials, latest })
|
||||
expect(uploadDesktopRelease).toHaveBeenCalledOnce()
|
||||
expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan)
|
||||
if (tagged) {
|
||||
expect(tagDesktopRelease).toHaveBeenCalledWith(expect.objectContaining({ version: plan.version, commit: plan.commit }))
|
||||
expect(vi.mocked(tagDesktopRelease).mock.invocationCallOrder[0])
|
||||
.toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!)
|
||||
}
|
||||
else {
|
||||
expect(tagDesktopRelease).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1',
|
||||
const roots: string[] = []
|
||||
const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`
|
||||
|
||||
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-failure', deployment = 'production') {
|
||||
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure', deployment = 'production') {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-'))
|
||||
roots.push(root)
|
||||
const credentialPath = join(root, 'credential.clixml')
|
||||
let entry = launcher
|
||||
let uploadArguments = ''
|
||||
if (mode === 'upload' || mode === 'upload-failure') {
|
||||
if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure') {
|
||||
const scripts = join(root, 'apps/desktop/scripts')
|
||||
await mkdir(scripts, { recursive: true })
|
||||
entry = join(scripts, 'upload-with-credentials.ps1')
|
||||
@@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload
|
||||
assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined)
|
||||
assert.equal(process.env.NODE_OPTIONS, undefined)
|
||||
assert.equal(process.argv[2], 'win-x64')
|
||||
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'])
|
||||
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-latest' ? ", '--latest'" : ''}])
|
||||
console.log('fixture-id fixture-secret')
|
||||
console.error('private service details fixture-secret')
|
||||
process.exit(${mode === 'upload-failure' ? 17 : 0})
|
||||
`)
|
||||
uploadArguments = ' -Upload -Target win-x64 -Bucket fixture-bucket'
|
||||
uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-latest' ? ' -Latest' : ''}`
|
||||
}
|
||||
const setup = mode === 'missing' ? '' : `
|
||||
[pscustomobject]@{
|
||||
@@ -134,9 +134,9 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () =
|
||||
expect(result.output).not.toContain('child environment verified')
|
||||
})
|
||||
|
||||
it.each(['upload', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
|
||||
it.each(['upload', 'upload-latest', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
|
||||
const result = await check(mode)
|
||||
expect(result.code, result.output).toBe(mode === 'upload' ? 0 : 1)
|
||||
expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0)
|
||||
expect(result.output).toContain('[REDACTED] [REDACTED]')
|
||||
expect(result.output).not.toContain('fixture-id')
|
||||
expect(result.output).not.toContain('fixture-secret')
|
||||
|
||||
@@ -115,7 +115,8 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
|
||||
expect(slashPickerBox).not.toBeNull()
|
||||
expect(composerBox).not.toBeNull()
|
||||
expect(slashPickerBox!.width).toBeGreaterThanOrEqual(220)
|
||||
expect(composerBox!.width - slashPickerBox!.width).toBeGreaterThan(1)
|
||||
expect(slashPickerBox!.width).toBeCloseTo(composerBox!.width)
|
||||
expect(slashPickerBox!.x).toBeCloseTo(composerBox!.x)
|
||||
expect(Math.abs(composerBox!.y - slashPickerBox!.y - slashPickerBox!.height - 4)).toBeLessThan(1)
|
||||
|
||||
await slashPicker.getByRole('option', { name: 'Auto review EXP' }).click()
|
||||
|
||||
@@ -56,8 +56,12 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
|
||||
for (const fullscreen of [false, true, false]) {
|
||||
await page.evaluate(value => document.documentElement.toggleAttribute('data-fullscreen', value), fullscreen)
|
||||
const margin = fullscreen ? 24 : 60
|
||||
await expect.poll(async () => (await plugin.boundingBox())?.y).toBe(margin)
|
||||
expect((await plugin.boundingBox())?.height).toBe(600 - 2 * margin)
|
||||
// The dialog is content-sized: it fills the available height only while its content needs it,
|
||||
// and otherwise stays centered inside the same clearances.
|
||||
await expect.poll(async () => {
|
||||
const box = await plugin.boundingBox()
|
||||
return box !== null && box.y >= margin && box.y + box.height <= 600 - margin
|
||||
}, { timeout: 5_000 }).toBe(true)
|
||||
expect((await plugin.locator('..').locator(':scope > [aria-hidden="true"]').boundingBox())?.y).toBe(fullscreen ? 0 : 40)
|
||||
expect(await plugin.getByRole('button', { name: '关闭', exact: true }).isVisible()).toBe(true)
|
||||
expect(await plugin.getByRole('button', { name: '安装', exact: true }).isVisible()).toBe(true)
|
||||
|
||||
@@ -46,6 +46,7 @@ describe.skipIf(MODE === 'record').each([
|
||||
models: [
|
||||
{ id: 'acme-think', name: 'Acme Think' },
|
||||
{ id: 'acme-swift', name: 'Acme Swift' },
|
||||
{ id: 'acme-lite', name: 'Acme Lite' },
|
||||
].map(model => ({
|
||||
...model,
|
||||
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
|
||||
@@ -148,8 +149,10 @@ describe.skipIf(MODE === 'record').each([
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
} else {
|
||||
await page.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ }).click()
|
||||
await expect.poll(() => page.locator('[role="menuitemradio"][aria-checked="true"]')
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
const focused = pane === 'model'
|
||||
? page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
: page.locator('[role="menuitemradio"][aria-checked="true"]')
|
||||
await expect.poll(() => focused.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
@@ -172,22 +175,27 @@ describe.skipIf(MODE === 'record').each([
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
const current = page.getByRole('menuitemradio', { name: 'Acme Think', exact: true })
|
||||
const target = page.getByRole('menuitemradio', { name: 'Acme Swift', exact: true })
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id'))
|
||||
|
||||
// Native mousedown must not blur the focused row and unmount the menu before click in WebKit.
|
||||
// Native mousedown must not blur search and unmount the menu before click in WebKit.
|
||||
await target.getByText('Acme Swift', { exact: true }).hover()
|
||||
await page.mouse.down()
|
||||
try {
|
||||
await expect.poll(() => menu.count()).toBe(1)
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
await page.getByText('Acme Gateway', { exact: true }).hover()
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
// Later selection counts also include any request from this cancelled press.
|
||||
expect(selections).toBe(0)
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await search.press('ArrowUp')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id'))
|
||||
await search.press('ArrowDown')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id'))
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
@@ -222,16 +230,20 @@ describe.skipIf(MODE === 'record').each([
|
||||
}, { times: 1 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id'))
|
||||
await current.click()
|
||||
await page.getByRole('alert').waitFor()
|
||||
expect(selections).toBe(3)
|
||||
await compareOrRefreshGolden(POINTER_EXPECTED, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
|
||||
await compareOrRefreshGolden(POINTER_EXPECTED,
|
||||
await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), MODE)
|
||||
await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await target.getAttribute('id'))
|
||||
await search.press('ArrowUp')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(await current.getAttribute('id'))
|
||||
expect(await search.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
|
||||
@@ -80,7 +80,13 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'picker.expected.md'),
|
||||
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('menuitem', { name: /推理等级/ }).click()
|
||||
const effortWeights = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row =>
|
||||
getComputedStyle(row.querySelector('span span')!).fontWeight,
|
||||
))
|
||||
expect(new Set(effortWeights)).toEqual(new Set(['400']))
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -73,7 +73,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: MODEL, name: 'Acme Large' }],
|
||||
models: [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -92,6 +92,223 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('keeps command popup search borders transparent in both palettes', async () => {
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
const commandMenuBounds = await page.locator('[data-trigger-menu]').boundingBox()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
const search = page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await search.waitFor()
|
||||
try {
|
||||
const popupBounds = await page.locator('[aria-label="/model 选项"]').boundingBox()
|
||||
expect(popupBounds).not.toBeNull()
|
||||
expect(commandMenuBounds).not.toBeNull()
|
||||
expect(popupBounds!.width).toBeCloseTo(commandMenuBounds!.width)
|
||||
expect(popupBounds!.x).toBeCloseTo(commandMenuBounds!.x)
|
||||
expect(await search.getAttribute('placeholder')).toBe('搜索模型…')
|
||||
await page.getByRole('option').first().waitFor()
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/default-model/command-picker.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[aria-label="/model 选项"]', scaffold.workspaceCwd),
|
||||
webSnapshotMode(),
|
||||
)
|
||||
await search.fill('no-model-matches')
|
||||
await page.getByText('没有匹配的模型。', { exact: true }).waitFor()
|
||||
await search.fill('')
|
||||
const borders = await search.evaluate((input) => {
|
||||
const body = input.ownerDocument.body
|
||||
const previousTheme = body.getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
return [false, true].map((dark) => {
|
||||
body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
return getComputedStyle(input).borderColor
|
||||
})
|
||||
} finally {
|
||||
if (previousTheme === null) body.removeAttribute('data-ds-dark-theme')
|
||||
else body.setAttribute('data-ds-dark-theme', previousTheme)
|
||||
}
|
||||
})
|
||||
expect(borders).toEqual(['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'])
|
||||
} finally {
|
||||
await search.press('Escape')
|
||||
await composer.fill('')
|
||||
}
|
||||
})
|
||||
|
||||
it('shares provider order, fuzzy result order and sticky header material between both model pickers', async () => {
|
||||
const readGroups = (surface: Locator, role: 'option' | 'menuitemradio') => surface.locator('[data-menu-group]')
|
||||
.evaluateAll((groups, rowRole) => groups.map(group => ({
|
||||
label: group.querySelector('[data-menu-group-heading]')!.textContent,
|
||||
rows: [...group.querySelectorAll(`[role="${rowRole}"]`)].map(row => row.textContent),
|
||||
})), role)
|
||||
const checkSticky = async (surface: Locator, viewport: Locator) => {
|
||||
// Bound only this test's list viewport so native scrolling can cross a heading.
|
||||
await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 })
|
||||
const heading = surface.locator('[data-menu-group-heading]').first()
|
||||
await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull()
|
||||
const clear = await heading.evaluate(node => getComputedStyle(node).backgroundColor)
|
||||
expect(clear).toBe('rgba(0, 0, 0, 0)')
|
||||
await viewport.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(() => heading.getAttribute('data-stuck')).toBe('')
|
||||
const stuck = await heading.evaluate((node) => {
|
||||
const style = getComputedStyle(node)
|
||||
return { fill: style.backgroundColor, radius: style.borderRadius, font: style.fontSize, padding: style.padding }
|
||||
})
|
||||
await viewport.evaluate((node) => { node.scrollTop = 0 })
|
||||
await expect.poll(() => heading.getAttribute('data-stuck')).toBeNull()
|
||||
return stuck
|
||||
}
|
||||
try {
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
const menu = page.getByRole('group', { name: '模型与推理等级', exact: true })
|
||||
const menuSearch = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
const order = await readGroups(menu, 'menuitemradio')
|
||||
const sticky = await checkSticky(menu, menu.getByRole('menu', { name: '模型', exact: true }))
|
||||
await menuSearch.fill(' ACMLG ')
|
||||
const filtered = await readGroups(menu, 'menuitemradio')
|
||||
await menuSearch.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
const popup = page.locator('[aria-label="/model 选项"]')
|
||||
await popup.getByRole('option').first().waitFor()
|
||||
expect(await readGroups(popup, 'option')).toEqual(order)
|
||||
expect(await checkSticky(popup, popup.getByRole('listbox'))).toEqual(sticky)
|
||||
const popupSearch = page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await popupSearch.fill(' ACMLG ')
|
||||
expect(await readGroups(popup, 'option')).toEqual(filtered)
|
||||
expect(await popupSearch.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.locator('[data-composer-input]').first().fill('')
|
||||
}
|
||||
})
|
||||
|
||||
it('hides search only in the button menu when four models remain', async () => {
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
const setModels = (expanded: boolean) => scaffold.ctx.settings.update('llm-pi-ai', {
|
||||
providers: { [ROUTE]: {
|
||||
displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1',
|
||||
models: expanded ? [{ id: MODEL, name: 'Acme Large' }, { id: 'acme-small', name: 'Acme Small' }]
|
||||
: [{ id: MODEL, name: 'Acme Large' }],
|
||||
} },
|
||||
})
|
||||
try {
|
||||
await setModels(false)
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(4)
|
||||
expect(await search.count()).toBe(0)
|
||||
const current = page.getByRole('menuitemradio', { name: 'Origin Large', exact: true })
|
||||
await expect.poll(() => current.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
const rows = page.getByRole('menuitemradio')
|
||||
const currentId = await current.getAttribute('id')
|
||||
for (let index = 0; index < 4; index++) await page.keyboard.press('ArrowDown')
|
||||
expect(await page.evaluate(() => document.activeElement?.id)).toBe(currentId)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => trigger.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await trigger.evaluate(node => getComputedStyle(node).boxShadow)).toBe('none')
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
const commandSearch = page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await commandSearch.waitFor()
|
||||
expect(await commandSearch.getAttribute('placeholder')).toBe('搜索模型…')
|
||||
await commandSearch.press('Escape')
|
||||
await page.locator('[data-composer-input]').first().fill('')
|
||||
expect(await rows.count()).toBe(0)
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await setModels(true)
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await expect.poll(() => page.getByRole('menuitemradio').count()).toBe(5)
|
||||
await search.waitFor()
|
||||
await search.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
|
||||
it('paints only pinned provider headings across themes, filtering, and reopening', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-model-sticky-headings'))
|
||||
const originalViewport = page.viewportSize()!
|
||||
const attributes = await page.evaluate(() => ({
|
||||
platform: document.documentElement.getAttribute('data-platform'),
|
||||
theme: document.body.getAttribute('data-ds-dark-theme'),
|
||||
}))
|
||||
const surface = page.getByRole('group', { name: '模型与推理等级', exact: true })
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
try {
|
||||
await page.setViewportSize({ width: 1680, height: 220 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
const scroller = page.getByRole('menu', { name: '模型', exact: true })
|
||||
const headings = surface.locator('section[role="group"] > div')
|
||||
const readPinned = () => headings.evaluateAll(nodes => nodes.map(node => node.hasAttribute('data-stuck')))
|
||||
const resetScroll = async (): Promise<void> => {
|
||||
await scroller.evaluate((node) => { node.scrollTop = 0 })
|
||||
await expect.poll(readPinned).toEqual([false, false, false])
|
||||
}
|
||||
for (const platform of ['web', 'win32', 'darwin']) {
|
||||
for (const dark of [false, true]) {
|
||||
await page.evaluate(({ platform, dark }) => {
|
||||
if (platform === 'web') document.documentElement.removeAttribute('data-platform')
|
||||
else document.documentElement.setAttribute('data-platform', platform)
|
||||
document.body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
}, { platform, dark })
|
||||
await resetScroll()
|
||||
expect(await headings.evaluateAll(nodes => [...new Set(nodes.map(node => getComputedStyle(node).backgroundColor))]))
|
||||
.toEqual(['rgba(0, 0, 0, 0)'])
|
||||
const secondTop = await scroller.evaluate((node) => {
|
||||
const second = node.querySelectorAll('section')[1]!
|
||||
return Math.ceil(second.getBoundingClientRect().top - node.getBoundingClientRect().top) + 1
|
||||
})
|
||||
expect(await scroller.evaluate(node => node.scrollHeight - node.clientHeight)).toBeGreaterThanOrEqual(secondTop)
|
||||
await scroller.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(readPinned).toEqual([true, false, false])
|
||||
expect(await headings.first().evaluate(node => getComputedStyle(node).backgroundColor))
|
||||
.toBe(dark ? 'rgba(48, 49, 54, 0.94)' : 'rgba(248, 249, 250, 0.94)')
|
||||
await scroller.evaluate((node, top) => { node.scrollTop = top }, secondTop)
|
||||
await expect.poll(readPinned).toEqual([false, true, false])
|
||||
await scroller.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(readPinned).toEqual([true, false, false])
|
||||
await resetScroll()
|
||||
}
|
||||
}
|
||||
await scroller.evaluate((node) => { node.scrollTop = 10 })
|
||||
await expect.poll(readPinned).toEqual([true, false, false])
|
||||
await search.fill('Origin Large')
|
||||
await expect.poll(() => headings.count()).toBe(1)
|
||||
await expect.poll(readPinned).toEqual([false])
|
||||
await page.getByRole('button', { name: '清除搜索', exact: true }).click()
|
||||
await expect.poll(readPinned).toEqual([false, false, false])
|
||||
await search.fill('zzzz')
|
||||
await expect.poll(() => surface.locator('[data-stuck]').count()).toBe(0)
|
||||
await page.getByRole('button', { name: '清除搜索', exact: true }).click()
|
||||
await expect.poll(readPinned).toEqual([false, false, false])
|
||||
await search.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
expect(await search.inputValue()).toBe('')
|
||||
await resetScroll()
|
||||
} finally {
|
||||
if (await surface.count()) await search.press('Escape')
|
||||
if (await trigger.getAttribute('aria-expanded') === 'true') await page.keyboard.press('Escape')
|
||||
await page.setViewportSize(originalViewport)
|
||||
await page.evaluate(({ platform, theme }) => {
|
||||
if (platform === null) document.documentElement.removeAttribute('data-platform')
|
||||
else document.documentElement.setAttribute('data-platform', platform)
|
||||
if (theme === null) document.body.removeAttribute('data-ds-dark-theme')
|
||||
else document.body.setAttribute('data-ds-dark-theme', theme)
|
||||
}, attributes)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes the switched model as the default and leaves a logged session alone', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
|
||||
// A session that has already run a turn, spelled as the fact a turn
|
||||
@@ -106,7 +323,109 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400')
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
const modelMenuBounds = await page.getByRole('menu', { name: '模型与推理等级', exact: true }).boundingBox()
|
||||
const composerBounds = await page.locator('[data-composer-card]').first().boundingBox()
|
||||
expect(modelMenuBounds!.width).toBeLessThan(composerBounds!.width)
|
||||
const modelCell = page.getByRole('menuitem', { name: /模型/ })
|
||||
await trigger.press('ArrowDown')
|
||||
expect(await modelCell.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
expect(await modelCell.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
|
||||
const focusedBackground = await modelCell.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await trigger.focus()
|
||||
await modelCell.hover()
|
||||
expect(await modelCell.evaluate(element => getComputedStyle(element).backgroundColor)).toBe(focusedBackground)
|
||||
await modelCell.click()
|
||||
const search = page.getByRole('searchbox', { name: '搜索模型…' })
|
||||
const rowIds = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row => row.id))
|
||||
const initialHighlight = rowIds.indexOf(await search.getAttribute('aria-activedescendant') ?? '')
|
||||
expect(initialHighlight).toBeGreaterThanOrEqual(0)
|
||||
for (let step = 1; step <= rowIds.length + 1; step++) {
|
||||
await search.press('ArrowDown')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(rowIds[(initialHighlight + step) % rowIds.length])
|
||||
expect(await search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true)
|
||||
}
|
||||
await search.press('ArrowUp')
|
||||
expect(await search.getAttribute('aria-activedescendant')).toBe(rowIds[initialHighlight])
|
||||
const searchStyle = await search.evaluate((input) => {
|
||||
const wrapper = input.parentElement!
|
||||
const caption = input.ownerDocument.createElement('span')
|
||||
caption.style.color = 'var(--dsw-alias-label-caption)'
|
||||
wrapper.append(caption)
|
||||
try {
|
||||
return {
|
||||
placeholderColor: getComputedStyle(input, '::placeholder').color,
|
||||
captionColor: getComputedStyle(caption).color,
|
||||
iconCount: wrapper.querySelectorAll('svg').length,
|
||||
background: getComputedStyle(wrapper).backgroundColor,
|
||||
radius: getComputedStyle(wrapper).borderRadius,
|
||||
padding: getComputedStyle(wrapper).padding,
|
||||
fontSize: getComputedStyle(input).fontSize,
|
||||
}
|
||||
} finally {
|
||||
caption.remove()
|
||||
}
|
||||
})
|
||||
expect(searchStyle.placeholderColor).toBe(searchStyle.captionColor)
|
||||
expect(searchStyle.iconCount).toBe(0)
|
||||
expect(searchStyle.background).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(searchStyle.radius).toBe('12px')
|
||||
expect(searchStyle.padding).toBe('5px 7px')
|
||||
expect(searchStyle.fontSize).toBe('12px')
|
||||
const modelWeights = await page.getByRole('menuitemradio').evaluateAll(rows => rows.map(row =>
|
||||
getComputedStyle(row.querySelector('span span')!).fontWeight,
|
||||
))
|
||||
expect(new Set(modelWeights)).toEqual(new Set(['400']))
|
||||
const headingPalettes = await page.getByRole('group', { name: '模型与推理等级', exact: true }).evaluate((menu) => {
|
||||
const html = menu.ownerDocument.documentElement
|
||||
const body = menu.ownerDocument.body
|
||||
const previousPlatform = html.getAttribute('data-platform')
|
||||
const previousTheme = body.getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
return ['web', 'win32', 'darwin'].flatMap(platform => [false, true].map((dark) => {
|
||||
if (platform === 'web') html.removeAttribute('data-platform')
|
||||
else html.setAttribute('data-platform', platform)
|
||||
body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
const headings = [...menu.querySelectorAll('section[role="group"] > div')]
|
||||
return {
|
||||
platform, dark,
|
||||
headingFills: [...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))],
|
||||
positions: [...new Set(headings.map(heading => getComputedStyle(heading).position))],
|
||||
headingRadii: [...new Set(headings.map(heading => getComputedStyle(heading).borderRadius))],
|
||||
optionRadius: getComputedStyle(menu.querySelector('[role="menuitemradio"]')!).borderRadius,
|
||||
menuFill: getComputedStyle(menu.querySelector(':scope > [aria-hidden="true"]')!).backgroundColor,
|
||||
searchBorder: getComputedStyle(menu.querySelector('input')!.parentElement!).borderColor,
|
||||
}
|
||||
}))
|
||||
} finally {
|
||||
if (previousPlatform === null) html.removeAttribute('data-platform')
|
||||
else html.setAttribute('data-platform', previousPlatform)
|
||||
if (previousTheme === null) body.removeAttribute('data-ds-dark-theme')
|
||||
else body.setAttribute('data-ds-dark-theme', previousTheme)
|
||||
}
|
||||
})
|
||||
for (const palette of headingPalettes) {
|
||||
expect(palette.headingFills, `${palette.platform}, dark=${String(palette.dark)}`)
|
||||
.toEqual(['rgba(0, 0, 0, 0)'])
|
||||
expect(palette.menuFill).toBe(palette.dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)')
|
||||
expect(palette.searchBorder).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(palette.positions).toEqual(['sticky'])
|
||||
expect(palette.headingRadii).toEqual([palette.platform === 'darwin' ? '0px' : palette.optionRadius])
|
||||
}
|
||||
await search.fill('zzzz')
|
||||
await page.getByText('没有匹配的模型。', { exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: '清除搜索', exact: true }).click()
|
||||
expect(await search.inputValue()).toBe('')
|
||||
expect(await search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await page.getByRole('menuitemradio').count()).toBe(modelWeights.length)
|
||||
expect(await page.getByRole('button', { name: '清除搜索', exact: true }).count()).toBe(0)
|
||||
await search.fill(' ACMLG ')
|
||||
expect(await page.getByRole('menuitemradio').allTextContents()).toEqual(['Acme Large'])
|
||||
expect(await page.getByRole('group', { name: 'Origin Gateway', exact: true }).count()).toBe(0)
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/default-model/search.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[role="group"][aria-label="模型与推理等级"]', scaffold.workspaceCwd),
|
||||
webSnapshotMode(),
|
||||
)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const blocked = scaffold.ctx.hmr.runExclusive(async () => {
|
||||
@@ -115,9 +434,15 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
})
|
||||
try {
|
||||
await entered.promise
|
||||
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
|
||||
await search.press('ArrowDown')
|
||||
await expect.poll(() => search.evaluate(input => input === input.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await search.getAttribute('aria-activedescendant'))
|
||||
.toBe(await page.getByRole('menuitemradio', { name: 'Acme Large' }).getAttribute('id'))
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false')
|
||||
await expect.poll(() => trigger.textContent()).toContain('Acme Large')
|
||||
await expect.poll(() => trigger.evaluate(element => element === element.ownerDocument.activeElement)).toBe(true)
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).toBe('none')
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/background-save.expected.md', import.meta.url)), aria, webSnapshotMode())
|
||||
@@ -143,6 +468,10 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
.toEqual({ provider: ROUTE, model: MODEL })
|
||||
// ...while the one holding a logged route keeps deriving from its log.
|
||||
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
await trigger.press('Tab')
|
||||
await trigger.focus()
|
||||
expect(await trigger.evaluate(element => element.matches(':focus-visible'))).toBe(true)
|
||||
expect(await trigger.evaluate(element => getComputedStyle(element).boxShadow)).not.toBe('none')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -26,17 +26,14 @@ it('persists developer tools in the Host settings document and restores the acce
|
||||
|
||||
await page.getByRole('button', { name: 'Agent presets', exact: true }).click()
|
||||
await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1)
|
||||
// The page owns no selection switch: only the General one gates the choice.
|
||||
// The page owns no selection switch; the cards and the Creator entry stay usable with Coding Tools off.
|
||||
const section = page.locator('section')
|
||||
.filter({ has: page.getByRole('heading', { name: 'Agent presets', exact: true }) })
|
||||
const refused = section.getByRole('button', { name: /^Turn on Coding Tools in General settings to choose a default: / })
|
||||
const setDefault = section.getByRole('button', { name: 'Set as new task default: Minimal mode' })
|
||||
expect(await section.getByRole('switch').count()).toBe(0)
|
||||
await expect.poll(() => refused.count()).toBeGreaterThan(0)
|
||||
await expect.poll(() => setDefault.isEnabled()).toBe(true)
|
||||
expect(await section.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).isEnabled()).toBe(true)
|
||||
await scaffold.ctx.settings.update('ui-settings', { enabled: true })
|
||||
await expect.poll(() => section.getByRole('button', { name: 'Set as new task default: Minimal mode' }).count()).toBe(1)
|
||||
await expect.poll(() => refused.count()).toBe(0)
|
||||
expect(await section.getByRole('switch').count()).toBe(0)
|
||||
await scaffold.ctx.settings.update('ui-settings', { enabled: false })
|
||||
await expect.poll(() => refused.count()).toBeGreaterThan(0)
|
||||
await expect.poll(() => setDefault.isEnabled()).toBe(true)
|
||||
expect(await section.getByRole('switch').count()).toBe(0)
|
||||
})
|
||||
@@ -1,9 +1,12 @@
|
||||
- menu "模型与推理等级":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "DeepSeek-V4-Flash"
|
||||
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Think"
|
||||
- menuitemradio "Acme Swift" [checked]
|
||||
- group "模型与推理等级":
|
||||
- searchbox "搜索模型…"
|
||||
- menu "模型":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "DeepSeek-V4-Flash"
|
||||
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Think"
|
||||
- menuitemradio "Acme Swift" [checked]
|
||||
- menuitemradio "Acme Lite"
|
||||
@@ -1,5 +1,6 @@
|
||||
- menu "模型与推理等级":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "Messages Flash" [checked]
|
||||
- menuitemradio "DeepSeek-V4-Pro"
|
||||
- group "模型与推理等级":
|
||||
- menu "模型":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "Messages Flash" [checked]
|
||||
- menuitemradio "DeepSeek-V4-Pro"
|
||||
@@ -0,0 +1,14 @@
|
||||
- textbox "筛选选项":
|
||||
- /placeholder: 搜索模型…
|
||||
- listbox "/model 匹配项":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- option "DeepSeek-V4-Flash"
|
||||
- option "DeepSeek-V4-Flash-Vision-Exp"
|
||||
- group "Origin Gateway":
|
||||
- text: Origin Gateway
|
||||
- option "Origin Large"
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- option "Acme Large" [selected]
|
||||
- option "Acme Small"
|
||||
@@ -0,0 +1,7 @@
|
||||
- group "模型与推理等级":
|
||||
- searchbox "搜索模型…": ACMLG
|
||||
- button "清除搜索"
|
||||
- menu "模型":
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Large"
|
||||
@@ -27,8 +27,8 @@
|
||||
- button "编辑快捷键"
|
||||
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送"
|
||||
- text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品
|
||||
- switch "在使用官方模型 API 时上传 Session Log"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品
|
||||
- switch "在使用官方模型 API 时上传 Session Log"
|
||||
- text: 当前版本:{{version}}
|
||||
@@ -27,8 +27,8 @@
|
||||
- button "Edit shortcuts"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: Upload Session Log when using the official model API Help improve DeepSeek models and products.
|
||||
- switch "Upload Session Log when using the official model API"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Upload Session Log when using the official model API Help improve DeepSeek models and products.
|
||||
- switch "Upload Session Log when using the official model API"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -33,6 +33,7 @@
|
||||
- option "groq"
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "meta"
|
||||
- option "minimax"
|
||||
- option "minimax-cn" [selected]
|
||||
- option "mistral"
|
||||
@@ -47,6 +48,7 @@
|
||||
- option "qwen-token-plan"
|
||||
- option "qwen-token-plan-cn"
|
||||
- option "qwen-token-plan-individual"
|
||||
- option "radius"
|
||||
- option "together"
|
||||
- option "vercel-ai-gateway"
|
||||
- option "xai"
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
- option "groq"
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "meta"
|
||||
- option "minimax"
|
||||
- option "minimax-cn" [selected]
|
||||
- option "mistral"
|
||||
@@ -51,6 +52,7 @@
|
||||
- option "qwen-token-plan"
|
||||
- option "qwen-token-plan-cn"
|
||||
- option "qwen-token-plan-individual"
|
||||
- option "radius"
|
||||
- option "together"
|
||||
- option "vercel-ai-gateway"
|
||||
- option "xai"
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
- text: slow-package
|
||||
- button "插件安装引导和示例"
|
||||
- button "安装源 默认安装源"
|
||||
- note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。
|
||||
- note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。
|
||||
- button "安装"
|
||||
@@ -7,16 +7,8 @@
|
||||
- button "安装源 中国大陆镜像源"
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例:
|
||||
- text: 填入插件 npm 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例:
|
||||
- code: dsh-plugin-whale-pet
|
||||
- button "填入示例 dsh-plugin-whale-pet": 填入示例
|
||||
- listitem:
|
||||
- text: GitHub 仓库地址 插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。 示例:
|
||||
- code: https://github.com/author/dsh-plugin
|
||||
- button "填入示例 https://github.com/author/dsh-plugin": 填入示例
|
||||
- listitem:
|
||||
- text: 本地插件目录 本机上插件目录的绝对路径,适用于自行开发或已下载的插件。 示例:
|
||||
- code: /Users/name/my-plugin
|
||||
- button "填入示例 /Users/name/my-plugin": 填入示例
|
||||
- note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。
|
||||
- note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。
|
||||
- button "安装" [disabled]
|
||||
@@ -5,5 +5,5 @@
|
||||
- /placeholder: 例如 dsh-plugin-whale-pet
|
||||
- button "插件安装引导和示例"
|
||||
- button "安装源 中国大陆镜像源"
|
||||
- note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。
|
||||
- note: 请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。 插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。
|
||||
- button "安装" [disabled]
|
||||
@@ -27,8 +27,8 @@
|
||||
- button "Edit shortcuts"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: Upload Session Log when using the official model API Help improve DeepSeek models and products.
|
||||
- switch "Upload Session Log when using the official model API"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Upload Session Log when using the official model API Help improve DeepSeek models and products.
|
||||
- switch "Upload Session Log when using the official model API"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -27,8 +27,8 @@
|
||||
- button "Edit shortcuts"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
- text: Upload Session Log when using the official model API Help improve DeepSeek models and products.
|
||||
- switch "Upload Session Log when using the official model API"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Upload Session Log when using the official model API Help improve DeepSeek models and products.
|
||||
- switch "Upload Session Log when using the official model API"
|
||||
- text: "Current version: {{version}}"
|
||||
@@ -27,8 +27,8 @@
|
||||
- button "编辑快捷键"
|
||||
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送"
|
||||
- text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品
|
||||
- switch "在使用官方模型 API 时上传 Session Log"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 在使用官方模型 API 时上传 Session Log 帮助改进 DeepSeek 模型与产品
|
||||
- switch "在使用官方模型 API 时上传 Session Log"
|
||||
- text: 当前版本:{{version}}
|
||||
@@ -0,0 +1,290 @@
|
||||
/** Built-profile browser coverage for sticky model groups; counts only their synchronous rect reads, not FPS or latency. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium, webkit } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Test-private count for MenuGroup sections and their direct scroll viewport. */
|
||||
__modelMenuRectReads: number
|
||||
}
|
||||
}
|
||||
|
||||
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
|
||||
const PROVIDERS = [
|
||||
{ id: 'origin-gateway', name: 'Origin Gateway', prefix: 'Origin' },
|
||||
{ id: 'amber-gateway', name: 'Amber Gateway', prefix: 'Amber' },
|
||||
{ id: 'cedar-gateway', name: 'Cedar Gateway', prefix: 'Cedar' },
|
||||
{ id: 'quartz-gateway', name: 'Quartz Gateway', prefix: 'Quartz' },
|
||||
].map((provider, providerIndex) => ({
|
||||
...provider,
|
||||
models: Array.from({ length: 7 }, (_, index) => providerIndex === 0 && index === 0
|
||||
? { id: 'origin-large', name: 'Origin Large' }
|
||||
: { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}` }),
|
||||
}))
|
||||
const EXPECTED_GROUPS = [
|
||||
{ label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] },
|
||||
...PROVIDERS.map(provider => ({ label: provider.name, rows: provider.models.map(model => model.name) })),
|
||||
]
|
||||
|
||||
// Native IO/RO callbacks need rendering opportunities, not elapsed-time sleeps.
|
||||
async function frames(page: Page): Promise<void> {
|
||||
await page.evaluate(() => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
|
||||
}))
|
||||
}
|
||||
|
||||
function readGroups(viewport: Locator) {
|
||||
return viewport.locator('[data-menu-group]').evaluateAll(groups => groups.map(group => ({
|
||||
label: group.querySelector('[data-menu-group-heading]')!.textContent,
|
||||
rows: [...group.querySelectorAll('[role="option"], [role="menuitemradio"]')].map(row => row.textContent),
|
||||
})))
|
||||
}
|
||||
|
||||
function readPinned(viewport: Locator) {
|
||||
return viewport.locator('[data-menu-group-heading]').evaluateAll(headings =>
|
||||
headings.map(heading => heading.hasAttribute('data-stuck')))
|
||||
}
|
||||
|
||||
async function assertNoRectReads(page: Page): Promise<void> {
|
||||
await frames(page)
|
||||
expect(await page.evaluate(() => window.__modelMenuRectReads)).toBe(0)
|
||||
}
|
||||
|
||||
async function assertPinned(page: Page, viewport: Locator, index: number, count = 5): Promise<void> {
|
||||
const expected = Array.from({ length: count }, (_, i) => i === index)
|
||||
await expect.poll(() => readPinned(viewport)).toEqual(expected)
|
||||
await assertNoRectReads(page)
|
||||
expect(await readPinned(viewport)).toEqual(expected)
|
||||
}
|
||||
|
||||
async function scrollToGroup(viewport: Locator, index: number, inside = 1): Promise<void> {
|
||||
// Sections share an offset parent. Their normal-flow offsets are test positioning, not rect instrumentation.
|
||||
await viewport.evaluate((node, target) => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const groups = node.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')
|
||||
node.scrollTop = groups[target.index]!.offsetTop - groups[0]!.offsetTop + target.inside
|
||||
resolve()
|
||||
})
|
||||
}), { index, inside })
|
||||
}
|
||||
|
||||
async function headingMaterial(viewport: Locator) {
|
||||
return viewport.locator('[data-menu-group-heading]').first().evaluate((heading) => {
|
||||
const body = heading.ownerDocument.body
|
||||
const theme = body.getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
return [false, true].map((dark) => {
|
||||
body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
const style = getComputedStyle(heading)
|
||||
return {
|
||||
fill: style.backgroundColor, radius: style.borderRadius, position: style.position,
|
||||
font: style.fontSize, weight: style.fontWeight, padding: style.padding,
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
if (theme === null) body.removeAttribute('data-ds-dark-theme')
|
||||
else body.setAttribute('data-ds-dark-theme', theme)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(webSnapshotMode() === 'record').each([
|
||||
{ name: 'Chromium', engine: chromium },
|
||||
{ name: 'WebKit', engine: webkit },
|
||||
])('web e2e: model menu observers ($name)', ({ engine }) => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let crashed = false
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
// Catalog-only settings traffic: no prompt, selection, replay fixture, or model request.
|
||||
await scaffold.ctx.settings.update('llm-pi-ai', {
|
||||
providers: Object.fromEntries(PROVIDERS.map(provider => [provider.id, {
|
||||
displayName: provider.name,
|
||||
api: 'openai-completions',
|
||||
baseURL: `https://${provider.id}.example/v1`,
|
||||
models: provider.models,
|
||||
}])),
|
||||
})
|
||||
browser = await engine.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
await page.addInitScript(() => {
|
||||
window.__modelMenuRectReads = 0
|
||||
// oxlint-disable-next-line typescript/unbound-method -- The wrapper supplies the original Element receiver below.
|
||||
const original = Element.prototype.getBoundingClientRect
|
||||
Element.prototype.getBoundingClientRect = function (): DOMRect {
|
||||
if (this.hasAttribute('data-menu-group') || [...this.children].some(child => child.hasAttribute('data-menu-group'))) {
|
||||
window.__modelMenuRectReads++
|
||||
}
|
||||
return original.call(this)
|
||||
}
|
||||
})
|
||||
page.on('crash', () => { crashed = true; console.error('observer test page crashed') })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
await scaffold?.close()
|
||||
}
|
||||
})
|
||||
|
||||
// FIXME: WebKit 26.5 crashes while filtering the grouped model rows in this scenario.
|
||||
it.skipIf(engine === webkit)('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, `web-e2e-model-menu-observer-${engine.name()}`))
|
||||
let buttonMaterial: Awaited<ReturnType<typeof headingMaterial>> | undefined
|
||||
for (const entry of ['button', 'command'] as const) {
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
const surface = entry === 'button'
|
||||
? page.getByRole('group', { name: '模型与推理等级', exact: true })
|
||||
: page.locator('[aria-label="/model 选项"]')
|
||||
const viewport = entry === 'button'
|
||||
? surface.getByRole('menu', { name: '模型', exact: true })
|
||||
: surface.getByRole('listbox')
|
||||
const search = entry === 'button'
|
||||
? page.getByRole('searchbox', { name: '搜索模型…', exact: true })
|
||||
: page.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
const open = async (): Promise<void> => {
|
||||
if (entry === 'button') {
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
} else {
|
||||
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
||||
await page.getByRole('option', { name: /^模型/ }).click()
|
||||
}
|
||||
await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS)
|
||||
await expect.poll(() => search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
await assertNoRectReads(page)
|
||||
await page.mouse.move(0, 0)
|
||||
await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 })
|
||||
await assertPinned(page, viewport, -1)
|
||||
}
|
||||
const close = async (): Promise<void> => {
|
||||
if (crashed || page.isClosed() || await surface.count() === 0) return
|
||||
await search.press('Escape')
|
||||
if (entry === 'button') await page.keyboard.press('Escape')
|
||||
await surface.waitFor({ state: 'detached' })
|
||||
await composer.fill('')
|
||||
}
|
||||
|
||||
await open()
|
||||
try {
|
||||
expect(await viewport.locator('[role="option"], [role="menuitemradio"]').count()).toBe(30)
|
||||
expect(await viewport.evaluate(node => node.scrollTop)).toBe(0)
|
||||
expect(await viewport.locator('[data-menu-group-heading]').evaluateAll(headings =>
|
||||
[...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))]))
|
||||
.toEqual(['rgba(0, 0, 0, 0)'])
|
||||
await scrollToGroup(viewport, 0)
|
||||
await assertPinned(page, viewport, 0)
|
||||
const material = await headingMaterial(viewport)
|
||||
expect(material.map(style => style.fill)).toEqual(['rgba(248, 249, 250, 0.94)', 'rgba(48, 49, 54, 0.94)'])
|
||||
for (const style of material) {
|
||||
expect(style).toMatchObject({ position: 'sticky', font: '11px', weight: '500', padding: '4px 7px 2px' })
|
||||
}
|
||||
if (entry === 'button') buttonMaterial = material
|
||||
else expect(material).toEqual(buttonMaterial)
|
||||
|
||||
for (const index of [2, 4, 1]) {
|
||||
await scrollToGroup(viewport, index)
|
||||
await assertPinned(page, viewport, index)
|
||||
const height = await viewport.evaluate(node => node.clientHeight)
|
||||
expect(height).toBeGreaterThan(0)
|
||||
expect(height).toBeLessThanOrEqual(80)
|
||||
expect(await viewport.locator('[data-menu-group]').nth(index).evaluate(node => node.clientHeight))
|
||||
.toBeGreaterThan(height * 2)
|
||||
await scrollToGroup(viewport, index, height + 1)
|
||||
await assertPinned(page, viewport, index)
|
||||
expect(await viewport.evaluate((node, groupIndex) => {
|
||||
const headings = node.querySelectorAll('[data-menu-group-heading]')
|
||||
const firstStart = node.querySelector('[data-menu-group-start]')!
|
||||
const viewportTop = firstStart.getBoundingClientRect().top + node.scrollTop
|
||||
return Math.abs(headings[groupIndex]!.getBoundingClientRect().top - viewportTop)
|
||||
}, index)).toBeLessThan(1)
|
||||
}
|
||||
await scrollToGroup(viewport, 0, 0)
|
||||
await assertPinned(page, viewport, -1)
|
||||
|
||||
for (const [key, steps] of [['ArrowDown', 18], ['ArrowUp', 10]] as const) {
|
||||
const visited = new Set<number>()
|
||||
let observedPinnedHighlight = false
|
||||
for (let step = 0; step < steps; step++) {
|
||||
const rows = viewport.locator('[role="option"], [role="menuitemradio"]')
|
||||
const before = await rows.evaluateAll(nodes => nodes.findIndex(node =>
|
||||
node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))
|
||||
await search.press(key)
|
||||
const expected = (before + (key === 'ArrowDown' ? 1 : -1) + 30) % 30
|
||||
await expect.poll(() => rows.evaluateAll(nodes => nodes.findIndex(node =>
|
||||
node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))).toBe(expected)
|
||||
await frames(page)
|
||||
await expect.poll(() => viewport.evaluate((node) => {
|
||||
const groups = [...node.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')]
|
||||
const base = groups[0]!.offsetTop
|
||||
return groups.every((group) => {
|
||||
const top = group.offsetTop - base
|
||||
const shouldStick = top < node.scrollTop && top + group.offsetHeight > node.scrollTop
|
||||
return group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') === shouldStick
|
||||
})
|
||||
})).toBe(true)
|
||||
const highlight = await viewport.evaluate((node) => {
|
||||
const groups = [...node.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')]
|
||||
const index = groups.findIndex(group => group.querySelector('[data-highlighted], [aria-selected="true"]') !== null)
|
||||
const group = groups[index]!
|
||||
return { index, above: group.offsetTop - groups[0]!.offsetTop < node.scrollTop,
|
||||
stuck: group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') }
|
||||
})
|
||||
visited.add(highlight.index)
|
||||
if (highlight.above) {
|
||||
expect(highlight.stuck).toBe(true)
|
||||
observedPinnedHighlight = true
|
||||
}
|
||||
expect(await search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
||||
await assertNoRectReads(page)
|
||||
}
|
||||
expect(visited.size).toBeGreaterThan(1)
|
||||
expect(observedPinnedHighlight).toBe(true)
|
||||
}
|
||||
|
||||
await scrollToGroup(viewport, 0)
|
||||
await assertPinned(page, viewport, 0)
|
||||
await search.fill('Quartz Model 1')
|
||||
await expect.poll(() => readGroups(viewport)).toEqual([{ label: 'Quartz Gateway', rows: ['Quartz Model 1'] }])
|
||||
await assertPinned(page, viewport, -1, 1)
|
||||
expect(await viewport.evaluate(node => node.scrollTop)).toBe(0)
|
||||
await search.fill('')
|
||||
await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS)
|
||||
await assertPinned(page, viewport, -1)
|
||||
await close()
|
||||
await open()
|
||||
expect(await search.inputValue()).toBe('')
|
||||
|
||||
await scrollToGroup(viewport, 3, 90)
|
||||
await assertPinned(page, viewport, 3)
|
||||
await viewport.evaluate((node) => { node.style.maxHeight = '64px' })
|
||||
await expect.poll(() => viewport.evaluate(node => node.clientHeight)).toBeLessThanOrEqual(64)
|
||||
await assertPinned(page, viewport, 3)
|
||||
await scrollToGroup(viewport, 4, 100)
|
||||
await assertPinned(page, viewport, 4)
|
||||
await scrollToGroup(viewport, 1, 100)
|
||||
await assertPinned(page, viewport, 1)
|
||||
await scrollToGroup(viewport, 0, 0)
|
||||
await assertPinned(page, viewport, -1)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
}
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -16,9 +16,9 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
|
||||
compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
@@ -264,6 +264,130 @@ describe('web e2e: plan review takeover round trip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar seat changes', () => {
|
||||
// Two recorded Sessions seeded side by side; opening one in the page gives it
|
||||
// a live Agent, so a review can be delivered to it while it is off screen.
|
||||
const OTHER_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let planned: SessionId
|
||||
let other: SessionId
|
||||
const crashes: string[] = []
|
||||
const row = (session: SessionId) => page.locator(`[data-row-key="session:${session}"]`)
|
||||
|
||||
async function showSessions(): Promise<void> {
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Expand the Workspace group by its state, not by whether its rows have rendered yet.
|
||||
const group = page.locator('[data-row-key^="workspace:"]').first()
|
||||
await group.waitFor({ timeout: 15_000 })
|
||||
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
|
||||
await row(planned).waitFor({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
async function open(session: SessionId): Promise<void> {
|
||||
await row(session).click()
|
||||
await page.locator(`[data-conversation-session="${session}"]`).waitFor({ timeout: 15_000 })
|
||||
await page.locator(`[data-sidebar-right-session="${session}"]:not([hidden])`).first().waitFor({ state: 'attached', timeout: 15_000 })
|
||||
// `expect.poll` is test-scoped and this also runs from beforeAll, so poll by hand.
|
||||
const deadline = Date.now() + 15_000
|
||||
while (scaffold.ctx.agents.get(session) === undefined) {
|
||||
if (Date.now() > deadline) throw new Error(`opening Session ${session} published no live Agent`)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
async function showPlugins(): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await expect.poll(() => page.locator('[data-composer-input]').count(), { timeout: 10_000 }).toBe(0)
|
||||
}
|
||||
|
||||
/** Deliver a plan review to one Session, run the scenario, and withdraw the review. */
|
||||
async function whileReviewing(session: SessionId, detail: string, scenario: () => Promise<void>): Promise<void> {
|
||||
const agent = scaffold.ctx.agents.get(session)
|
||||
if (agent === undefined) throw new Error(`Session ${session} has no live Agent`)
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent, signal: controller.signal,
|
||||
questions: [{ id: 'seat-change', question: 'Approve this plan?', detail,
|
||||
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
}],
|
||||
})
|
||||
const outcome = asked.then(value => value, (error: unknown) => ({ error }))
|
||||
try {
|
||||
await row(session).locator('[data-state="warning"]').waitFor({ timeout: 10_000 })
|
||||
await scenario()
|
||||
} finally {
|
||||
controller.abort()
|
||||
await outcome
|
||||
}
|
||||
}
|
||||
|
||||
/** The review opened itself in the Sidebar and kept its manual opener. */
|
||||
async function expectOpened(text: string): Promise<void> {
|
||||
const card = page.locator('[data-plan-review-key]')
|
||||
await card.waitFor({ timeout: 10_000 })
|
||||
const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]')
|
||||
await preview.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
expect(await preview.getByText(text).isVisible()).toBe(true)
|
||||
expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1)
|
||||
expect(await page.locator('[data-slot-error]').count()).toBe(0)
|
||||
expect(crashes).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
planned = await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), 'plan-review-seat-planned')
|
||||
other = await seedSession(scaffold, await readFile(OTHER_FIXTURE, 'utf8'), 'plan-review-seat-other')
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
page.on('console', (message: ConsoleMessage) => {
|
||||
if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text())
|
||||
})
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await showSessions()
|
||||
await open(planned)
|
||||
await open(other)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('opens a review that arrives while another Session is on screen', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-session-return'))
|
||||
// Returning mounts the review in the commit that brings its Session back on
|
||||
// screen and swaps the Sidebar seats; the review's automatic open runs
|
||||
// before either seat's effects.
|
||||
await whileReviewing(planned, '# Session review\n\nSubmitted while another Session was on screen.', async () => {
|
||||
await row(planned).click()
|
||||
await expectOpened('Submitted while another Session was on screen.')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a review for a Session whose Sidebar layout was never saved', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-first-layout'))
|
||||
// The layout is stored per browser. Without it the arriving seat creates
|
||||
// one, and that store commit lands in the commit where the review opens.
|
||||
await open(other)
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.evaluate((session) => { localStorage.removeItem(`dsh.sidebar-right.v1.${session}`) }, planned)
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await showSessions()
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await showPlugins()
|
||||
await whileReviewing(planned, '# Layout review\n\nSubmitted to a Session without a saved Sidebar layout.', async () => {
|
||||
await row(planned).click()
|
||||
await expectOpened('Submitted to a Session without a saved Sidebar layout.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('web e2e: dismissed plan history', () => {
|
||||
it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => {
|
||||
// Replay supplies a finite continuation; the real question rejection and retained document are the assertions.
|
||||
|
||||
@@ -835,13 +835,18 @@ describe('web e2e: plugin manager', () => {
|
||||
const install = dialog.getByRole('button', { name: '安装', exact: true })
|
||||
expect(await install.isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('note').textContent()).toContain('请确认插件来源可信')
|
||||
expect(await dialog.getByRole('note').textContent()).toContain('暂不支持自动更新')
|
||||
await dialog.getByRole('button', { name: '插件安装引导和示例' }).click()
|
||||
// The guide carries the package-name example only; the former template strings keep their replacement reminder.
|
||||
await expect.poll(() => dialog.getByRole('listitem').count()).toBe(1)
|
||||
await dialog.getByRole('button', { name: '填入示例 dsh-plugin-whale-pet' }).click()
|
||||
expect(await field.inputValue()).toBe('dsh-plugin-whale-pet')
|
||||
expect(await dialog.getByRole('status').count()).toBe(0)
|
||||
for (const [example, hint] of [
|
||||
['https://github.com/author/dsh-plugin', '请替换为实际的 Git 仓库地址'],
|
||||
['/Users/name/my-plugin', '请替换为本机插件目录的实际路径'],
|
||||
]) {
|
||||
await dialog.getByRole('button', { name: `填入示例 ${example}` }).click()
|
||||
expect(await field.inputValue()).toBe(example)
|
||||
] as const) {
|
||||
await field.fill(example)
|
||||
expect(await dialog.getByRole('status').textContent()).toBe(hint)
|
||||
}
|
||||
await field.fill('/actual/plugin-directory')
|
||||
|
||||
@@ -122,6 +122,8 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
|
||||
expect(await page.getByText('产物', { exact: true }).count()).toBe(0)
|
||||
// The scaffold workspace is not a git repository; the write call supplies its single changed file.
|
||||
const changes = page.locator('[data-changed-files]')
|
||||
// Changed-file cards wait for a separate summary request after the presentation events arrive.
|
||||
await changes.waitFor({ state: 'visible' })
|
||||
expect(await changes.count()).toBe(1)
|
||||
expect(await changes.getByText(`已编辑 ${FILE}`, { exact: true }).count()).toBe(1)
|
||||
expect(await changes.getByRole('list').count()).toBe(0)
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"tests/deepseek-messages-chat.e2e.ts",
|
||||
"tests/models-settings-recovery.e2e.ts",
|
||||
"tests/default-model.e2e.ts",
|
||||
"tests/model-menu-observer.e2e.ts",
|
||||
"tests/default-workspace.e2e.ts",
|
||||
"tests/new-session-refused.e2e.ts",
|
||||
"tests/github-ready-review.e2e.ts",
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ When the gate goes red:
|
||||
2. **Condense** content that belongs here but can be shorter.
|
||||
3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug.
|
||||
|
||||
Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,950; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers.
|
||||
Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,960; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers.
|
||||
|
||||
## The slop checklist
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.1.7-rc.2/schedule-optional-bundle/guide.md
|
||||
/:
|
||||
en: 224d57a22f38ba26
|
||||
zh: 1472851dde2bf9c3
|
||||
/schedule-moves-into-the-automation-tasks-optional-bundle:
|
||||
en: 138f715e3a260eb6
|
||||
zh: eff22703b2c79915
|
||||
/schedule-moves-into-the-automation-tasks-optional-bundle/change:
|
||||
en: b1b250e677c23b10
|
||||
zh: b71c1591ea4f9ce5
|
||||
/schedule-moves-into-the-automation-tasks-optional-bundle/migration:
|
||||
en: 656fea5dc45f8310
|
||||
zh: 8dfe16d2fdacf71a
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The Web composition no longer carries the Schedule rows; the Automation tasks optional bundle inserts them."
|
||||
---
|
||||
|
||||
# Schedule moves into the Automation tasks optional bundle
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
In v0.1.7-rc.2, `@deepseek-ai/dsh-web-app` carried the `time-context`, `schedule`, and `ui-schedule` rows with `disabled: true`. Turning Schedule on in the Plugins page, or by hand, wrote id-targeted overrides such as `- id: schedule` with `disabled: false` into `$DSH_HOME/profiles/<name>/cordis.patch.yml` or a `--patch` overlay.
|
||||
|
||||
The next release removes the three rows from the Web composition. `@deepseek-ai/dsh-experimental-schedule-bundle`, shown as Automation tasks in the Plugins page's Official group, inserts them. Every installation ships the bundle switched off.
|
||||
|
||||
Profiles that enabled Schedule by id lose it after upgrading: the loader warns `patch: entry schedule not found` (and the same for `time-context` and `ui-schedule`), the `schedule_*` tools and the Automation tasks page disappear, and stored reminders stop being delivered. Stored tasks and delivery records remain on disk.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Open Plugins, find Automation tasks in the Official group, and switch it on. The switch appends `@deepseek-ai/dsh-experimental-schedule-bundle` to `dsh.profile.bundles` in `$DSH_HOME/profiles/<name>/package.json`; for a profile edited by hand, add that entry yourself.
|
||||
2. Keep existing overrides that set other fields on `schedule`, `time-context`, or `ui-schedule`, such as `deliveryHistoryDays`; they apply again once the bundle inserts the rows. Overrides that only set `disabled: false` are redundant and can be deleted.
|
||||
3. Confirm: restart `dsh web`, check that startup logs no `patch: entry ... not found` warning for the three ids, and that the sidebar shows Automation tasks with the previously stored reminders.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Web 组合不再包含 Schedule 的三行;改由“自动化任务”可选 bundle 插入。"
|
||||
---
|
||||
|
||||
# Schedule 移入“自动化任务”可选 bundle
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
在 v0.1.7-rc.2 中,`@deepseek-ai/dsh-web-app` 以 `disabled: true` 包含 `time-context`、`schedule` 和 `ui-schedule` 三行。在插件管理页或手动开启 Schedule,会向 `$DSH_HOME/profiles/<name>/cordis.patch.yml` 或 `--patch` overlay 写入按 id 定位的覆盖项,例如 `- id: schedule` 加 `disabled: false`。
|
||||
|
||||
下一版本从 Web 组合中移除这三行。插件管理页“官方”分组中的“自动化任务”,即 `@deepseek-ai/dsh-experimental-schedule-bundle`,负责插入它们。每个安装都自带该 bundle,默认关闭。
|
||||
|
||||
按 id 开启 Schedule 的 profile 升级后会失去 Schedule:loader 警告 `patch: entry schedule not found`(`time-context` 和 `ui-schedule` 同理),`schedule_*` 工具和自动化任务页面消失,已存储的提醒不再投递。已存储的任务和投递记录仍保留在磁盘上。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 打开插件管理页,在“官方”分组中找到“自动化任务”并开启。开关会把 `@deepseek-ai/dsh-experimental-schedule-bundle` 追加到 `$DSH_HOME/profiles/<name>/package.json` 的 `dsh.profile.bundles`;手动维护的 profile 需自行添加该条目。
|
||||
2. 保留为 `schedule`、`time-context` 或 `ui-schedule` 设置其他字段的覆盖项,例如 `deliveryHistoryDays`;bundle 插入这些行后它们重新生效。只设置 `disabled: false` 的覆盖项已无作用,可以删除。
|
||||
3. 确认:重启 `dsh web`,检查启动日志中这三个 id 不再出现 `patch: entry ... not found` 警告,且侧栏显示“自动化任务”并列出之前存储的提醒。
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.1.7-rc.2/transcript-view-legacy-normal/guide.md
|
||||
/:
|
||||
en: 7d7e527ed3c87db1
|
||||
zh: 4654dab85b66bcb0
|
||||
/legacy-normal-work-details-display-as-detailed:
|
||||
en: 3c88b0b06094c162
|
||||
zh: 9ae8ff49975824a9
|
||||
/legacy-normal-work-details-display-as-detailed/change:
|
||||
en: 0e2287aa768141f3
|
||||
zh: 035992a94129fd9e
|
||||
/legacy-normal-work-details-display-as-detailed/migration:
|
||||
en: 6078ccce1ceacfd6
|
||||
zh: 6a394cf5259c9965
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The legacy ui-chat.transcriptView value normal, and an unset value in non-Desktop Web, now display Detailed instead of Standard."
|
||||
---
|
||||
|
||||
# Legacy `normal` work details display as Detailed
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
The Host setting `ui-chat.transcriptView` selects Settings → General → Work details. In v0.1.7-rc.2 the client displayed the legacy saved value `normal` as `standard`, and every client used `standard` when the setting was missing, `null`, or invalid.
|
||||
|
||||
From the next release:
|
||||
|
||||
- A saved `normal` displays as `detailed` on Desktop and Web. The saved value on disk is not rewritten.
|
||||
- A missing, `null`, or invalid value displays as `detailed` in non-Desktop Web (the npm `dsh web` install). Desktop still uses `standard`.
|
||||
- Saved `compact`, `standard`, `detailed`, and `verbose` values are unchanged.
|
||||
|
||||
Users who relied on the old reading see running Turns with process-group bodies expanded instead of collapsed summaries.
|
||||
|
||||
## Migration
|
||||
|
||||
1. To keep the previous presentation, open Settings → General → Work details and choose Standard. The client saves `standard` to `ui-chat.transcriptView`, which every later default change leaves untouched.
|
||||
2. Confirm: start a Turn that calls tools and check that its process group shows a collapsed summary with live task detail.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "旧值 ui-chat.transcriptView: normal,以及非 Desktop Web 中未设置的值,改为按“详细”而非“标准”展示。"
|
||||
---
|
||||
|
||||
# 旧值 `normal` 的工作步骤展示改为“详细”
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
Host 设置 `ui-chat.transcriptView` 对应设置 → 通用设置 → 工作步骤展示。在 v0.1.7-rc.2 中,客户端把旧保存值 `normal` 按 `standard` 展示;设置缺失、为 `null` 或无效时,所有客户端都使用 `standard`。
|
||||
|
||||
从下一版本起:
|
||||
|
||||
- 保存的 `normal` 在 Desktop 和 Web 中均按 `detailed` 展示,磁盘中的保存值不会被改写。
|
||||
- 设置缺失、为 `null` 或无效时,非 Desktop Web(npm 安装的 `dsh web`)按 `detailed` 展示;Desktop 仍使用 `standard`。
|
||||
- 已保存的 `compact`、`standard`、`detailed` 和 `verbose` 保持不变。
|
||||
|
||||
依赖旧读取方式的用户会看到运行中轮次的过程组正文直接展开,而不是收起的摘要。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 如需保留原有展示,打开设置 → 通用设置 → 工作步骤展示并选择“标准”。客户端会把 `standard` 保存到 `ui-chat.transcriptView`,此后的默认值变化不影响该值。
|
||||
2. 确认:发起一个调用工具的轮次,检查其过程组显示收起的摘要和实时任务详情。
|
||||
@@ -41,6 +41,9 @@
|
||||
"upload:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:arm64",
|
||||
"upload:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:x64",
|
||||
"upload:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:win:x64",
|
||||
"upload:latest:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:arm64",
|
||||
"upload:latest:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:x64",
|
||||
"upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64",
|
||||
"clean": "tsx scripts/clean.ts",
|
||||
"change-scope": "tsx scripts/change-scope.ts",
|
||||
"typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready",
|
||||
@@ -118,6 +121,7 @@
|
||||
"verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts",
|
||||
"verify-type-equiv": "tsx scripts/verify-type-equiv.ts",
|
||||
"verify-skill-invocation-metadata": "tsx scripts/verify-skill-invocation-metadata.ts",
|
||||
"verify-upgrade-guides": "tsx scripts/verify-upgrade-guides.ts",
|
||||
"verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts",
|
||||
"verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts",
|
||||
"test:docs": "tsx scripts/run-gates.ts doc-quick",
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 1fa9a72d1ee045cc
|
||||
/deepseek-ai-dsh-api-terminal-controller/use-this-package:
|
||||
en: 234403af4f95ea67
|
||||
zh: d711c2e62d7d37f8
|
||||
en: 0148cabb3a856226
|
||||
zh: 059a60413474a173
|
||||
/deepseek-ai-dsh-api-terminal-controller/understand-the-implementation:
|
||||
en: 94dcd1835c55acc2
|
||||
zh: cbfeb2da8cb60395
|
||||
|
||||
@@ -27,7 +27,7 @@ Open the execution environment's default shell in a Session workspace from the W
|
||||
|
||||
The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. Sandbox policy supplies only the fallback working directory for Sessions without a cwd. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error.
|
||||
|
||||
Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing.
|
||||
Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits confirmed lookup misses. It lists each executable name once, ignoring letter case and `.exe`, and keeps the earliest entry, because PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing.
|
||||
|
||||
| Configuration | Default | Meaning |
|
||||
|---|---|---|
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
|
||||
Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。Sandbox policy 仅为没有 cwd 的 Session 提供默认工作目录。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。
|
||||
|
||||
Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。
|
||||
Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,并省略确定未找到的候选。同一可执行文件名(不区分大小写,忽略 `.exe`)只列出最早的一项,因为 PATH 查找可能经由其他目录找到默认 shell,例如 merged-`/usr` 系统上 `/bin/bash` 对应的 `/usr/bin/bash`。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。
|
||||
|
||||
| 配置 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -22,18 +22,26 @@ export async function resolveShell(
|
||||
}
|
||||
|
||||
function profile(path: string): TerminalShell {
|
||||
const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
|
||||
const kind = name.toLowerCase().replace(/\.exe$/u, '')
|
||||
return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] }
|
||||
const kind = shellKind(path)
|
||||
return { path, name: executableName(path), args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] }
|
||||
}
|
||||
|
||||
function executableName(path: string): string {
|
||||
return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
|
||||
}
|
||||
|
||||
function shellKind(path: string): string {
|
||||
return executableName(path).toLowerCase().replace(/\.exe$/u, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* List verified candidates after the configured or environment-default shell.
|
||||
* List verified candidates after the configured or environment-default shell, one per executable name ignoring case and `.exe`.
|
||||
* PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems.
|
||||
* @param subprocess - target execution provider.
|
||||
* @param configured - optional default profile.
|
||||
* @param candidates - executable names or paths permitted for shell selection.
|
||||
* @param signal - discovery cancellation.
|
||||
* @returns unique installed shells, with the default first; transport failures reject.
|
||||
* @returns installed shells with the default first, keeping the earliest entry per name; transport failures reject.
|
||||
*/
|
||||
export async function discoverShells(
|
||||
subprocess: SubprocessRuntime, configured: TerminalShell | undefined,
|
||||
@@ -50,8 +58,8 @@ export async function discoverShells(
|
||||
const shells = new Map<string, TerminalShell>()
|
||||
for (const shell of [preferred, ...found]) {
|
||||
if (shell === undefined) continue
|
||||
const key = shell.path.includes('\\') ? shell.path.toLowerCase() : shell.path
|
||||
if (!shells.has(key)) shells.set(key, shell)
|
||||
const kind = shellKind(shell.path)
|
||||
if (!shells.has(kind)) shells.set(kind, shell)
|
||||
}
|
||||
return [...shells.values()]
|
||||
}
|
||||
@@ -546,6 +546,16 @@ it('deduplicates Windows executable paths regardless of letter case', async () =
|
||||
expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }])
|
||||
})
|
||||
|
||||
it('lists each executable name once when PATH reaches the default shell through another directory', async () => {
|
||||
const h = fixture({ shell: undefined, shellCandidates: ['zsh', 'bash'] })
|
||||
// Merged /usr: the login shell is /bin/bash, while PATH lists /usr/bin before the /bin link.
|
||||
h.subprocess.resolveExecutable.mockImplementation(async path => path.startsWith('/') ? path : `/usr/bin/${path}`)
|
||||
expect(await h.controller.shells(h.agent, signal())).toEqual([
|
||||
{ path: '/bin/bash', name: 'bash', args: ['-i'] },
|
||||
{ path: '/usr/bin/zsh', name: 'zsh', args: ['-i'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('retains a known terminal without Agent resolution and fences stream admission after explicit close', async () => {
|
||||
const h = fixture()
|
||||
await h.controller.create(h.agent, request, signal())
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
en: 89e0624fafd701fb
|
||||
zh: 19d36a33c1d9af9d
|
||||
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work:
|
||||
en: 75e861bd7c91e37b
|
||||
zh: d209c33dd5b16182
|
||||
en: 379bc2c72fd9e53e
|
||||
zh: 1fff1e0e676bb161
|
||||
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note:
|
||||
en: 7972e4b2005ec9a4
|
||||
zh: 754c49ec538c164c
|
||||
@@ -87,6 +87,7 @@ Reloading a contributing plugin can change later request prefixes; HMR does not
|
||||
|
||||
- Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process.
|
||||
- Replacing installed package versions still requires a restart through Plugin Manager. The browser Client module graph retains its separate browser-side loading mechanism.
|
||||
- `watchConfig()` resolves when Chokidar reports readiness. On darwin, libuv starts the FSEvents stream afterwards on its own thread, so a write that lands within milliseconds of registration is not reported until the next event in that directory; edits made after startup are unaffected.
|
||||
|
||||
### Dev Note
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提
|
||||
|
||||
- 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。
|
||||
- 通过插件管理器替换已安装包版本仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。
|
||||
- `watchConfig()` 在 Chokidar 报告就绪时 resolve。darwin 上 libuv 随后才在自己的线程启动 FSEvents 流,因此注册后数毫秒内落地的写入要等到该目录的下一个事件才会被报告;启动之后的编辑不受影响。
|
||||
|
||||
### 开发备注
|
||||
|
||||
|
||||
@@ -45,8 +45,30 @@ async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean):
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
/**
|
||||
* Block until this process's native directory watches deliver events. Chokidar
|
||||
* reports `ready` once `fs.watch()` returns, but libuv on darwin builds the
|
||||
* per-process FSEvents stream later on its CoreFoundation thread
|
||||
* (`uv__fsevents_init` only signals that thread), and a write that lands before
|
||||
* then is never reported. Closing any directory handle runs `uv__fsevents_close`,
|
||||
* whose `uv_sem_wait` returns only after `uv__fsevents_reschedule` has rebuilt
|
||||
* and started the stream with the remaining handles (libuv `src/unix/fsevents.c`,
|
||||
* unchanged across the libuv 1.x releases bundled by Node 22–24), so a watcher
|
||||
* registered before this call observes the next write. Any existing directory
|
||||
* works because the stream is per process. Per-file handles use kqueue and leave
|
||||
* the stream alone, so one call after registration covers the later change and
|
||||
* unlink steps. Linux inotify and Windows `ReadDirectoryChangesW` are armed
|
||||
* inside `fs.watch()`, so there this is an immediate open and close. Should a
|
||||
* libuv release drop the close-time wait, these cases regain the darwin loss
|
||||
* rate this call removes rather than failing deterministically.
|
||||
*/
|
||||
function ensureNativeWatchLive(dir: string): void {
|
||||
fs.watch(dir).close()
|
||||
}
|
||||
|
||||
/** Poll `test` every 10 ms under the calling case's effective budget. */
|
||||
async function eventually(test: () => boolean, message: string, budget: number): Promise<void> {
|
||||
const deadline = Date.now() + budget
|
||||
while (!test()) {
|
||||
if (Date.now() >= deadline) throw new Error(message)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
@@ -109,12 +131,14 @@ describe('HMR exact config paths', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
|
||||
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async ({ task, onTestFailed }) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
hmrRoots.push(dir)
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const observed: string[] = []
|
||||
// The case budget fires before eventually() can name the pending step.
|
||||
onTestFailed(() => { console.error(`refresh observed ${JSON.stringify(observed)}`) })
|
||||
try {
|
||||
await watchConfig(ctx, filename, {}, () => {
|
||||
try {
|
||||
@@ -124,38 +148,42 @@ describe('HMR exact config paths', () => {
|
||||
observed.push('missing')
|
||||
}
|
||||
})
|
||||
ensureNativeWatchLive(dir)
|
||||
|
||||
writeFileSync(filename, 'one', { flag: 'wx' })
|
||||
await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
|
||||
await eventually(() => observed.includes('one'), 'HMR did not observe config creation', task.timeout)
|
||||
writeFileSync(filename, 'two')
|
||||
await eventually(() => observed.includes('two'), 'HMR did not observe config change')
|
||||
await eventually(() => observed.includes('two'), 'HMR did not observe config change', task.timeout)
|
||||
unlinkSync(filename)
|
||||
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
|
||||
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal', task.timeout)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
|
||||
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async ({ task, onTestFailed }) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
hmrRoots.push(root)
|
||||
const dir = join(root, 'later')
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(root)
|
||||
const observed: string[] = []
|
||||
// The case budget fires before eventually() can name the pending step.
|
||||
onTestFailed(() => { console.error(`refresh observed ${JSON.stringify(observed)}`) })
|
||||
try {
|
||||
await watchConfig(ctx, filename, {}, () => {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
})
|
||||
ensureNativeWatchLive(root)
|
||||
mkdirSync(dir)
|
||||
writeFileSync(filename, 'created')
|
||||
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
|
||||
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent', task.timeout)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('processes native events for a watcher registered during a transaction', async () => {
|
||||
it('processes native events for a watcher registered during a transaction', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-transaction-watch-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
|
||||
@@ -168,6 +196,7 @@ describe('HMR exact config paths', () => {
|
||||
await hmr.runExclusive(() => hmr.watchConfig(filename, async () => {
|
||||
observed.resolve(readFileSync(filename, 'utf8'))
|
||||
}))
|
||||
ensureNativeWatchLive(dir)
|
||||
writeFileSync(filename, 'created-after-transaction')
|
||||
expect(await observed.promise).toBe('created-after-transaction')
|
||||
})
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: b3ad7867f23dad1c
|
||||
/deepseek-ai-dsh-client-ui-agent-preset/use-this-package:
|
||||
en: cf381c4f00b42c3f
|
||||
zh: 1b226687dcbd4875
|
||||
en: a50da1a9dfdaf624
|
||||
zh: faaffc5fbeec446b
|
||||
/deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation:
|
||||
en: 7813f0063c15e241
|
||||
zh: 6eabc242edfc45ec
|
||||
|
||||
@@ -27,7 +27,7 @@ Choose Agent presets and the new-task default in Web, read what each mode does a
|
||||
|
||||
Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists.
|
||||
|
||||
Coding Tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears and the cards refuse selection, while the saved default keeps composing new tasks. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset.
|
||||
Coding Tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears, while the settings cards still choose the saved default that keeps composing new tasks and Creator stays available. With Coding Tools on, choosing a healthy default also synchronizes the blank session on the current new-task surface; with them off, a reused blank session keeps its preset. Creator starts a new task using the `cordis` preset.
|
||||
|
||||
Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
|
||||
设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。
|
||||
|
||||
通用设置中的代码工作工具决定能否选择模式:关闭后新会话选择器消失、卡片拒绝选择,而已保存的默认值继续用于新任务。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。
|
||||
通用设置中的代码工作工具决定能否选择模式:关闭后新会话选择器消失,而设置卡片仍可选择继续用于新任务的已保存默认值,Creator 入口也保持可用。开启时,选择健康定义作为默认值也会同步当前新任务页面的空白会话;关闭时,被复用的空白会话保留原预设。Creator 入口开启一个使用 `cordis` preset 的新任务。
|
||||
|
||||
已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
}
|
||||
|
||||
|
||||
.card:hover:not(.cardActive):not(.cardSelectionDisabled) {
|
||||
.card:hover:not(.cardActive) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@@ -75,14 +75,6 @@
|
||||
|
||||
/* Mode selection is off, not the preset itself: mute only the selectable
|
||||
body while the independent view/copy/location actions stay fully usable. */
|
||||
.cardSelectionDisabled {
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.cardSelectionDisabled .cardMain {
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
/* A broken preset reads as damaged before anything else: the card cannot be
|
||||
picked, so its border carries the warning the disabled body cannot. */
|
||||
.cardBroken {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button, IconBrowseOutlineRegular, IconPlusOutlineRegular, Modal, Tag, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AgentPresetSectionState } from './section-store.ts'
|
||||
import { isBuiltInPreset, presetDisplayText } from './locales.ts'
|
||||
@@ -15,8 +15,6 @@ import css from './AgentPresetSection.module.css'
|
||||
export interface AgentPresetSectionInjected {
|
||||
hooks: {
|
||||
agentPresetSection: SnapshotStore<AgentPresetSectionState>
|
||||
/** Shared Developer tools preference; off hides every selection action. */
|
||||
developerTools: ObservableSnapshot<boolean>
|
||||
}
|
||||
/** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */
|
||||
startCreatorDraft?: () => void
|
||||
@@ -62,10 +60,9 @@ function CardDescription({ text }: { text: string }): ReactNode {
|
||||
*/
|
||||
export function AgentPresetSection({
|
||||
useAgentPresetSection, load, view, closeView, makeDefault, startCreatorDraft,
|
||||
close: closeSettings, useDeveloperTools, t,
|
||||
close: closeSettings, t,
|
||||
}: AgentPresetSectionProps) {
|
||||
const state = useAgentPresetSection(value => value)
|
||||
const developerTools = useDeveloperTools(enabled => enabled)
|
||||
const [guide, setGuide] = useState<{
|
||||
content: NonNullable<ReturnType<typeof presetGuide>>
|
||||
page: PresetGuidePage
|
||||
@@ -90,8 +87,7 @@ export function AgentPresetSection({
|
||||
<button
|
||||
type="button"
|
||||
className={css.creatorButton}
|
||||
disabled={!developerTools || state.saving}
|
||||
title={developerTools ? undefined : t('enableDevToolsToCreate')}
|
||||
disabled={state.saving}
|
||||
onClick={() => { creator(); closeSettings() }}
|
||||
>
|
||||
<IconPlusOutlineRegular size={14} />
|
||||
@@ -113,15 +109,13 @@ export function AgentPresetSection({
|
||||
const display = presetDisplayText(row, t)
|
||||
const help = presetGuide(row.id, builtIn ? 'system' : 'user')
|
||||
const selectionAction = row.broken !== undefined ? t('brokenBadge')
|
||||
: row.isDefault ? t('inUse')
|
||||
: t(developerTools ? 'setDefault' : 'enableDevToolsToSetDefault')
|
||||
: t(row.isDefault ? 'inUse' : 'setDefault')
|
||||
return <li key={row.id} data-agent-preset-id={row.id} className={[
|
||||
css.card, row.broken === undefined ? undefined : css.cardBroken,
|
||||
row.isDefault ? css.cardActive : undefined,
|
||||
!developerTools && row.broken === undefined && !row.isDefault ? css.cardSelectionDisabled : undefined,
|
||||
].filter(Boolean).join(' ')}>
|
||||
<button type="button" className={css.cardMain} aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault || (row.broken === undefined && (!developerTools || state.saving))}
|
||||
disabled={row.isDefault || (row.broken === undefined && state.saving)}
|
||||
aria-disabled={row.broken !== undefined} aria-label={`${selectionAction}: ${display.name}`} title={selectionAction}
|
||||
onClick={() => { if (row.broken === undefined) void makeDefault(row.id) }}>
|
||||
<span className={css.cardHead}>
|
||||
|
||||
@@ -209,7 +209,7 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
|
||||
const sectionInjected = (): AgentPresetSectionInjected => ({
|
||||
hooks: { agentPresetSection: section.store, developerTools: ctx.configForms.developerTools.enabled },
|
||||
hooks: { agentPresetSection: section.store },
|
||||
load: () => section.load(),
|
||||
view: (id: string) => section.view(id),
|
||||
closeView: () => { section.closeView() },
|
||||
|
||||
@@ -27,8 +27,6 @@ export type AgentPresetSettingsKey =
|
||||
| 'switchRefused'
|
||||
| 'close'
|
||||
| 'creatorDraft'
|
||||
| 'enableDevToolsToSetDefault'
|
||||
| 'enableDevToolsToCreate'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
@@ -67,8 +65,6 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
|
||||
creatorDraft: 'Let the agent help me create a preset',
|
||||
|
||||
enableDevToolsToSetDefault: 'Turn on Coding Tools in General settings to choose a default',
|
||||
enableDevToolsToCreate: 'Turn on Coding Tools in General settings to start Creator mode',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
@@ -104,8 +100,6 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
|
||||
creatorDraft: '让 Agent 帮我创建预设模式',
|
||||
|
||||
enableDevToolsToSetDefault: '请先在通用设置中开启代码工作工具,再设置默认值',
|
||||
enableDevToolsToCreate: '请先在通用设置中开启代码工作工具,再启动创造模式',
|
||||
}
|
||||
|
||||
// The resolution itself is the shared fold in `dsh-agent-preset-registry/display`,
|
||||
|
||||
@@ -12,8 +12,7 @@ const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
|
||||
function unusedHook(): never {
|
||||
throw new Error('This section does not read global slot sources')
|
||||
}
|
||||
function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?: () => void, developerTools = true,
|
||||
outerClose?: () => void) {
|
||||
function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?: () => void, outerClose?: () => void) {
|
||||
const store = createSnapshotStore<AgentPresetSectionState>({ status: 'ready', error: null,
|
||||
saving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }],
|
||||
view: null, ...partial })
|
||||
@@ -24,7 +23,6 @@ function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?
|
||||
usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook,
|
||||
useWorkspaces: unusedHook, useResource: unusedHook,
|
||||
useAgentPresetSection: bindSnapshotSelector(store),
|
||||
useDeveloperTools: bindSnapshotSelector(createSnapshotStore(developerTools)),
|
||||
t: key => translations.get(key) ?? key }
|
||||
render(outerClose === undefined ? <AgentPresetSection {...props} />
|
||||
: <Modal open onClose={outerClose} title="Settings" closeLabel="Close"><AgentPresetSection {...props} /></Modal>)
|
||||
@@ -35,14 +33,9 @@ function rowFor(id: string): HTMLElement {
|
||||
if (row === null) throw new Error(`no card for ${id}`)
|
||||
return row
|
||||
}
|
||||
it('offers no selection switch and disables the card actions while Developer tools are off', () => {
|
||||
view({}, undefined, false)
|
||||
|
||||
expect(screen.queryByRole('switch')).toBeNull()
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: `${en.enableDevToolsToSetDefault}: Mine` }).disabled).toBe(true)
|
||||
})
|
||||
it('reads the roster once and sets a default from the card body', async () => {
|
||||
const actions = view()
|
||||
expect(screen.queryByRole('switch')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.setDefault}: Mine` }))
|
||||
expect(actions.makeDefault).toHaveBeenCalledWith('mine')
|
||||
await waitFor(() =>{ expect(actions.load).toHaveBeenCalledOnce() })
|
||||
@@ -82,15 +75,6 @@ it('offers no Creator entry without the conversation flow or the cordis preset',
|
||||
view({}, vi.fn())
|
||||
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
|
||||
})
|
||||
it('disables the Creator entry while Developer tools are off', () => {
|
||||
const launch = vi.fn()
|
||||
view({ rows: [{ id: 'cordis', isDefault: true }] }, launch, false)
|
||||
const button = screen.getByRole<HTMLButtonElement>('button', { name: en.creatorDraft })
|
||||
expect(button.disabled).toBe(true)
|
||||
expect(button.title).toBe(en.enableDevToolsToCreate)
|
||||
fireEvent.click(button)
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
})
|
||||
it('reads a declared composition read-only from every card, broken ones included', () => {
|
||||
const actions = view({ rows: [{ id: 'standard', isDefault: true }, { id: 'broken', isDefault: false, broken: 'Missing plugin' }] })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
@@ -114,7 +98,7 @@ it('shows the open composition under the preset display name, without copy, and
|
||||
})
|
||||
it('keeps Escape inside the composition viewer when Settings is also open', () => {
|
||||
const closeSettings = vi.fn()
|
||||
const actions = view({}, undefined, true, closeSettings)
|
||||
const actions = view({}, undefined, closeSettings)
|
||||
const trigger = within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
@@ -181,7 +165,7 @@ it.each([
|
||||
})
|
||||
it('keeps keyboard focus in help and dismisses only the reader on Escape', () => {
|
||||
const closeSettings = vi.fn()
|
||||
const actions = view({}, undefined, true, closeSettings)
|
||||
const actions = view({}, undefined, closeSettings)
|
||||
const trigger = within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
@@ -225,12 +209,6 @@ it('does not attach built-in claims to named or unknown presets', () => {
|
||||
expect(screen.queryByRole('button', { name: new RegExp(en.modeExplanation) })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: new RegExp(en.howToUse) })).toBeNull()
|
||||
})
|
||||
it('leaves help usable while Developer tools are off', () => {
|
||||
const actions = view({}, undefined, false)
|
||||
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.howToUse}: ${en.presetStandardName}` }))
|
||||
expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy()
|
||||
expect(actions.makeDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
it('closes help even when the browser reports no previously focused element', () => {
|
||||
const descriptor: TypedPropertyDescriptor<Element | null> = Object.getOwnPropertyDescriptor(Document.prototype, 'activeElement')!
|
||||
const readActiveElement = descriptor.get!.bind(document)
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 2157fcd6b0d100b5
|
||||
zh: 9e865ac3953ebe30
|
||||
/deepseek-ai-dsh-client-ui-chat/turn-token-usage:
|
||||
en: e1dd055d68ab25ea
|
||||
zh: c5b62a22c9e457cb
|
||||
en: 11436f75c377ed1b
|
||||
zh: 42707b8efc201dad
|
||||
/deepseek-ai-dsh-client-ui-chat/completed-turn-footer:
|
||||
en: b4cd36e7ab91281e
|
||||
zh: 8ccf412b9dde61d5
|
||||
|
||||
@@ -58,7 +58,7 @@ Generic command rows retain the ordinary command glyph in every lifecycle state;
|
||||
|
||||
A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total.
|
||||
|
||||
Settings → General → Performance & usage appears immediately before Current version and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact.
|
||||
Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact.
|
||||
|
||||
On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,
|
||||
|
||||
只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。
|
||||
|
||||
设置 → 通用设置 → 性能与用量位于当前版本之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。
|
||||
设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。
|
||||
|
||||
在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ export function apply(ctx: Context): void {
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'performance-usage',
|
||||
order: 95,
|
||||
order: 30,
|
||||
locale: NS,
|
||||
inject: (): PerformanceUsageRowInjected => ({
|
||||
hooks: { performanceUsage },
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 01adce72791579fa
|
||||
zh: 70eb55a1529038e7
|
||||
/deepseek-ai-dsh-client-ui-commands/use-this-package/kinds-and-decorations:
|
||||
en: 86841942f6989fbb
|
||||
zh: 901557d3a5d0528e
|
||||
en: 57679a20574d8b3f
|
||||
zh: 98d5e5a7781e6de1
|
||||
/deepseek-ai-dsh-client-ui-commands/use-this-package/built-in-row-faces:
|
||||
en: e96156be95ea87e3
|
||||
zh: f5350db26483ec9f
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 4feecdbec0c3f39e
|
||||
zh: 618944ecade8a3a2
|
||||
/deepseek-ai-dsh-client-ui-commands/understand-the-implementation:
|
||||
en: 0fd2b19e41605440
|
||||
zh: 52d5c653b877a1f0
|
||||
en: 3aeeba6c91c3ef19
|
||||
zh: d3afb3df6d15e17b
|
||||
/deepseek-ai-dsh-client-ui-commands/further-exploration:
|
||||
en: 409c04f534d23de7
|
||||
zh: 165022d7a0a554eb
|
||||
|
||||
@@ -31,6 +31,12 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so
|
||||
|
||||
A contribution is a client-owned command; a host-name collision fails loudly. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, without submitting a message. Business packages own their actions and availability; the composer registers File through this same API. A decoration adds a bare-invocation popup or action to an existing host command while preserving its catalog row, argument claim, and lifecycle logging; it never fires without a matching host row. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names and titles, with prefixes first and no section headings.
|
||||
|
||||
A popupSelect spec may supply `searchLabels()` to resolve its placeholder, empty-catalog text, and no-match text for each opening. Omitted labels retain the shell's generic copy; labels from one popup never carry into the next.
|
||||
|
||||
Options may carry `group: { name, label }`: `name` identifies the group and `label` supplies its localized sticky heading. Equal names share one group; groups retain first-occurrence order in the loaded options, including when a query hides earlier rows. Ungrouped options form one headingless block at their first occurrence. Groups without matching rows are hidden.
|
||||
|
||||
The optional `searchMode` defaults to `'substring'`, matching `label` and `detail` case-insensitively without ranking. `'fuzzy-label'` matches only labels as ordered, case-insensitive subsequences and ranks within each group: prefixes first, then alignment score, then original row order. Both modes ignore leading and trailing query spaces; a blank query keeps every row in group order. Rendering, keyboard navigation, and selection use the same filtered order. `/model` opts into `'fuzzy-label'`; other commands retain substring matching unless they opt in.
|
||||
|
||||
### Built-in row faces
|
||||
|
||||
First-party command definitions carry stable `definitionId` values. The client selects their localized titles, descriptions, icons, and input spellings by identity; changing a Host description cannot change that selection. Same-name overrides without the matching identity keep their own copy and receive no first-party aliases. Chinese and English spellings resolve through the same effective Session catalog in every locale, preserving the typed spelling in the draft and submitting the registered Host name. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass. Empty-query section order follows names, with unlisted rows closing Commands.
|
||||
@@ -44,7 +50,7 @@ When the composer submits with images or generic files, only a host command decl
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
|
||||
Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; an ordinary effect owns the asynchronous observation and rebuilds it when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
@@ -31,6 +31,12 @@ kind: "package-reference"
|
||||
|
||||
贡献项是客户端自有命令,与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用消费触发 token 后运行回调,不提交消息。业务包负责自己的动作及可用性,输入框通过同一 API 注册「文件」。装饰为已有宿主命令添加裸调用弹窗或动作,并保留其目录行、参数认领与生命周期记录;没有匹配的宿主行时不触发。菜单查询按顺序、不区分大小写地模糊匹配命令名与标题的子序列,前缀优先,不显示小节标题。
|
||||
|
||||
popupSelect 规格可提供 `searchLabels()`,在每次打开时解析占位文字、空目录提示和无匹配提示。未提供时保留外壳的通用文案;一个弹窗的文案不会沿用到下一个弹窗。
|
||||
|
||||
选项可携带 `group: { name, label }`:`name` 标识分组,`label` 提供本地化吸顶标题。同名选项归入同一组;分组保持在已加载选项中首次出现的顺序,即使查询隐藏了较早的行也不变。未分组选项在首次出现的位置组成一个无标题区块。没有匹配行的分组会隐藏。
|
||||
|
||||
可选的 `searchMode` 默认为 `'substring'`,不区分大小写地匹配 `label` 与 `detail` 的子串,不进行相关性排序。`'fuzzy-label'` 仅匹配标签中按顺序、不区分大小写的子序列,并在组内排序:前缀优先,其次按匹配得分,最后按原始行顺序。两种模式均忽略查询首尾空格;空查询按分组顺序保留所有行。渲染、键盘导航与选择使用相同的筛选顺序。`/model` 启用 `'fuzzy-label'`;其他命令未显式启用时仍使用子串匹配。
|
||||
|
||||
### 内置行的展示面
|
||||
|
||||
内置命令定义携带稳定的 `definitionId`。客户端按标识选择本地化标题、说明、图标和输入写法,修改宿主说明不会改变选择结果。没有匹配标识的同名覆盖保留自己的文案,也不获得内置别名。在任何界面语言下,中英文写法都通过同一个会话有效目录解析,草稿保留手输写法,提交使用宿主注册名。贡献项提供自己的 `label`、`description` 和 `icon`,每次生成候选项时读取。空查询按名称确定小节顺序,未列出的行排在「指令」末尾。
|
||||
@@ -44,7 +50,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
|
||||
弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;普通 effect 负责异步观察,并在渲染分组变化时清理和重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user